feat: secure convex admin flows with real metrics\n\nCo-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

This commit is contained in:
esdrasrenan 2025-10-05 19:59:24 -03:00
parent 0ec5b49e8a
commit 29a647f6c6
43 changed files with 4992 additions and 363 deletions

View file

@ -42,22 +42,25 @@ Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garanti
- SLAs (BullMQ + Redis), notificacoes, ingest de e-mail, portal cliente, etc.
## Backlog imediato
- [ ] Expor portal do cliente com listagem de tickets filtrada por `viewerId` (Convex + UI)
- [ ] Completar painel administrativo (gestão de agentes, filas, categorias) com RBAC server/client
- [x] Expor portal do cliente com listagem de tickets filtrada por `viewerId` (Convex + UI)
- [x] Completar painel administrativo (times, filas, campos e SLAs) com RBAC server/client
- [ ] Consolidar sincronização Better Auth ↔ Convex para fluxo de convites e resets de senha
- [ ] Expandir suite de testes (UI + Convex) cobrindo guardas e mapeadores críticos
- [ ] Expandir suite de testes (UI + Convex) cobrindo guardas, relatórios e mapeadores críticos
- [ ] Implementar fluxo completo de convites (criação, envio, revogação e aceite) para administradores
- [ ] Habilitar ações avançadas para agentes (edição de categorias, reassigação rápida) com as devidas permissões
### Iniciativa atual — Autenticação real e personas
- [x] Migrar placeholder para Better Auth + Prisma (handlers Next, cliente React e sync Convex).
- [x] Expor roles (`admin`, `agent`, `customer`) e aplicar guardas (`requireUser/Staff/Admin/Customer`) no Convex.
- [x] Ajustar middleware e componentes para usar `viewerId`/`actorId`, evitando vazamento de dados entre tenants.
- [ ] Criar portal do cliente para abertura/consulta de chamados e comentários públicos.
- [ ] Consolidar painel administrativo (agentes, filas, categorias) com fluxos completos de convite.
- [x] Criar portal do cliente para abertura/consulta de chamados e comentários públicos.
- [x] Consolidar painel administrativo (times, filas, campos e SLAs) com UI protegida por RBAC completo.
- [ ] Entregar fluxo de convites Better Auth (criação, envio, revogação) e gerenciamento de agentes.
## Proximas entregas sugeridas
1. Entregar portal do cliente (listagem, detalhes e criação de ticket) consumindo RBAC e mapeadores atualizados.
2. Evoluir painel administrativo com gerenciamento de filas/categorias e convites com Better Auth.
3. Introduzir relatórios e métricas (workSummary, SLA) protegidos por `requireStaff/requireAdmin`.
1. Finalizar onboarding/offboarding de agentes com convites Better Auth, sincronização Convex e trilhas de auditoria.
2. Evoluir painel administrativo com gestão de categorias avançadas e permissões granulares para agentes.
3. Expandir relatórios operacionais (workSummary, tendências de SLA/CSAT) com alertas e comparativos configuráveis.
4. Automatizar pipeline CI (lint + vitest) integrando checagens obrigatórias.
5. Revisar UX dos fluxos de atendimento (play next, comentários) com feedback otimista e trilha de auditoria.
@ -364,6 +367,13 @@ Manter este arquivo atualizado ao concluir cada item estratégico ou quando surg
- Toast de autenticação inválida agora informa "E-mail ou senha inválidos", alinhando o feedback com o restante da interface.
### Próximos passos imediatos
- [ ] Consolidar o painel administrativo com fluxo completo de convites (criação, exibição e revogação) utilizando Better Auth.
- [ ] Iniciar o portal do cliente com listagem de tickets filtrada por `viewerId` e detalhamento básico.
- [ ] Cobrir o fluxo de autenticação (login/convite/reset) com testes Vitest focados em regressões críticas.
- [ ] Implementar fluxo completo de convites (criação, expiração, revogação) integrado ao Better Auth e Convex.
- [ ] Adicionar testes Vitest/E2E cobrindo dashboards, relatórios e guardas de RBAC no front.
- [ ] Mapear permissões de edição avançada para agentes (categorias, campos rápidos) antes de liberar novas mutações.
## Atualizações recentes (jun/2026)
- RBAC do Convex reforçado em times, filas, campos, SLAs e relatórios; todas as chamadas exigem `viewerId`/`actorId` conforme o papel (admin ou staff).
- Painel administrativo atualizado para consumir as novas assinaturas protegidas, com validações de sessão Better Auth e feedback de toasts.
- Dashboard principal passou a exibir métricas reais via `reports.dashboardOverview` e séries históricas por canal com `reports.ticketsByChannel`.
- Portal do cliente publicado com isolamento por `viewerId`, garantindo que clientes visualizem apenas seus chamados.

View file

@ -10,10 +10,14 @@
import type * as bootstrap from "../bootstrap.js";
import type * as categories from "../categories.js";
import type * as fields from "../fields.js";
import type * as files from "../files.js";
import type * as queues from "../queues.js";
import type * as rbac from "../rbac.js";
import type * as reports from "../reports.js";
import type * as seed from "../seed.js";
import type * as slas from "../slas.js";
import type * as teams from "../teams.js";
import type * as tickets from "../tickets.js";
import type * as users from "../users.js";
@ -34,10 +38,14 @@ import type {
declare const fullApi: ApiFromModules<{
bootstrap: typeof bootstrap;
categories: typeof categories;
fields: typeof fields;
files: typeof files;
queues: typeof queues;
rbac: typeof rbac;
reports: typeof reports;
seed: typeof seed;
slas: typeof slas;
teams: typeof teams;
tickets: typeof tickets;
users: typeof users;
}>;

209
web/convex/fields.ts Normal file
View file

@ -0,0 +1,209 @@
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 } from "./rbac";
const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const;
type FieldType = (typeof FIELD_TYPES)[number];
function normalizeKey(label: string) {
return label
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "_")
.replace(/_+/g, "_");
}
type AnyCtx = QueryCtx | MutationCtx;
async function ensureUniqueKey(ctx: AnyCtx, tenantId: string, key: string, excludeId?: Id<"ticketFields">) {
const existing = await ctx.db
.query("ticketFields")
.withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key))
.first();
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe um campo com este identificador");
}
}
function validateOptions(type: FieldType, options: { value: string; label: string }[] | undefined) {
if (type === "select" && (!options || options.length === 0)) {
throw new ConvexError("Campos de seleção precisam de pelo menos uma opção");
}
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId);
const fields = await ctx.db
.query("ticketFields")
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
.collect();
return fields
.sort((a, b) => a.order - b.order)
.map((field) => ({
id: field._id,
key: field.key,
label: field.label,
description: field.description ?? "",
type: field.type as FieldType,
required: field.required,
options: field.options ?? [],
order: field.order,
createdAt: field.createdAt,
updatedAt: field.updatedAt,
}));
},
});
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
label: v.string(),
description: v.optional(v.string()),
type: v.string(),
required: v.boolean(),
options: v.optional(
v.array(
v.object({
value: v.string(),
label: v.string(),
})
)
),
},
handler: async (ctx, { tenantId, actorId, label, description, type, required, options }) => {
await requireAdmin(ctx, actorId, tenantId);
const normalizedLabel = label.trim();
if (normalizedLabel.length < 2) {
throw new ConvexError("Informe um rótulo para o campo");
}
if (!FIELD_TYPES.includes(type as FieldType)) {
throw new ConvexError("Tipo de campo inválido");
}
validateOptions(type as FieldType, options ?? undefined);
const key = normalizeKey(normalizedLabel);
await ensureUniqueKey(ctx, tenantId, key);
const existing = await ctx.db
.query("ticketFields")
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
.collect();
const maxOrder = existing.reduce((acc: number, item: Doc<"ticketFields">) => Math.max(acc, item.order ?? 0), 0);
const now = Date.now();
const id = await ctx.db.insert("ticketFields", {
tenantId,
key,
label: normalizedLabel,
description,
type,
required,
options,
order: maxOrder + 1,
createdAt: now,
updatedAt: now,
});
return id;
},
});
export const update = mutation({
args: {
tenantId: v.string(),
fieldId: v.id("ticketFields"),
actorId: v.id("users"),
label: v.string(),
description: v.optional(v.string()),
type: v.string(),
required: v.boolean(),
options: v.optional(
v.array(
v.object({
value: v.string(),
label: v.string(),
})
)
),
},
handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options }) => {
await requireAdmin(ctx, actorId, tenantId);
const field = await ctx.db.get(fieldId);
if (!field || field.tenantId !== tenantId) {
throw new ConvexError("Campo não encontrado");
}
if (!FIELD_TYPES.includes(type as FieldType)) {
throw new ConvexError("Tipo de campo inválido");
}
const normalizedLabel = label.trim();
if (normalizedLabel.length < 2) {
throw new ConvexError("Informe um rótulo para o campo");
}
validateOptions(type as FieldType, options ?? undefined);
let key = field.key;
if (field.label !== normalizedLabel) {
key = normalizeKey(normalizedLabel);
await ensureUniqueKey(ctx, tenantId, key, fieldId);
}
await ctx.db.patch(fieldId, {
key,
label: normalizedLabel,
description,
type,
required,
options,
updatedAt: Date.now(),
});
},
});
export const remove = mutation({
args: {
tenantId: v.string(),
fieldId: v.id("ticketFields"),
actorId: v.id("users"),
},
handler: async (ctx, { tenantId, fieldId, actorId }) => {
await requireAdmin(ctx, actorId, tenantId);
const field = await ctx.db.get(fieldId);
if (!field || field.tenantId !== tenantId) {
throw new ConvexError("Campo não encontrado");
}
await ctx.db.delete(fieldId);
},
});
export const reorder = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
orderedIds: v.array(v.id("ticketFields")),
},
handler: async (ctx, { tenantId, actorId, orderedIds }) => {
await requireAdmin(ctx, actorId, tenantId);
const fields = await Promise.all(orderedIds.map((id) => ctx.db.get(id)));
fields.forEach((field) => {
if (!field || field.tenantId !== tenantId) {
throw new ConvexError("Campo inválido para reordenação");
}
});
const now = Date.now();
await Promise.all(
orderedIds.map((fieldId, index) =>
ctx.db.patch(fieldId, {
order: index + 1,
updatedAt: now,
})
)
);
},
});

View file

@ -1,5 +1,9 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { requireAdmin, requireStaff } from "./rbac";
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados",
@ -15,11 +19,75 @@ function renameQueueString(value: string) {
return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value;
}
function slugify(value: string) {
return value
.normalize("NFD")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.toLowerCase();
}
type AnyCtx = QueryCtx | MutationCtx;
async function ensureUniqueSlug(ctx: AnyCtx, tenantId: string, slug: string, excludeId?: Id<"queues">) {
const existing = await ctx.db
.query("queues")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first();
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe uma fila com este identificador");
}
}
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"queues">) {
const existing = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("name"), name))
.first();
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe uma fila com este nome");
}
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId);
const queues = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
const teams = await ctx.db
.query("teams")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
.collect();
return queues.map((queue) => {
const team = queue.teamId ? teams.find((item) => item._id === queue.teamId) : null;
return {
id: queue._id,
name: queue.name,
slug: queue.slug,
team: team
? {
id: team._id,
name: team.name,
}
: null,
};
});
},
});
export const summary = query({
args: { tenantId: v.string() },
handler: async (ctx, { tenantId }) => {
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId);
const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect();
// Compute counts per queue
const result = await Promise.all(
queues.map(async (qItem) => {
const pending = await ctx.db
@ -28,7 +96,7 @@ export const summary = query({
.collect();
const waiting = pending.filter((t) => t.status === "PENDING" || t.status === "ON_HOLD").length;
const open = pending.filter((t) => t.status !== "RESOLVED" && t.status !== "CLOSED").length;
const breached = 0; // Placeholder, SLAs later
const breached = 0;
return { id: qItem._id, name: renameQueueString(qItem.name), pending: open, waiting, breached };
})
);
@ -36,3 +104,98 @@ export const summary = query({
},
});
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
teamId: v.optional(v.id("teams")),
},
handler: async (ctx, { tenantId, actorId, name, teamId }) => {
await requireAdmin(ctx, actorId, tenantId);
const trimmed = name.trim();
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a fila");
}
await ensureUniqueName(ctx, tenantId, trimmed);
const slug = slugify(trimmed);
await ensureUniqueSlug(ctx, tenantId, slug);
if (teamId) {
const team = await ctx.db.get(teamId);
if (!team || team.tenantId !== tenantId) {
throw new ConvexError("Time inválido");
}
}
const id = await ctx.db.insert("queues", {
tenantId,
name: trimmed,
slug,
teamId: teamId ?? undefined,
});
return id;
},
});
export const update = mutation({
args: {
queueId: v.id("queues"),
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
teamId: v.optional(v.id("teams")),
},
handler: async (ctx, { queueId, tenantId, actorId, name, teamId }) => {
await requireAdmin(ctx, actorId, tenantId);
const queue = await ctx.db.get(queueId);
if (!queue || queue.tenantId !== tenantId) {
throw new ConvexError("Fila não encontrada");
}
const trimmed = name.trim();
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a fila");
}
await ensureUniqueName(ctx, tenantId, trimmed, queueId);
let slug = queue.slug;
if (queue.name !== trimmed) {
slug = slugify(trimmed);
await ensureUniqueSlug(ctx, tenantId, slug, queueId);
}
if (teamId) {
const team = await ctx.db.get(teamId);
if (!team || team.tenantId !== tenantId) {
throw new ConvexError("Time inválido");
}
}
await ctx.db.patch(queueId, {
name: trimmed,
slug,
teamId: teamId ?? undefined,
});
},
});
export const remove = mutation({
args: {
queueId: v.id("queues"),
tenantId: v.string(),
actorId: v.id("users"),
},
handler: async (ctx, { queueId, tenantId, actorId }) => {
await requireAdmin(ctx, actorId, tenantId);
const queue = await ctx.db.get(queueId);
if (!queue || queue.tenantId !== tenantId) {
throw new ConvexError("Fila não encontrada");
}
const ticketUsingQueue = await ctx.db
.query("tickets")
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId))
.first();
if (ticketUsingQueue) {
throw new ConvexError("Não é possível remover uma fila vinculada a tickets");
}
await ctx.db.delete(queueId);
},
});

341
web/convex/reports.ts Normal file
View file

@ -0,0 +1,341 @@
import { query } from "./_generated/server";
import type { QueryCtx } from "./_generated/server";
import { v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { requireStaff } from "./rbac";
function average(values: number[]) {
if (values.length === 0) return null;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
const OPEN_STATUSES = new Set(["NEW", "OPEN", "PENDING", "ON_HOLD"]);
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
function percentageChange(current: number, previous: number) {
if (previous === 0) {
return current === 0 ? 0 : null;
}
return ((current - previous) / previous) * 100;
}
function extractScore(payload: unknown): number | null {
if (typeof payload === "number") return payload;
if (payload && typeof payload === "object" && "score" in payload) {
const value = (payload as { score: unknown }).score;
if (typeof value === "number") {
return value;
}
}
return null;
}
function isNotNull<T>(value: T | null): value is T {
return value !== null;
}
async function fetchTickets(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
}
async function fetchQueues(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
}
type CsatSurvey = {
ticketId: Id<"tickets">;
reference: number;
score: number;
receivedAt: number;
};
async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise<CsatSurvey[]> {
const perTicket = await Promise.all(
tickets.map(async (ticket) => {
const events = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
.collect();
return events
.filter((event) => event.type === "CSAT_RECEIVED" || event.type === "CSAT_RATED")
.map((event) => {
const score = extractScore(event.payload);
if (score === null) return null;
return {
ticketId: ticket._id,
reference: ticket.reference,
score,
receivedAt: event.createdAt,
} as CsatSurvey;
})
.filter(isNotNull);
})
);
return perTicket.flat();
}
function formatDateKey(timestamp: number) {
const date = new Date(timestamp);
const year = date.getUTCFullYear();
const month = `${date.getUTCMonth() + 1}`.padStart(2, "0");
const day = `${date.getUTCDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
}
export const slaOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
const queues = await fetchQueues(ctx, tenantId);
const now = Date.now();
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status));
const resolvedTickets = tickets.filter((ticket) => ticket.status === "RESOLVED" || ticket.status === "CLOSED");
const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
const firstResponseTimes = tickets
.filter((ticket) => ticket.firstResponseAt)
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
const resolutionTimes = resolvedTickets
.filter((ticket) => ticket.resolvedAt)
.map((ticket) => (ticket.resolvedAt! - ticket.createdAt) / 60000);
const queueBreakdown = queues.map((queue) => {
const count = openTickets.filter((ticket) => ticket.queueId === queue._id).length;
return {
id: queue._id,
name: queue.name,
open: count,
};
});
return {
totals: {
total: tickets.length,
open: openTickets.length,
resolved: resolvedTickets.length,
overdue: overdueTickets.length,
},
response: {
averageFirstResponseMinutes: average(firstResponseTimes),
responsesRegistered: firstResponseTimes.length,
},
resolution: {
averageResolutionMinutes: average(resolutionTimes),
resolvedCount: resolutionTimes.length,
},
queueBreakdown,
};
},
});
export const csatOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
const surveys = await collectCsatSurveys(ctx, tickets);
const averageScore = average(surveys.map((item) => item.score));
const distribution = [1, 2, 3, 4, 5].map((score) => ({
score,
total: surveys.filter((item) => item.score === score).length,
}));
return {
totalSurveys: surveys.length,
averageScore,
distribution,
recent: surveys
.slice()
.sort((a, b) => b.receivedAt - a.receivedAt)
.slice(0, 10)
.map((item) => ({
ticketId: item.ticketId,
reference: item.reference,
score: item.score,
receivedAt: item.receivedAt,
})),
};
},
});
export const backlogOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
const statusCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
acc[ticket.status] = (acc[ticket.status] ?? 0) + 1;
return acc;
}, {});
const priorityCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
acc[ticket.priority] = (acc[ticket.priority] ?? 0) + 1;
return acc;
}, {});
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status));
const queueMap = new Map<string, { name: string; count: number }>();
for (const ticket of openTickets) {
const queueId = ticket.queueId ? ticket.queueId : "sem-fila";
const current = queueMap.get(queueId) ?? { name: queueId === "sem-fila" ? "Sem fila" : "", count: 0 };
current.count += 1;
queueMap.set(queueId, current);
}
const queues = await fetchQueues(ctx, tenantId);
for (const queue of queues) {
const entry = queueMap.get(queue._id) ?? { name: queue.name, count: 0 };
entry.name = queue.name;
queueMap.set(queue._id, entry);
}
return {
statusCounts,
priorityCounts,
queueCounts: Array.from(queueMap.entries()).map(([id, data]) => ({
id,
name: data.name,
total: data.count,
})),
totalOpen: openTickets.length,
};
},
});
export const dashboardOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, tenantId);
const now = Date.now();
const lastDayStart = now - ONE_DAY_MS;
const previousDayStart = now - 2 * ONE_DAY_MS;
const newTickets = tickets.filter((ticket) => ticket.createdAt >= lastDayStart);
const previousTickets = tickets.filter(
(ticket) => ticket.createdAt >= previousDayStart && ticket.createdAt < lastDayStart
);
const trend = percentageChange(newTickets.length, previousTickets.length);
const lastWindowStart = now - 7 * ONE_DAY_MS;
const previousWindowStart = now - 14 * ONE_DAY_MS;
const firstResponseWindow = tickets
.filter(
(ticket) =>
ticket.createdAt >= lastWindowStart &&
ticket.createdAt < now &&
ticket.firstResponseAt
)
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
const firstResponsePrevious = tickets
.filter(
(ticket) =>
ticket.createdAt >= previousWindowStart &&
ticket.createdAt < lastWindowStart &&
ticket.firstResponseAt
)
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
const averageWindow = average(firstResponseWindow);
const averagePrevious = average(firstResponsePrevious);
const deltaMinutes =
averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null;
const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status));
const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
const surveys = await collectCsatSurveys(ctx, tickets);
const averageScore = average(surveys.map((item) => item.score));
return {
newTickets: {
last24h: newTickets.length,
previous24h: previousTickets.length,
trendPercentage: trend,
},
firstResponse: {
averageMinutes: averageWindow,
previousAverageMinutes: averagePrevious,
deltaMinutes,
responsesCount: firstResponseWindow.length,
},
awaitingAction: {
total: awaitingTickets.length,
atRisk: atRiskTickets.length,
},
csat: {
averageScore,
totalSurveys: surveys.length,
},
};
},
});
export const ticketsByChannel = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
range: v.optional(v.string()),
},
handler: async (ctx, { tenantId, viewerId, range }) => {
await requireStaff(ctx, viewerId, tenantId);
const tickets = await fetchTickets(ctx, 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 timeline = new Map<string, Map<string, number>>();
for (let ts = startMs; ts < endMs; ts += ONE_DAY_MS) {
timeline.set(formatDateKey(ts), new Map());
}
const channels = new Set<string>();
for (const ticket of tickets) {
if (ticket.createdAt < startMs || ticket.createdAt >= endMs) continue;
const dateKey = formatDateKey(ticket.createdAt);
const channelKey = ticket.channel ?? "OUTRO";
channels.add(channelKey);
const dayMap = timeline.get(dateKey) ?? new Map<string, number>();
dayMap.set(channelKey, (dayMap.get(channelKey) ?? 0) + 1);
timeline.set(dateKey, dayMap);
}
const sortedChannels = Array.from(channels).sort();
const points = Array.from(timeline.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([date, map]) => {
const values: Record<string, number> = {};
for (const channel of sortedChannels) {
values[channel] = map.get(channel) ?? 0;
}
return { date, values };
});
return {
rangeDays: days,
channels: sortedChannels,
points,
};
},
});

View file

@ -11,7 +11,8 @@ export default defineSchema({
teams: v.optional(v.array(v.string())),
})
.index("by_tenant_email", ["tenantId", "email"])
.index("by_tenant_role", ["tenantId", "role"]),
.index("by_tenant_role", ["tenantId", "role"])
.index("by_tenant", ["tenantId"]),
queues: defineTable({
tenantId: v.string(),
@ -129,4 +130,27 @@ export default defineSchema({
.index("by_category_order", ["categoryId", "order"])
.index("by_category_slug", ["categoryId", "slug"])
.index("by_tenant_slug", ["tenantId", "slug"]),
ticketFields: defineTable({
tenantId: v.string(),
key: v.string(),
label: v.string(),
type: v.string(),
description: v.optional(v.string()),
required: v.boolean(),
order: v.number(),
options: v.optional(
v.array(
v.object({
value: v.string(),
label: v.string(),
})
)
),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_tenant_key", ["tenantId", "key"])
.index("by_tenant_order", ["tenantId", "order"])
.index("by_tenant", ["tenantId"]),
});

138
web/convex/slas.ts Normal file
View file

@ -0,0 +1,138 @@
import { mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { requireAdmin } from "./rbac";
function normalizeName(value: string) {
return value.trim();
}
type AnyCtx = QueryCtx | MutationCtx;
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) {
const existing = await ctx.db
.query("slaPolicies")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name))
.first();
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe uma política SLA com este nome");
}
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId);
const items = await ctx.db
.query("slaPolicies")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
.collect();
return items.map((policy) => ({
id: policy._id,
name: policy.name,
description: policy.description ?? "",
timeToFirstResponse: policy.timeToFirstResponse ?? null,
timeToResolution: policy.timeToResolution ?? null,
}));
},
});
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()),
timeToResolution: v.optional(v.number()),
},
handler: async (ctx, { tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
await requireAdmin(ctx, actorId, tenantId);
const trimmed = normalizeName(name);
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a política");
}
await ensureUniqueName(ctx, tenantId, trimmed);
if (timeToFirstResponse !== undefined && timeToFirstResponse < 0) {
throw new ConvexError("Tempo para primeira resposta deve ser positivo");
}
if (timeToResolution !== undefined && timeToResolution < 0) {
throw new ConvexError("Tempo para resolução deve ser positivo");
}
const id = await ctx.db.insert("slaPolicies", {
tenantId,
name: trimmed,
description,
timeToFirstResponse,
timeToResolution,
});
return id;
},
});
export const update = mutation({
args: {
policyId: v.id("slaPolicies"),
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
description: v.optional(v.string()),
timeToFirstResponse: v.optional(v.number()),
timeToResolution: v.optional(v.number()),
},
handler: async (ctx, { policyId, tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => {
await requireAdmin(ctx, actorId, tenantId);
const policy = await ctx.db.get(policyId);
if (!policy || policy.tenantId !== tenantId) {
throw new ConvexError("Política não encontrada");
}
const trimmed = normalizeName(name);
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para a política");
}
if (timeToFirstResponse !== undefined && timeToFirstResponse < 0) {
throw new ConvexError("Tempo para primeira resposta deve ser positivo");
}
if (timeToResolution !== undefined && timeToResolution < 0) {
throw new ConvexError("Tempo para resolução deve ser positivo");
}
await ensureUniqueName(ctx, tenantId, trimmed, policyId);
await ctx.db.patch(policyId, {
name: trimmed,
description,
timeToFirstResponse,
timeToResolution,
});
},
});
export const remove = mutation({
args: {
policyId: v.id("slaPolicies"),
tenantId: v.string(),
actorId: v.id("users"),
},
handler: async (ctx, { policyId, tenantId, actorId }) => {
await requireAdmin(ctx, actorId, tenantId);
const policy = await ctx.db.get(policyId);
if (!policy || policy.tenantId !== tenantId) {
throw new ConvexError("Política não encontrada");
}
const ticketLinked = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("slaPolicyId"), policyId))
.first();
if (ticketLinked) {
throw new ConvexError("Remova a associação de tickets antes de excluir a política");
}
await ctx.db.delete(policyId);
},
});

232
web/convex/teams.ts Normal file
View file

@ -0,0 +1,232 @@
import { mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import { ConvexError, v } from "convex/values";
import { requireAdmin } from "./rbac";
function normalizeName(value: string) {
return value.trim();
}
type AnyCtx = QueryCtx | MutationCtx;
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"teams">) {
const existing = await ctx.db
.query("teams")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name))
.first();
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe um time com este nome");
}
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId);
const teams = await ctx.db
.query("teams")
.withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId))
.collect();
const users = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
const queues = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
return teams.map((team) => {
const members = users
.filter((user) => (user.teams ?? []).includes(team.name))
.map((user) => ({
id: user._id,
name: user.name,
email: user.email,
role: user.role ?? "AGENT",
}));
const linkedQueues = queues.filter((queue) => queue.teamId === team._id);
return {
id: team._id,
name: team.name,
description: team.description ?? "",
members,
queueCount: linkedQueues.length,
createdAt: team._creationTime,
};
});
},
});
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
description: v.optional(v.string()),
},
handler: async (ctx, { tenantId, actorId, name, description }) => {
await requireAdmin(ctx, actorId, tenantId);
const trimmed = normalizeName(name);
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para o time");
}
await ensureUniqueName(ctx, tenantId, trimmed);
const id = await ctx.db.insert("teams", {
tenantId,
name: trimmed,
description,
});
return id;
},
});
export const update = mutation({
args: {
teamId: v.id("teams"),
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
description: v.optional(v.string()),
},
handler: async (ctx, { teamId, tenantId, actorId, name, description }) => {
await requireAdmin(ctx, actorId, tenantId);
const team = await ctx.db.get(teamId);
if (!team || team.tenantId !== tenantId) {
throw new ConvexError("Time não encontrado");
}
const trimmed = normalizeName(name);
if (trimmed.length < 2) {
throw new ConvexError("Informe um nome válido para o time");
}
await ensureUniqueName(ctx, tenantId, trimmed, teamId);
if (team.name !== trimmed) {
const users = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
const now = users
.filter((user) => (user.teams ?? []).includes(team.name))
.map(async (user) => {
const teams = (user.teams ?? []).map((entry) => (entry === team.name ? trimmed : entry));
await ctx.db.patch(user._id, { teams });
});
await Promise.all(now);
}
await ctx.db.patch(teamId, { name: trimmed, description });
},
});
export const remove = mutation({
args: {
teamId: v.id("teams"),
tenantId: v.string(),
actorId: v.id("users"),
},
handler: async (ctx, { teamId, tenantId, actorId }) => {
await requireAdmin(ctx, actorId, tenantId);
const team = await ctx.db.get(teamId);
if (!team || team.tenantId !== tenantId) {
throw new ConvexError("Time não encontrado");
}
const queuesLinked = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("teamId"), teamId))
.first();
if (queuesLinked) {
throw new ConvexError("Remova ou realoque as filas associadas antes de excluir o time");
}
const users = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
await Promise.all(
users
.filter((user) => (user.teams ?? []).includes(team.name))
.map((user) => {
const teams = (user.teams ?? []).filter((entry) => entry !== team.name);
return ctx.db.patch(user._id, { teams });
})
);
await ctx.db.delete(teamId);
},
});
export const setMembers = mutation({
args: {
teamId: v.id("teams"),
tenantId: v.string(),
actorId: v.id("users"),
memberIds: v.array(v.id("users")),
},
handler: async (ctx, { teamId, tenantId, actorId, memberIds }) => {
await requireAdmin(ctx, actorId, tenantId);
const team = await ctx.db.get(teamId);
if (!team || team.tenantId !== tenantId) {
throw new ConvexError("Time não encontrado");
}
const users = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
const tenantUserIds = new Set(users.map((user) => user._id));
for (const memberId of memberIds) {
if (!tenantUserIds.has(memberId)) {
throw new ConvexError("Usuário inválido para este tenant");
}
}
const target = new Set(memberIds);
await Promise.all(
users.map(async (user) => {
const teams = new Set(user.teams ?? []);
const hasTeam = teams.has(team.name);
const shouldHave = target.has(user._id);
if (shouldHave && !hasTeam) {
teams.add(team.name);
await ctx.db.patch(user._id, { teams: Array.from(teams) });
}
if (!shouldHave && hasTeam) {
teams.delete(team.name);
await ctx.db.patch(user._id, { teams: Array.from(teams) });
}
})
);
},
});
export const directory = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
await requireAdmin(ctx, viewerId, tenantId);
const users = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
return users.map((user) => ({
id: user._id,
name: user.name,
email: user.email,
role: user.role ?? "AGENT",
teams: user.teams ?? [],
}));
},
});

View file

@ -2,7 +2,7 @@ import { mutation, query } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel";
import { requireAdmin, requireCustomer, requireStaff, requireUser } from "./rbac";
import { requireCustomer, requireStaff, requireUser } from "./rbac";
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados",
@ -52,7 +52,7 @@ export const list = query({
if (!args.viewerId) {
return []
}
const { user, role } = await requireUser(ctx, args.viewerId, args.tenantId)
const { role } = await requireUser(ctx, args.viewerId, args.tenantId)
// Choose best index based on provided args for efficiency
let base: Doc<"tickets">[] = [];

View file

@ -15,7 +15,45 @@ export const ensureUser = mutation({
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", args.tenantId).eq("email", args.email))
.first();
if (existing) return existing;
const reconcile = async (record: typeof existing) => {
if (!record) return null;
const shouldPatch =
record.tenantId !== args.tenantId ||
(args.role && record.role !== args.role) ||
(args.avatarUrl && record.avatarUrl !== args.avatarUrl) ||
record.name !== args.name ||
(args.teams && JSON.stringify(args.teams) !== JSON.stringify(record.teams ?? []));
if (shouldPatch) {
await ctx.db.patch(record._id, {
tenantId: args.tenantId,
role: args.role ?? record.role,
avatarUrl: args.avatarUrl ?? record.avatarUrl,
name: args.name,
teams: args.teams ?? record.teams,
});
const updated = await ctx.db.get(record._id);
if (updated) {
return updated;
}
}
return record;
};
if (existing) {
const reconciled = await reconcile(existing);
if (reconciled) {
return reconciled;
}
} else {
const anyTenant = (await ctx.db.query("users").collect()).find((user) => user.email === args.email);
if (anyTenant) {
const reconciled = await reconcile(anyTenant);
if (reconciled) {
return reconciled;
}
}
}
const id = await ctx.db.insert("users", {
tenantId: args.tenantId,
email: args.email,

View file

@ -1,5 +1,7 @@
export default {
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
};
export default config;

View file

@ -7,7 +7,7 @@ const email = process.env.SEED_USER_EMAIL ?? "admin@sistema.dev"
const password = process.env.SEED_USER_PASSWORD ?? "admin123"
const name = process.env.SEED_USER_NAME ?? "Administrador"
const role = process.env.SEED_USER_ROLE ?? "admin"
const tenantId = process.env.SEED_USER_TENANT ?? "default"
const tenantId = process.env.SEED_USER_TENANT ?? "tenant-atlas"
async function main() {
const hashedPassword = await hashPassword(password)
@ -26,7 +26,7 @@ async function main() {
tenantId,
accounts: {
create: {
providerId: "email",
providerId: "credential",
accountId: email,
password: hashedPassword,
},
@ -37,7 +37,41 @@ async function main() {
},
})
const account = user.accounts[0]
await prisma.authAccount.updateMany({
where: {
userId: user.id,
accountId: email,
},
data: {
providerId: "credential",
},
})
let account = await prisma.authAccount.findFirst({
where: {
userId: user.id,
providerId: "credential",
accountId: email,
},
})
if (account) {
account = await prisma.authAccount.update({
where: { id: account.id },
data: {
password: hashedPassword,
},
})
} else {
account = await prisma.authAccount.create({
data: {
userId: user.id,
providerId: "credential",
accountId: email,
password: hashedPassword,
},
})
}
console.log(`✅ Usuario seed criado/atualizado: ${user.email}`)
console.log(` ID: ${user.id}`)

View file

@ -0,0 +1,17 @@
import { QueuesManager } from "@/components/admin/queues/queues-manager"
export const dynamic = "force-dynamic"
export default function AdminChannelsPage() {
return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
<header className="mb-8 space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Filas e canais</h1>
<p className="text-sm text-neutral-600">
Configure as filas internas e vincule-as aos times responsáveis por cada canal de atendimento.
</p>
</header>
<QueuesManager />
</main>
)
}

View file

@ -0,0 +1,17 @@
import { FieldsManager } from "@/components/admin/fields/fields-manager"
export const dynamic = "force-dynamic"
export default function AdminFieldsPage() {
return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
<header className="mb-8 space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Campos personalizados</h1>
<p className="text-sm text-neutral-600">
Defina quais informações adicionais devem ser coletadas nos tickets de cada tenant.
</p>
</header>
<FieldsManager />
</main>
)
}

View file

@ -0,0 +1,17 @@
import { SlasManager } from "@/components/admin/slas/slas-manager"
export const dynamic = "force-dynamic"
export default function AdminSlasPage() {
return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
<header className="mb-8 space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Políticas de SLA</h1>
<p className="text-sm text-neutral-600">
Configure tempos de resposta e resolução para garantir a cobertura dos acordos de serviço.
</p>
</header>
<SlasManager />
</main>
)
}

View file

@ -0,0 +1,17 @@
import { TeamsManager } from "@/components/admin/teams/teams-manager"
export const dynamic = "force-dynamic"
export default function AdminTeamsPage() {
return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
<header className="mb-8 space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Times e agentes</h1>
<p className="text-sm text-neutral-600">
Estruture squads, capítulos e equipes responsáveis pelos tickets antes de associar filas e SLAs.
</p>
</header>
<TeamsManager />
</main>
)
}

View file

@ -86,7 +86,7 @@ export async function POST(request: Request) {
tenantId,
accounts: {
create: {
providerId: "email",
providerId: "credential",
accountId: emailInput,
password: hashedPassword,
},

View file

@ -0,0 +1,7 @@
import type { ReactNode } from "react"
import { PortalShell } from "@/components/portal/portal-shell"
export default function PortalLayout({ children }: { children: ReactNode }) {
return <PortalShell>{children}</PortalShell>
}

View file

@ -1,7 +1,5 @@
import type { Metadata } from "next"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { redirect } from "next/navigation"
export const metadata: Metadata = {
title: "Portal do cliente",
@ -9,26 +7,5 @@ export const metadata: Metadata = {
}
export default function PortalPage() {
return (
<main className="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-8 px-6 py-12 text-center lg:min-h-[calc(100vh-6rem)]">
<div className="max-w-md space-y-4">
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-muted-foreground">
Portal do cliente
</p>
<h1 className="text-3xl font-bold tracking-tight text-foreground">
Área do cliente em construção
</h1>
<p className="text-muted-foreground">
Em breve você poderá abrir novos chamados, acompanhar o status e conversar com a equipe de suporte por aqui.
Enquanto finalizamos os ajustes, utilize os canais combinados com sua equipe de atendimento.
</p>
</div>
<div className="flex flex-col items-center gap-3 text-sm text-muted-foreground">
<span>Precisa falar com a equipe agora?</span>
<Button asChild variant="outline">
<Link href="mailto:suporte@sistema.dev">Enviar e-mail para suporte</Link>
</Button>
</div>
</main>
)
redirect("/portal/tickets")
}

View file

@ -0,0 +1,5 @@
import { PortalTicketDetail } from "@/components/portal/portal-ticket-detail"
export default function PortalTicketDetailPage({ params }: { params: { id: string } }) {
return <PortalTicketDetail ticketId={params.id} />
}

View file

@ -0,0 +1,12 @@
import type { Metadata } from "next"
import { PortalTicketForm } from "@/components/portal/portal-ticket-form"
export const metadata: Metadata = {
title: "Abrir chamado",
description: "Registre um novo chamado para a equipe de suporte.",
}
export default function PortalNewTicketPage() {
return <PortalTicketForm />
}

View file

@ -0,0 +1,12 @@
import type { Metadata } from "next"
import { PortalTicketList } from "@/components/portal/portal-ticket-list"
export const metadata: Metadata = {
title: "Meus chamados",
description: "Acompanhe os chamados abertos com a equipe de suporte.",
}
export default function PortalTicketsPage() {
return <PortalTicketList />
}

View file

@ -0,0 +1,17 @@
import { BacklogReport } from "@/components/reports/backlog-report"
export const dynamic = "force-dynamic"
export default function ReportsBacklogPage() {
return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
<header className="mb-8 space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Backlog e Prioridades</h1>
<p className="text-sm text-neutral-600">
Avalie o volume de tickets em aberto, prioridades e filas mais pressionadas.
</p>
</header>
<BacklogReport />
</main>
)
}

View file

@ -0,0 +1,17 @@
import { CsatReport } from "@/components/reports/csat-report"
export const dynamic = "force-dynamic"
export default function ReportsCsatPage() {
return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
<header className="mb-8 space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Relatório de CSAT</h1>
<p className="text-sm text-neutral-600">
Visualize a satisfação dos clientes e identifique pontos de melhoria na entrega.
</p>
</header>
<CsatReport />
</main>
)
}

View file

@ -0,0 +1,17 @@
import { SlaReport } from "@/components/reports/sla-report"
export const dynamic = "force-dynamic"
export default function ReportsSlaPage() {
return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0">
<header className="mb-8 space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Relatório de SLA</h1>
<p className="text-sm text-neutral-600">
Acompanhe tempos de resposta, resolução e balanço de filas em tempo real.
</p>
</header>
<SlaReport />
</main>
)
}

View file

@ -0,0 +1,13 @@
import { AppShell } from "@/components/app-shell"
import { SettingsContent } from "@/components/settings/settings-content"
import { SiteHeader } from "@/components/site-header"
export default function SettingsPage() {
return (
<AppShell
header={<SiteHeader title="Configurações" lead="Central de preferências e governança do workspace" />}
>
<SettingsContent />
</AppShell>
)
}

View file

@ -0,0 +1,551 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconAdjustments, IconForms, IconListDetails, IconTypography } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
type FieldOption = { value: string; label: string }
type Field = {
id: string
key: string
label: string
description: string
type: "text" | "number" | "select" | "date" | "boolean"
required: boolean
options: FieldOption[]
order: number
}
const TYPE_LABELS: Record<Field["type"], string> = {
text: "Texto",
number: "Número",
select: "Seleção",
date: "Data",
boolean: "Verdadeiro/Falso",
}
export function FieldsManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const fields = useQuery(
api.fields.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Field[] | undefined
const createField = useMutation(api.fields.create)
const updateField = useMutation(api.fields.update)
const removeField = useMutation(api.fields.remove)
const reorderFields = useMutation(api.fields.reorder)
const [label, setLabel] = useState("")
const [description, setDescription] = useState("")
const [type, setType] = useState<Field["type"]>("text")
const [required, setRequired] = useState(false)
const [options, setOptions] = useState<FieldOption[]>([])
const [saving, setSaving] = useState(false)
const [editingField, setEditingField] = useState<Field | null>(null)
const totals = useMemo(() => {
if (!fields) return { total: 0, required: 0, select: 0 }
return {
total: fields.length,
required: fields.filter((field) => field.required).length,
select: fields.filter((field) => field.type === "select").length,
}
}, [fields])
const resetForm = () => {
setLabel("")
setDescription("")
setType("text")
setRequired(false)
setOptions([])
}
const normalizeOptions = (source: FieldOption[]) =>
source
.map((option) => ({
label: option.label.trim(),
value: option.value.trim() || option.label.trim().toLowerCase().replace(/\s+/g, "_"),
}))
.filter((option) => option.label.length > 0)
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!label.trim()) {
toast.error("Informe o rótulo do campo")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
setSaving(true)
toast.loading("Criando campo...", { id: "field" })
try {
await createField({
tenantId,
actorId: convexUserId as Id<"users">,
label: label.trim(),
description: description.trim() || undefined,
type,
required,
options: preparedOptions,
})
toast.success("Campo criado", { id: "field" })
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível criar o campo", { id: "field" })
} finally {
setSaving(false)
}
}
const handleRemove = async (field: Field) => {
const confirmed = window.confirm(`Excluir o campo ${field.label}?`)
if (!confirmed) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
toast.loading("Removendo campo...", { id: `field-remove-${field.id}` })
try {
await removeField({
tenantId,
fieldId: field.id as Id<"ticketFields">,
actorId: convexUserId as Id<"users">,
})
toast.success("Campo removido", { id: `field-remove-${field.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover o campo", { id: `field-remove-${field.id}` })
}
}
const openEdit = (field: Field) => {
setEditingField(field)
setLabel(field.label)
setDescription(field.description)
setType(field.type)
setRequired(field.required)
setOptions(field.options)
}
const handleUpdate = async () => {
if (!editingField) return
if (!label.trim()) {
toast.error("Informe o rótulo do campo")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
setSaving(true)
toast.loading("Atualizando campo...", { id: "field-edit" })
try {
await updateField({
tenantId,
fieldId: editingField.id as Id<"ticketFields">,
actorId: convexUserId as Id<"users">,
label: label.trim(),
description: description.trim() || undefined,
type,
required,
options: preparedOptions,
})
toast.success("Campo atualizado", { id: "field-edit" })
setEditingField(null)
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o campo", { id: "field-edit" })
} finally {
setSaving(false)
}
}
const moveField = async (field: Field, direction: "up" | "down") => {
if (!fields) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
const index = fields.findIndex((item) => item.id === field.id)
const targetIndex = direction === "up" ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= fields.length) return
const reordered = [...fields]
const [removed] = reordered.splice(index, 1)
reordered.splice(targetIndex, 0, removed)
try {
await reorderFields({
tenantId,
actorId: convexUserId as Id<"users">,
orderedIds: reordered.map((item) => item.id as Id<"ticketFields">),
})
toast.success("Ordem atualizada")
} catch (error) {
console.error(error)
toast.error("Não foi possível reordenar os campos")
}
}
const addOption = () => {
setOptions((current) => [...current, { label: "", value: "" }])
}
const updateOption = (index: number, key: keyof FieldOption, value: string) => {
setOptions((current) => {
const copy = [...current]
copy[index] = { ...copy[index], [key]: value }
return copy
})
}
const removeOption = (index: number) => {
setOptions((current) => current.filter((_, optIndex) => optIndex !== index))
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconForms className="size-4" /> Campos personalizados
</CardTitle>
<CardDescription>Metadados adicionais disponíveis nos tickets.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{fields ? totals.total : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconTypography className="size-4" /> Campos obrigatórios
</CardTitle>
<CardDescription>Informações exigidas na abertura.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{fields ? totals.required : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconListDetails className="size-4" /> Campos de seleção
</CardTitle>
<CardDescription>Usados para listas e múltipla escolha.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{fields ? totals.select : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconAdjustments className="size-5 text-neutral-500" /> Novo campo
</CardTitle>
<CardDescription>Capture informações específicas do seu fluxo de atendimento.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="grid gap-4 lg:grid-cols-[minmax(0,280px)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="field-label">Rótulo</Label>
<Input
id="field-label"
placeholder="Ex.: Número do contrato"
value={label}
onChange={(event) => setLabel(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Tipo de dado</Label>
<Select value={type} onValueChange={(value) => setType(value as Field["type"])}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Texto curto</SelectItem>
<SelectItem value="number">Número</SelectItem>
<SelectItem value="select">Seleção</SelectItem>
<SelectItem value="date">Data</SelectItem>
<SelectItem value="boolean">Verdadeiro/Falso</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox id="field-required" checked={required} onCheckedChange={(value) => setRequired(Boolean(value))} />
<Label htmlFor="field-required" className="text-sm font-normal text-neutral-600">
Campo obrigatório na abertura
</Label>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="field-description">Descrição</Label>
<textarea
id="field-description"
className="min-h-[96px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
placeholder="Como este campo será utilizado"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
{type === "select" ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Opções</Label>
<Button type="button" variant="outline" size="sm" onClick={addOption}>
Adicionar opção
</Button>
</div>
{options.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
Adicione pelo menos uma opção para este campo de seleção.
</p>
) : (
<div className="space-y-3">
{options.map((option, index) => (
<div key={index} className="grid gap-3 rounded-lg border border-slate-200 p-3 md:grid-cols-[minmax(0,1fr)_minmax(0,200px)_auto]">
<Input
placeholder="Rótulo"
value={option.label}
onChange={(event) => updateOption(index, "label", event.target.value)}
/>
<Input
placeholder="Valor"
value={option.value}
onChange={(event) => updateOption(index, "value", event.target.value)}
/>
<Button variant="ghost" type="button" onClick={() => removeOption(index)}>
Remover
</Button>
</div>
))}
</div>
)}
</div>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
Criar campo
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
<div className="space-y-4">
{fields === undefined ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-28 rounded-2xl" />
))}
</div>
) : fields.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhum campo cadastrado</CardTitle>
<CardDescription className="text-neutral-600">
Crie campos personalizados para enriquecer os tickets com informações importantes.
</CardDescription>
</CardHeader>
</Card>
) : (
fields.map((field, index) => (
<Card key={field.id} className="border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-3">
<CardTitle className="text-xl font-semibold text-neutral-900">{field.label}</CardTitle>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{TYPE_LABELS[field.type]}
</Badge>
{field.required ? (
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
obrigatório
</Badge>
) : null}
</div>
<CardDescription className="text-neutral-600">Identificador: {field.key}</CardDescription>
{field.description ? (
<p className="text-sm text-neutral-600">{field.description}</p>
) : null}
</div>
<div className="flex flex-col items-end gap-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(field)}>
Editar
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(field)}>
Excluir
</Button>
</div>
<div className="flex gap-2 text-xs text-neutral-500">
<Button
type="button"
size="sm"
variant="ghost"
className="px-2"
disabled={index === 0}
onClick={() => moveField(field, "up")}
>
Subir
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="px-2"
disabled={index === fields.length - 1}
onClick={() => moveField(field, "down")}
>
Descer
</Button>
</div>
</div>
</div>
</CardHeader>
{field.type === "select" && field.options.length > 0 ? (
<CardContent>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Opções cadastradas</p>
<div className="flex flex-wrap gap-2">
{field.options.map((option) => (
<Badge key={option.value} variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{option.label} ({option.value})
</Badge>
))}
</div>
</CardContent>
) : null}
</Card>
))
)}
</div>
<Dialog open={Boolean(editingField)} onOpenChange={(value) => (!value ? setEditingField(null) : null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Editar campo</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2 lg:grid-cols-[minmax(0,260px)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="edit-field-label">Rótulo</Label>
<Input
id="edit-field-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Tipo de dado</Label>
<Select value={type} onValueChange={(value) => setType(value as Field["type"])}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Texto curto</SelectItem>
<SelectItem value="number">Número</SelectItem>
<SelectItem value="select">Seleção</SelectItem>
<SelectItem value="date">Data</SelectItem>
<SelectItem value="boolean">Verdadeiro/Falso</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox id="edit-field-required" checked={required} onCheckedChange={(value) => setRequired(Boolean(value))} />
<Label htmlFor="edit-field-required" className="text-sm font-normal text-neutral-600">
Campo obrigatório na abertura
</Label>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-field-description">Descrição</Label>
<textarea
id="edit-field-description"
className="min-h-[96px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
{type === "select" ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Opções</Label>
<Button type="button" variant="outline" size="sm" onClick={addOption}>
Adicionar opção
</Button>
</div>
{options.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
Inclua ao menos uma opção para salvar este campo.
</p>
) : (
<div className="space-y-3">
{options.map((option, index) => (
<div key={index} className="grid gap-3 rounded-lg border border-slate-200 p-3 md:grid-cols-[minmax(0,1fr)_minmax(0,200px)_auto]">
<Input
placeholder="Rótulo"
value={option.label}
onChange={(event) => updateOption(index, "label", event.target.value)}
/>
<Input
placeholder="Valor"
value={option.value}
onChange={(event) => updateOption(index, "value", event.target.value)}
/>
<Button variant="ghost" type="button" onClick={() => removeOption(index)}>
Remover
</Button>
</div>
))}
</div>
)}
</div>
) : null}
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingField(null)}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,322 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconInbox, IconHierarchy2, IconLink, IconPlus } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
type Queue = {
id: string
name: string
slug: string
team: { id: string; name: string } | null
}
type TeamOption = {
id: string
name: string
}
export function QueuesManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const queues = useQuery(
api.queues.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Queue[] | undefined
const teams = useQuery(
api.teams.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as TeamOption[] | undefined
const createQueue = useMutation(api.queues.create)
const updateQueue = useMutation(api.queues.update)
const removeQueue = useMutation(api.queues.remove)
const [name, setName] = useState("")
const [teamId, setTeamId] = useState<string | undefined>()
const [editingQueue, setEditingQueue] = useState<Queue | null>(null)
const [saving, setSaving] = useState(false)
const totalQueues = queues?.length ?? 0
const withoutTeam = useMemo(() => {
if (!queues) return 0
return queues.filter((queue) => !queue.team).length
}, [queues])
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!name.trim()) {
toast.error("Informe o nome da fila")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Criando fila...", { id: "queue" })
try {
await createQueue({
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
teamId: teamId as Id<"teams"> | undefined,
})
setName("")
setTeamId(undefined)
toast.success("Fila criada", { id: "queue" })
} catch (error) {
console.error(error)
toast.error("Não foi possível criar a fila", { id: "queue" })
} finally {
setSaving(false)
}
}
const openEdit = (queue: Queue) => {
setEditingQueue(queue)
setName(queue.name)
setTeamId(queue.team?.id)
}
const handleUpdate = async () => {
if (!editingQueue) return
if (!name.trim()) {
toast.error("Informe o nome da fila")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Salvando alterações...", { id: "queue-edit" })
try {
await updateQueue({
queueId: editingQueue.id as Id<"queues">,
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
teamId: (teamId ?? undefined) as Id<"teams"> | undefined,
})
toast.success("Fila atualizada", { id: "queue-edit" })
setEditingQueue(null)
setName("")
setTeamId(undefined)
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar a fila", { id: "queue-edit" })
} finally {
setSaving(false)
}
}
const handleRemove = async (queue: Queue) => {
const confirmed = window.confirm(`Remover a fila ${queue.name}?`)
if (!confirmed) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
toast.loading("Removendo fila...", { id: `queue-remove-${queue.id}` })
try {
await removeQueue({ tenantId, queueId: queue.id as Id<"queues">, actorId: convexUserId as Id<"users"> })
toast.success("Fila removida", { id: `queue-remove-${queue.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover a fila", { id: `queue-remove-${queue.id}` })
}
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconInbox className="size-4" /> Filas criadas
</CardTitle>
<CardDescription>Rotas que recebem tickets dos canais conectados.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{queues ? totalQueues : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconHierarchy2 className="size-4" /> Com time definido
</CardTitle>
<CardDescription>Filas com time responsável atribuído.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{queues ? totalQueues - withoutTeam : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconLink className="size-4" /> Sem vinculação
</CardTitle>
<CardDescription>Filas aguardando responsáveis.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{queues ? withoutTeam : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconPlus className="size-5 text-neutral-500" /> Nova fila
</CardTitle>
<CardDescription>Defina as filas de atendimento, conectando-as aos times responsáveis.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="grid gap-4 md:grid-cols-[minmax(0,300px)_minmax(0,300px)_auto]">
<div className="space-y-2">
<Label htmlFor="queue-name">Nome da fila</Label>
<Input
id="queue-name"
placeholder="Ex.: Suporte N1"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Time responsável</Label>
<Select value={teamId ?? ""} onValueChange={(value) => setTeamId(value || undefined)}>
<SelectTrigger>
<SelectValue placeholder="Selecione um time" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Sem time</SelectItem>
{teams?.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button type="submit" className="w-full" disabled={saving}>
Criar fila
</Button>
</div>
</form>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
{queues === undefined ? (
Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-40 rounded-2xl" />)
) : queues.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhuma fila cadastrada</CardTitle>
<CardDescription className="text-neutral-600">
Crie filas para segmentar os atendimentos por canal ou especialidade.
</CardDescription>
</CardHeader>
</Card>
) : (
queues.map((queue) => (
<Card key={queue.id} className="border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-xl font-semibold text-neutral-900">{queue.name}</CardTitle>
<CardDescription className="mt-2 text-xs uppercase tracking-wide text-neutral-500">
Slug: {queue.slug}
</CardDescription>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(queue)}>
Editar
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(queue)}>
Excluir
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 text-sm text-neutral-600">
<span className="font-medium text-neutral-500">Time:</span>
{queue.team ? (
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-700">
{queue.team.name}
</Badge>
) : (
<span className="text-neutral-500">Sem time vinculado</span>
)}
</div>
</CardContent>
</Card>
))
)}
</div>
<Dialog open={Boolean(editingQueue)} onOpenChange={(value) => (!value ? setEditingQueue(null) : null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Editar fila</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-queue-name">Nome</Label>
<Input
id="edit-queue-name"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Time responsável</Label>
<Select value={teamId ?? ""} onValueChange={(value) => setTeamId(value || undefined)}>
<SelectTrigger>
<SelectValue placeholder="Selecione um time" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Sem time</SelectItem>
{teams?.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingQueue(null)}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,390 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconAlarm, IconBolt, IconTargetArrow } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
type SlaPolicy = {
id: string
name: string
description: string
timeToFirstResponse: number | null
timeToResolution: number | null
}
function formatMinutes(value: number | null) {
if (value === null) return "—"
if (value < 60) return `${Math.round(value)} min`
const hours = Math.floor(value / 60)
const minutes = Math.round(value % 60)
if (minutes === 0) return `${hours}h`
return `${hours}h ${minutes}min`
}
export function SlasManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const slas = useQuery(
api.slas.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as SlaPolicy[] | undefined
const createSla = useMutation(api.slas.create)
const updateSla = useMutation(api.slas.update)
const removeSla = useMutation(api.slas.remove)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [firstResponse, setFirstResponse] = useState<string>("")
const [resolution, setResolution] = useState<string>("")
const [saving, setSaving] = useState(false)
const [editingSla, setEditingSla] = useState<SlaPolicy | null>(null)
const { bestFirstResponse, bestResolution } = useMemo(() => {
if (!slas) return { bestFirstResponse: null, bestResolution: null }
const response = slas.reduce<number | null>((acc, sla) => {
if (sla.timeToFirstResponse === null) return acc
return acc === null ? sla.timeToFirstResponse : Math.min(acc, sla.timeToFirstResponse)
}, null)
const resolution = slas.reduce<number | null>((acc, sla) => {
if (sla.timeToResolution === null) return acc
return acc === null ? sla.timeToResolution : Math.min(acc, sla.timeToResolution)
}, null)
return { bestFirstResponse: response, bestResolution: resolution }
}, [slas])
const resetForm = () => {
setName("")
setDescription("")
setFirstResponse("")
setResolution("")
}
const parseNumber = (value: string) => {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
}
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!name.trim()) {
toast.error("Informe um nome para a política")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Criando SLA...", { id: "sla" })
try {
await createSla({
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
timeToFirstResponse: parseNumber(firstResponse),
timeToResolution: parseNumber(resolution),
})
toast.success("Política criada", { id: "sla" })
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível criar a política", { id: "sla" })
} finally {
setSaving(false)
}
}
const openEdit = (policy: SlaPolicy) => {
setEditingSla(policy)
setName(policy.name)
setDescription(policy.description)
setFirstResponse(policy.timeToFirstResponse ? String(policy.timeToFirstResponse) : "")
setResolution(policy.timeToResolution ? String(policy.timeToResolution) : "")
}
const handleUpdate = async () => {
if (!editingSla) return
if (!name.trim()) {
toast.error("Informe um nome para a política")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Salvando alterações...", { id: "sla-edit" })
try {
await updateSla({
tenantId,
policyId: editingSla.id as Id<"slaPolicies">,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
timeToFirstResponse: parseNumber(firstResponse),
timeToResolution: parseNumber(resolution),
})
toast.success("Política atualizada", { id: "sla-edit" })
setEditingSla(null)
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar a política", { id: "sla-edit" })
} finally {
setSaving(false)
}
}
const handleRemove = async (policy: SlaPolicy) => {
const confirmed = window.confirm(`Excluir a política ${policy.name}?`)
if (!confirmed) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
toast.loading("Removendo política...", { id: `sla-remove-${policy.id}` })
try {
await removeSla({
tenantId,
policyId: policy.id as Id<"slaPolicies">,
actorId: convexUserId as Id<"users">,
})
toast.success("Política removida", { id: `sla-remove-${policy.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover a política", { id: `sla-remove-${policy.id}` })
}
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconTargetArrow className="size-4" /> Políticas criadas
</CardTitle>
<CardDescription>Regras aplicadas às filas e tickets.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{slas ? slas.length : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconAlarm className="size-4" /> Resposta (média)
</CardTitle>
<CardDescription>Tempo mínimo para primeira resposta.</CardDescription>
</CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900">
{slas ? formatMinutes(bestFirstResponse ?? null) : <Skeleton className="h-8 w-24" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconBolt className="size-4" /> Resolução (média)
</CardTitle>
<CardDescription>Alvo para encerrar chamados.</CardDescription>
</CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900">
{slas ? formatMinutes(bestResolution ?? null) : <Skeleton className="h-8 w-24" />}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nova política de SLA</CardTitle>
<CardDescription>Defina metas de resposta e resolução para garantir previsibilidade no atendimento.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="grid gap-4 md:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="sla-name">Nome da política</Label>
<Input
id="sla-name"
placeholder="Ex.: Resposta prioritária"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="sla-first-response">Primeira resposta (minutos)</Label>
<Input
id="sla-first-response"
type="number"
min={1}
value={firstResponse}
onChange={(event) => setFirstResponse(event.target.value)}
placeholder="Opcional"
/>
</div>
<div className="space-y-2">
<Label htmlFor="sla-resolution">Resolução (minutos)</Label>
<Input
id="sla-resolution"
type="number"
min={1}
value={resolution}
onChange={(event) => setResolution(event.target.value)}
placeholder="Opcional"
/>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sla-description">Descrição</Label>
<textarea
id="sla-description"
className="min-h-[120px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
placeholder="Como esta política será aplicada"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
Criar política
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
<div className="space-y-4">
{slas === undefined ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-32 rounded-2xl" />
))}
</div>
) : slas.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhuma política cadastrada</CardTitle>
<CardDescription className="text-neutral-600">
Crie SLAs para monitorar o tempo de resposta e resolução dos seus chamados.
</CardDescription>
</CardHeader>
</Card>
) : (
slas.map((policy) => (
<Card key={policy.id} className="border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<CardTitle className="text-xl font-semibold text-neutral-900">{policy.name}</CardTitle>
{policy.description ? (
<CardDescription className="text-neutral-600">{policy.description}</CardDescription>
) : null}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(policy)}>
Editar
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(policy)}>
Excluir
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<dl className="grid gap-4 md:grid-cols-2">
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Primeira resposta</dt>
<dd className="text-lg font-semibold text-neutral-900">{formatMinutes(policy.timeToFirstResponse)}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Resolução</dt>
<dd className="text-lg font-semibold text-neutral-900">{formatMinutes(policy.timeToResolution)}</dd>
</div>
</dl>
</CardContent>
</Card>
))
)}
</div>
<Dialog open={Boolean(editingSla)} onOpenChange={(value) => (!value ? setEditingSla(null) : null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Editar política de SLA</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-sla-name">Nome</Label>
<Input
id="edit-sla-name"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="edit-sla-first">Primeira resposta (minutos)</Label>
<Input
id="edit-sla-first"
type="number"
min={1}
value={firstResponse}
onChange={(event) => setFirstResponse(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-sla-resolution">Resolução (minutos)</Label>
<Input
id="edit-sla-resolution"
type="number"
min={1}
value={resolution}
onChange={(event) => setResolution(event.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="edit-sla-description">Descrição</Label>
<textarea
id="edit-sla-description"
className="min-h-[120px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingSla(null)}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,428 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconUsersGroup, IconCalendarClock, IconSettings, IconUserPlus } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
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 { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Checkbox } from "@/components/ui/checkbox"
import { Skeleton } from "@/components/ui/skeleton"
type Team = {
id: string
name: string
description: string
members: { id: string; name: string; email: string; role: string }[]
queueCount: number
createdAt: number
}
type DirectoryUser = {
id: string
name: string
email: string
role: string
teams: string[]
}
export function TeamsManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const teams = useQuery(
api.teams.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Team[] | undefined
const directory = useQuery(
api.teams.directory,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as DirectoryUser[] | undefined
const createTeam = useMutation(api.teams.create)
const updateTeam = useMutation(api.teams.update)
const removeTeam = useMutation(api.teams.remove)
const setMembers = useMutation(api.teams.setMembers)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [editingTeam, setEditingTeam] = useState<Team | null>(null)
const [membershipTeam, setMembershipTeam] = useState<Team | null>(null)
const [selectedMembers, setSelectedMembers] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false)
const totalMembers = useMemo(() => {
if (!teams) return 0
return teams.reduce((acc, team) => acc + team.members.length, 0)
}, [teams])
const totalQueues = useMemo(() => {
if (!teams) return 0
return teams.reduce((acc, team) => acc + team.queueCount, 0)
}, [teams])
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!name.trim()) {
toast.error("Informe um nome para o time")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Criando time...", { id: "team" })
try {
await createTeam({
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
})
setName("")
setDescription("")
toast.success("Time criado com sucesso", { id: "team" })
} catch (error) {
console.error(error)
toast.error("Não foi possível criar o time", { id: "team" })
} finally {
setSaving(false)
}
}
const openEdit = (team: Team) => {
setEditingTeam(team)
setName(team.name)
setDescription(team.description)
}
const handleUpdate = async () => {
if (!editingTeam) return
if (!name.trim()) {
toast.error("Informe um nome para o time")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Salvando alterações...", { id: "team-edit" })
try {
await updateTeam({
tenantId,
teamId: editingTeam.id as Id<"teams">,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
})
toast.success("Time atualizado", { id: "team-edit" })
setEditingTeam(null)
setName("")
setDescription("")
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o time", { id: "team-edit" })
} finally {
setSaving(false)
}
}
const handleRemove = async (team: Team) => {
const confirmed = window.confirm(`Excluir o time ${team.name}?`)
if (!confirmed) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
toast.loading("Removendo time...", { id: `team-remove-${team.id}` })
try {
await removeTeam({ tenantId, teamId: team.id as Id<"teams">, actorId: convexUserId as Id<"users"> })
toast.success("Time removido", { id: `team-remove-${team.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover o time", { id: `team-remove-${team.id}` })
}
}
const openMembership = (team: Team) => {
setMembershipTeam(team)
setSelectedMembers(new Set(team.members.map((member) => member.id)))
}
const toggleMember = (userId: string) => {
setSelectedMembers((current) => {
const next = new Set(current)
if (next.has(userId)) {
next.delete(userId)
} else {
next.add(userId)
}
return next
})
}
const handleMembershipSave = async () => {
if (!membershipTeam) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Atualizando membros...", { id: "team-members" })
try {
await setMembers({
tenantId,
teamId: membershipTeam.id as Id<"teams">,
actorId: convexUserId as Id<"users">,
memberIds: Array.from(selectedMembers) as Id<"users">[],
})
toast.success("Membros atualizados", { id: "team-members" })
setMembershipTeam(null)
setSelectedMembers(new Set())
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar os membros", { id: "team-members" })
} finally {
setSaving(false)
}
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconUsersGroup className="size-4" /> Times cadastrados
</CardTitle>
<CardDescription>Organize a operação em células de atendimento.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{teams ? teams.length : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconUserPlus className="size-4" /> Pessoas alocadas
</CardTitle>
<CardDescription>Soma de integrantes em cada time.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{teams ? totalMembers : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconCalendarClock className="size-4" /> Filas vinculadas
</CardTitle>
<CardDescription>Total de canais ligados aos times.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{teams ? totalQueues : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconSettings className="size-5 text-neutral-500" /> Novo time
</CardTitle>
<CardDescription>Defina a que squad, célula ou capítulo cada chamado poderá ser roteado.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="grid gap-4 md:grid-cols-[minmax(0,300px)_minmax(0,1fr)_auto]">
<div className="space-y-2">
<Label htmlFor="team-name">Nome do time</Label>
<Input
id="team-name"
placeholder="Ex.: Suporte N1"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="team-description">Descrição</Label>
<Input
id="team-description"
placeholder="Contextualize a responsabilidade do time"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex items-end">
<Button type="submit" className="w-full" disabled={saving}>
{saving && !editingTeam ? "Salvando..." : "Adicionar"}
</Button>
</div>
</form>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
{teams === undefined ? (
Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-48 rounded-2xl" />)
) : teams.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhum time cadastrado</CardTitle>
<CardDescription className="text-neutral-600">
Crie o primeiro time para organizar a distribuição das filas e agentes.
</CardDescription>
</CardHeader>
</Card>
) : (
teams.map((team) => (
<Card key={team.id} className="flex flex-col justify-between border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-xl font-semibold text-neutral-900">{team.name}</CardTitle>
{team.description ? (
<CardDescription className="mt-1 text-neutral-600">{team.description}</CardDescription>
) : null}
<div className="mt-4 flex flex-wrap gap-2 text-xs text-neutral-500">
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{team.members.length} membro{team.members.length === 1 ? "" : "s"}
</Badge>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{team.queueCount} fila{team.queueCount === 1 ? "" : "s"}
</Badge>
</div>
</div>
<div className="flex flex-col gap-2">
<Button variant="outline" size="sm" onClick={() => openMembership(team)}>
Gerenciar membros
</Button>
<Button variant="secondary" size="sm" onClick={() => openEdit(team)}>
Renomear
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(team)}>
Excluir
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Integrantes</p>
{team.members.length === 0 ? (
<p className="mt-2 text-sm text-neutral-600">Nenhum membro atribuído.</p>
) : (
<ul className="mt-2 space-y-2">
{team.members.map((member) => (
<li key={member.id} className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm">
<div>
<p className="font-medium text-neutral-800">{member.name || member.email}</p>
<p className="text-xs text-neutral-500">{member.email}</p>
</div>
<Badge variant="outline" className="rounded-full border-neutral-200 text-neutral-600">
{member.role.toLowerCase()}
</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
))
)}
</div>
<Dialog open={Boolean(editingTeam)} onOpenChange={(value) => (!value ? setEditingTeam(null) : null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Editar time</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-team-name">Nome</Label>
<Input
id="edit-team-name"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-team-description">Descrição</Label>
<Input
id="edit-team-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingTeam(null)}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(membershipTeam)} onOpenChange={(value) => (!value ? setMembershipTeam(null) : null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Gerenciar membros</DialogTitle>
</DialogHeader>
<div className="max-h-[420px] overflow-y-auto rounded-lg border border-slate-200">
{directory === undefined ? (
<div className="space-y-2 p-4">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-10 rounded" />
))}
</div>
) : directory.length === 0 ? (
<p className="p-4 text-sm text-neutral-600">Nenhum usuário sincronizado para este tenant.</p>
) : (
<ul className="divide-y divide-slate-100">
{directory.map((user) => {
const checked = selectedMembers.has(user.id)
return (
<li key={user.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div>
<p className="text-sm font-medium text-neutral-900">{user.name || user.email}</p>
<p className="text-xs text-neutral-500">{user.email}</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="rounded-full border-neutral-200 text-neutral-600">
{user.role.toLowerCase()}
</Badge>
<Checkbox checked={checked} onCheckedChange={() => toggleMember(user.id)} />
</div>
</li>
)
})}
</ul>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setMembershipTeam(null)}>
Cancelar
</Button>
<Button onClick={handleMembershipSave} disabled={saving}>
Salvar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -15,6 +15,7 @@ import {
Timer,
Plug,
Layers3,
Settings,
} from "lucide-react"
import { usePathname } from "next/navigation"
@ -32,43 +33,69 @@ import {
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar"
import { useAuth } from "@/lib/auth-client"
const navigation = {
import type { LucideIcon } from "lucide-react"
type NavRoleRequirement = "staff" | "admin" | "customer"
type NavigationItem = {
title: string
url: string
icon: LucideIcon
requiredRole?: NavRoleRequirement
}
type NavigationGroup = {
title: string
requiredRole?: NavRoleRequirement
items: NavigationItem[]
}
const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
versions: ["0.0.1"],
navMain: [
{
title: "Operação",
items: [
{ title: "Dashboard", url: "/dashboard", icon: LayoutDashboard },
{ title: "Tickets", url: "/tickets", icon: Ticket },
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft },
{ title: "Modo Play", url: "/play", icon: PlayCircle },
{ title: "Base de conhecimento", url: "/knowledge", icon: BookOpen },
{ title: "Dashboard", url: "/dashboard", icon: LayoutDashboard, requiredRole: "staff" },
{ title: "Tickets", url: "/tickets", icon: Ticket, requiredRole: "staff" },
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" },
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
{ title: "Base de conhecimento", url: "/knowledge", icon: BookOpen, requiredRole: "staff" },
],
},
{
title: "Relatorios",
title: "Relatórios",
requiredRole: "staff",
items: [
{ title: "SLA e produtividade", url: "/reports/sla", icon: Gauge },
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy },
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3 },
{ title: "SLA e produtividade", url: "/reports/sla", icon: Gauge, requiredRole: "staff" },
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
],
},
{
title: "Configuração",
title: "Administração",
requiredRole: "admin",
items: [
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints },
{ title: "Times & papéis", url: "/admin/teams", icon: Users },
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3 },
{ title: "SLAs", url: "/admin/slas", icon: Timer },
{ title: "Integrações", url: "/admin/integrations", icon: Plug },
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" },
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
{ title: "Integrações", url: "/admin/integrations", icon: Plug, requiredRole: "admin" },
],
},
{
title: "Conta",
requiredRole: "staff",
items: [{ title: "Configurações", url: "/settings", icon: Settings, requiredRole: "staff" }],
},
],
} as const
}
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname()
const { isAdmin, isStaff, isCustomer } = useAuth()
function isActive(url: string) {
if (!pathname) return false
@ -78,6 +105,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return pathname === url || pathname.startsWith(`${url}/`)
}
function canAccess(requiredRole?: NavRoleRequirement) {
if (!requiredRole) return true
if (requiredRole === "admin") return isAdmin
if (requiredRole === "staff") return isStaff
if (requiredRole === "customer") return isCustomer
return false
}
return (
<Sidebar {...props}>
<SidebarHeader className="gap-3">
@ -89,25 +124,30 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SearchForm placeholder="Buscar tickets, macros ou artigos" />
</SidebarHeader>
<SidebarContent>
{navigation.navMain.map((group) => (
<SidebarGroup key={group.title}>
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive(item.url)}>
<a href={item.url} className="gap-2">
<item.icon className="size-4" />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
{navigation.navMain.map((group) => {
if (!canAccess(group.requiredRole)) return null
const visibleItems = group.items.filter((item) => canAccess(item.requiredRole))
if (visibleItems.length === 0) return null
return (
<SidebarGroup key={group.title}>
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{visibleItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive(item.url)}>
<a href={item.url} className="gap-2">
<item.icon className="size-4" />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
})}
</SidebarContent>
<SidebarRail />
</Sidebar>

View file

@ -3,6 +3,12 @@
import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useIsMobile } from "@/hooks/use-mobile"
import {
Card,
@ -18,6 +24,7 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
import {
Select,
SelectContent,
@ -32,85 +39,11 @@ import {
export const description = "Distribuição semanal de tickets por canal"
const chartData = [
{ date: "2024-07-01", email: 38, whatsapp: 25 },
{ date: "2024-07-02", email: 42, whatsapp: 28 },
{ date: "2024-07-03", email: 35, whatsapp: 21 },
{ date: "2024-07-04", email: 47, whatsapp: 30 },
{ date: "2024-07-05", email: 51, whatsapp: 32 },
{ date: "2024-07-06", email: 44, whatsapp: 29 },
{ date: "2024-07-07", email: 39, whatsapp: 24 },
{ date: "2024-07-08", email: 48, whatsapp: 31 },
{ date: "2024-07-09", email: 45, whatsapp: 27 },
{ date: "2024-07-10", email: 53, whatsapp: 33 },
{ date: "2024-07-11", email: 56, whatsapp: 35 },
{ date: "2024-07-12", email: 49, whatsapp: 30 },
{ date: "2024-07-13", email: 41, whatsapp: 22 },
{ date: "2024-07-14", email: 37, whatsapp: 20 },
{ date: "2024-07-15", email: 52, whatsapp: 34 },
{ date: "2024-07-16", email: 50, whatsapp: 31 },
{ date: "2024-07-17", email: 47, whatsapp: 29 },
{ date: "2024-07-18", email: 58, whatsapp: 37 },
{ date: "2024-07-19", email: 54, whatsapp: 34 },
{ date: "2024-07-20", email: 43, whatsapp: 26 },
{ date: "2024-07-21", email: 39, whatsapp: 23 },
{ date: "2024-07-22", email: 55, whatsapp: 36 },
{ date: "2024-07-23", email: 52, whatsapp: 33 },
{ date: "2024-07-24", email: 57, whatsapp: 38 },
{ date: "2024-07-25", email: 60, whatsapp: 40 },
{ date: "2024-07-26", email: 49, whatsapp: 31 },
{ date: "2024-07-27", email: 44, whatsapp: 27 },
{ date: "2024-07-28", email: 41, whatsapp: 24 },
{ date: "2024-07-29", email: 58, whatsapp: 37 },
{ date: "2024-07-30", email: 61, whatsapp: 41 },
{ date: "2024-07-31", email: 46, whatsapp: 29 },
{ date: "2024-08-01", email: 52, whatsapp: 33 },
{ date: "2024-08-02", email: 48, whatsapp: 30 },
{ date: "2024-08-03", email: 43, whatsapp: 25 },
{ date: "2024-08-04", email: 40, whatsapp: 24 },
{ date: "2024-08-05", email: 57, whatsapp: 36 },
{ date: "2024-08-06", email: 59, whatsapp: 38 },
{ date: "2024-08-07", email: 62, whatsapp: 41 },
{ date: "2024-08-08", email: 55, whatsapp: 35 },
{ date: "2024-08-09", email: 51, whatsapp: 32 },
{ date: "2024-08-10", email: 45, whatsapp: 27 },
{ date: "2024-08-11", email: 42, whatsapp: 25 },
{ date: "2024-08-12", email: 58, whatsapp: 37 },
{ date: "2024-08-13", email: 56, whatsapp: 34 },
{ date: "2024-08-14", email: 60, whatsapp: 39 },
{ date: "2024-08-15", email: 63, whatsapp: 42 },
{ date: "2024-08-16", email: 49, whatsapp: 30 },
{ date: "2024-08-17", email: 46, whatsapp: 28 },
{ date: "2024-08-18", email: 44, whatsapp: 26 },
{ date: "2024-08-19", email: 61, whatsapp: 40 },
{ date: "2024-08-20", email: 59, whatsapp: 38 },
{ date: "2024-08-21", email: 55, whatsapp: 36 },
{ date: "2024-08-22", email: 63, whatsapp: 42 },
{ date: "2024-08-23", email: 53, whatsapp: 33 },
{ date: "2024-08-24", email: 47, whatsapp: 28 },
{ date: "2024-08-25", email: 43, whatsapp: 26 },
{ date: "2024-08-26", email: 60, whatsapp: 39 },
{ date: "2024-08-27", email: 62, whatsapp: 41 },
{ date: "2024-08-28", email: 65, whatsapp: 43 },
{ date: "2024-08-29", email: 58, whatsapp: 37 },
{ date: "2024-08-30", email: 54, whatsapp: 34 },
{ date: "2024-08-31", email: 48, whatsapp: 29 },
]
const chartConfig = {
email: {
label: "E-mail",
color: "var(--chart-1)",
},
whatsapp: {
label: "WhatsApp",
color: "var(--chart-2)",
},
} satisfies ChartConfig
export function ChartAreaInteractive() {
const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("90d")
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
React.useEffect(() => {
if (isMobile) {
@ -118,19 +51,50 @@ export function ChartAreaInteractive() {
}
}, [isMobile])
const filteredData = chartData.filter((item) => {
const date = new Date(item.date)
const referenceDate = new Date("2024-08-31")
let daysToSubtract = 90
if (timeRange === "30d") {
daysToSubtract = 30
} else if (timeRange === "7d") {
daysToSubtract = 7
}
const startDate = new Date(referenceDate)
startDate.setDate(referenceDate.getDate() - daysToSubtract)
return date >= startDate
})
const report = useQuery(
api.reports.ticketsByChannel,
convexUserId
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange })
: "skip"
)
const channels = React.useMemo(() => report?.channels ?? [], [report])
const palette = React.useMemo(
() => [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
],
[]
)
const chartConfig = React.useMemo(() => {
const entries = channels.map((channel, index) => [
channel,
{
label: channel
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase()),
color: palette[index % palette.length],
},
])
return Object.fromEntries(entries) as ChartConfig
}, [channels, palette])
const chartData = React.useMemo(() => {
if (!report?.points) return []
return report.points.map((point) => {
const entry: Record<string, number | string> = { date: point.date }
for (const channel of channels) {
entry[channel] = point.values[channel] ?? 0
}
return entry
})
}, [channels, report])
return (
<Card className="@container/card">
@ -138,9 +102,9 @@ export function ChartAreaInteractive() {
<CardTitle>Entrada de tickets por canal</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Comparativo entre e-mail e WhatsApp
Distribuição dos canais nos últimos {timeRange.replace("d", " dias")}
</span>
<span className="@[540px]/card:hidden">Últimos 90 dias</span>
<span className="@[540px]/card:hidden">Período: {timeRange}</span>
</CardDescription>
<CardAction>
<ToggleGroup
@ -177,86 +141,83 @@ export function ChartAreaInteractive() {
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillEmail" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--chart-1)"
stopOpacity={0.85}
/>
<stop
offset="95%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillWhatsapp" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--chart-2)"
stopOpacity={0.85}
/>
<stop
offset="95%"
stopColor="var(--chart-2)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("pt-BR", {
month: "short",
day: "2-digit",
})
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) =>
new Date(value).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
})
}
indicator="dot"
/>
}
/>
<Area
dataKey="whatsapp"
type="natural"
fill="url(#fillWhatsapp)"
stroke="var(--chart-2)"
strokeWidth={2}
stackId="a"
name={chartConfig.whatsapp.label}
/>
<Area
dataKey="email"
type="natural"
fill="url(#fillEmail)"
stroke="var(--chart-1)"
strokeWidth={2}
stackId="a"
name={chartConfig.email.label}
/>
</AreaChart>
</ChartContainer>
{report === undefined ? (
<div className="flex h-[250px] items-center justify-center">
<Skeleton className="h-24 w-full" />
</div>
) : chartData.length === 0 || channels.length === 0 ? (
<div className="flex h-[250px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
Sem dados suficientes no período selecionado.
</div>
) : (
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={chartData}>
<defs>
{channels.map((channel) => (
<linearGradient key={channel} id={`fill-${channel}`} x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor={chartConfig[channel]?.color ?? "var(--chart-1)"}
stopOpacity={0.85}
/>
<stop
offset="95%"
stopColor={chartConfig[channel]?.color ?? "var(--chart-1)"}
stopOpacity={0.1}
/>
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("pt-BR", {
month: "short",
day: "2-digit",
})
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) =>
new Date(value).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
})
}
indicator="dot"
/>
}
/>
{channels
.slice()
.reverse()
.map((channel) => (
<Area
key={channel}
dataKey={channel}
type="natural"
fill={`url(#fill-${channel})`}
stroke={chartConfig[channel]?.color ?? "var(--chart-1)"}
strokeWidth={2}
stackId="a"
name={chartConfig[channel]?.label ?? channel}
/>
))}
</AreaChart>
</ChartContainer>
)}
</CardContent>
</Card>
)

View file

@ -0,0 +1,122 @@
"use client"
import { type ReactNode, useMemo, useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { LogOut, PlusCircle } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { cn } from "@/lib/utils"
import { useAuth, signOut } from "@/lib/auth-client"
interface PortalShellProps {
children: ReactNode
}
const navItems = [
{ label: "Meus chamados", href: "/portal/tickets" },
{ label: "Abrir chamado", href: "/portal/tickets/new", icon: PlusCircle },
]
export function PortalShell({ children }: PortalShellProps) {
const pathname = usePathname()
const { session, isCustomer } = useAuth()
const [isSigningOut, setIsSigningOut] = useState(false)
const initials = useMemo(() => {
const name = session?.user.name || session?.user.email || "Cliente"
return name
.split(" ")
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join("")
}, [session?.user.name, session?.user.email])
async function handleSignOut() {
if (isSigningOut) return
setIsSigningOut(true)
toast.loading("Encerrando sessão...", { id: "portal-signout" })
try {
await signOut()
toast.success("Sessão encerrada", { id: "portal-signout" })
} catch (error) {
console.error(error)
toast.error("Não foi possível encerrar a sessão", { id: "portal-signout" })
setIsSigningOut(false)
}
}
return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-slate-50 via-slate-50 to-white">
<header className="border-b border-slate-200 bg-white/90 backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-4 px-6 py-4">
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-[0.28em] text-neutral-500">
Portal do cliente
</span>
<span className="text-lg font-semibold text-neutral-900">Sistema de chamados</span>
</div>
<nav className="flex items-center gap-3 text-sm font-medium">
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`)
const Icon = item.icon
return (
<Link
key={item.href}
href={item.href}
className={cn(
"inline-flex items-center gap-2 rounded-full px-4 py-2 transition",
isActive
? "bg-neutral-900 text-white shadow-sm"
: "bg-transparent text-neutral-700 hover:bg-neutral-100"
)}
>
{Icon ? <Icon className="size-4" /> : null}
{item.label}
</Link>
)
})}
</nav>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm">
<Avatar className="size-9 border border-slate-200">
<AvatarImage src={session?.user.avatarUrl ?? undefined} alt={session?.user.name ?? ""} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col leading-tight">
<span className="font-semibold text-neutral-900">{session?.user.name ?? "Cliente"}</span>
<span className="text-xs text-neutral-500">{session?.user.email ?? ""}</span>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={handleSignOut}
disabled={isSigningOut}
className="inline-flex items-center gap-2"
>
<LogOut className="size-4" />
Sair
</Button>
</div>
</div>
</header>
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-6 py-8">
{!isCustomer ? (
<div className="rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Este portal é voltado a clientes. Algumas ações podem não estar disponíveis para o seu perfil.
</div>
) : null}
{children}
</main>
<footer className="border-t border-slate-200 bg-white/70">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4 text-xs text-neutral-500">
<span>&copy; {new Date().getFullYear()} Sistema de chamados</span>
<span>Suporte: suporte@sistema.dev</span>
</div>
</footer>
</div>
)
}

View file

@ -0,0 +1,104 @@
"use client"
import { format } from "date-fns"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import Link from "next/link"
import { Tag } from "lucide-react"
import type { Ticket } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { cn } from "@/lib/utils"
const statusLabel: Record<Ticket["status"], string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const statusTone: Record<Ticket["status"], string> = {
NEW: "bg-slate-200 text-slate-800",
OPEN: "bg-sky-100 text-sky-700",
PENDING: "bg-amber-100 text-amber-700",
ON_HOLD: "bg-violet-100 text-violet-700",
RESOLVED: "bg-emerald-100 text-emerald-700",
CLOSED: "bg-slate-100 text-slate-600",
}
const priorityLabel: Record<Ticket["priority"], string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
const priorityTone: Record<Ticket["priority"], string> = {
LOW: "bg-slate-100 text-slate-600",
MEDIUM: "bg-sky-100 text-sky-700",
HIGH: "bg-amber-100 text-amber-700",
URGENT: "bg-rose-100 text-rose-700",
}
interface PortalTicketCardProps {
ticket: Ticket
}
export function PortalTicketCard({ ticket }: PortalTicketCardProps) {
const updatedAgo = formatDistanceToNow(ticket.updatedAt, {
addSuffix: true,
locale: ptBR,
})
return (
<Link href={`/portal/tickets/${ticket.id}`} className="block">
<Card className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-start justify-between gap-3 px-5 pb-3 pt-5">
<div>
<div className="flex items-center gap-2 text-sm text-neutral-500">
<span className="font-semibold text-neutral-900">#{ticket.reference}</span>
<span>&middot;</span>
<span>{format(ticket.createdAt, "dd/MM/yyyy")}</span>
</div>
<h3 className="mt-1 text-lg font-semibold text-neutral-900">{ticket.subject}</h3>
{ticket.summary ? (
<p className="mt-1 line-clamp-2 text-sm text-neutral-600">{ticket.summary}</p>
) : null}
</div>
<div className="flex flex-col items-end gap-2 text-right">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold uppercase", statusTone[ticket.status])}>
{statusLabel[ticket.status]}
</Badge>
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold uppercase", priorityTone[ticket.priority])}>
{priorityLabel[ticket.priority]}
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-wrap items-center justify-between gap-4 border-t border-slate-100 px-5 py-4 text-sm text-neutral-600">
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-neutral-500">Fila</span>
<span className="font-medium text-neutral-800">{ticket.queue ?? "Sem fila"}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-neutral-500">Status</span>
<span className="font-medium text-neutral-800">{statusLabel[ticket.status]}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-neutral-500">Última atualização</span>
<span className="font-medium text-neutral-800">{updatedAgo}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-neutral-500">Categoria</span>
<span className="flex items-center gap-2 font-medium text-neutral-800">
<Tag className="size-4 text-neutral-500" />
{ticket.category?.name ?? "Não classificada"}
</span>
</div>
</CardContent>
</Card>
</Link>
)
}

View file

@ -0,0 +1,303 @@
"use client"
import { useMemo, useState } from "react"
import { useQuery, useMutation } from "convex/react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { MessageCircle } from "lucide-react"
import { toast } from "sonner"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Skeleton } from "@/components/ui/skeleton"
import { Textarea } from "@/components/ui/textarea"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
const statusLabel: Record<TicketWithDetails["status"], string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
const priorityTone: Record<TicketWithDetails["priority"], string> = {
LOW: "bg-slate-100 text-slate-600",
MEDIUM: "bg-sky-100 text-sky-700",
HIGH: "bg-amber-100 text-amber-700",
URGENT: "bg-rose-100 text-rose-700",
}
const timelineLabels: Record<string, string> = {
CREATED: "Chamado criado",
STATUS_CHANGED: "Status atualizado",
ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Novo comentário",
COMMENT_EDITED: "Comentário editado",
ATTACHMENT_REMOVED: "Anexo removido",
QUEUE_CHANGED: "Fila atualizada",
}
function toHtmlFromText(text: string) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
return `<p>${escaped.replace(/\n/g, "<br />")}</p>`
}
interface PortalTicketDetailProps {
ticketId: string
}
export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
const { convexUserId, session } = useAuth()
const addComment = useMutation(api.tickets.addComment)
const [comment, setComment] = useState("")
const ticketRaw = useQuery(
api.tickets.getById,
convexUserId
? {
tenantId: session?.user.tenantId ?? DEFAULT_TENANT_ID,
id: ticketId as Id<"tickets">,
viewerId: convexUserId as Id<"users">,
}
: "skip"
)
const ticket = useMemo(() => {
if (!ticketRaw) return null
return mapTicketWithDetailsFromServer(ticketRaw)
}, [ticketRaw])
if (ticketRaw === undefined) {
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-5 py-5">
<CardTitle className="text-lg font-semibold text-neutral-900">Carregando ticket...</CardTitle>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-6">
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</CardContent>
</Card>
)
}
if (!ticket) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<span className="text-2xl">🔍</span>
</EmptyMedia>
<EmptyTitle className="text-neutral-900">Ticket não encontrado</EmptyTitle>
<EmptyDescription className="text-neutral-600">
Verifique o endereço ou retorne à lista de chamados.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
const createdAt = format(ticket.createdAt, "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })
const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!convexUserId || !comment.trim()) return
const toastId = "portal-add-comment"
toast.loading("Enviando comentário...", { id: toastId })
try {
const htmlBody = sanitizeEditorHtml(toHtmlFromText(comment.trim()))
await addComment({
ticketId: ticket.id as Id<"tickets">,
authorId: convexUserId as Id<"users">,
visibility: "PUBLIC",
body: htmlBody,
attachments: [],
})
setComment("")
toast.success("Comentário enviado!", { id: toastId })
} catch (error) {
console.error(error)
toast.error("Não foi possível enviar o comentário.", { id: toastId })
}
}
return (
<div className="space-y-6">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-5 pb-3 pt-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-sm text-neutral-500">Ticket #{ticket.reference}</p>
<h1 className="mt-1 text-2xl font-semibold text-neutral-900">{ticket.subject}</h1>
{ticket.summary ? (
<p className="mt-2 max-w-3xl text-sm text-neutral-600">{ticket.summary}</p>
) : null}
</div>
<div className="flex flex-col items-end gap-2 text-sm">
<Badge className="rounded-full bg-neutral-900 px-3 py-1 text-xs font-semibold uppercase text-white">
{statusLabel[ticket.status]}
</Badge>
<Badge className={`rounded-full px-3 py-1 text-xs font-semibold uppercase ${priorityTone[ticket.priority]}`}>
{priorityLabel[ticket.priority]}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-4 border-t border-slate-100 px-5 py-5 text-sm text-neutral-700 sm:grid-cols-2">
<DetailItem label="Fila" value={ticket.queue ?? "Sem fila"} />
<DetailItem label="Categoria" value={ticket.category?.name ?? "Não classificada"} />
<DetailItem label="Solicitante" value={ticket.requester.name} subtitle={ticket.requester.email} />
<DetailItem label="Responsável" value={ticket.assignee?.name ?? "Equipe de suporte"} />
<DetailItem label="Criado em" value={createdAt} />
<DetailItem label="Última atualização" value={updatedAgo} />
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="flex flex-row items-center justify-between px-5 py-4">
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<MessageCircle className="size-5 text-neutral-500" /> Conversas
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-5 pb-6">
<form onSubmit={handleSubmit} className="space-y-3">
<label htmlFor="comment" className="text-sm font-medium text-neutral-800">
Enviar uma mensagem para a equipe
</label>
<Textarea
id="comment"
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="Descreva o que aconteceu, envie atualizações ou compartilhe novas informações."
className="min-h-[120px] resize-y rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-neutral-800 shadow-sm focus-visible:border-neutral-900 focus-visible:ring-neutral-900/20"
/>
<div className="flex justify-end">
<Button type="submit" className="rounded-full bg-neutral-900 px-6 text-sm font-semibold text-white hover:bg-neutral-900/90">
Enviar comentário
</Button>
</div>
</form>
<div className="space-y-5">
{ticket.comments.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<MessageCircle className="size-5 text-neutral-500" />
</EmptyMedia>
<EmptyTitle className="text-neutral-900">Nenhum comentário ainda</EmptyTitle>
<EmptyDescription className="text-neutral-600">
Registre a primeira atualização acima.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
ticket.comments.map((commentItem) => {
const initials = commentItem.author.name
.split(" ")
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join("")
const createdAgo = formatDistanceToNow(commentItem.createdAt, {
addSuffix: true,
locale: ptBR,
})
return (
<div key={commentItem.id} className="rounded-xl border border-slate-100 bg-slate-50/70 p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Avatar className="size-9 border border-slate-200">
<AvatarImage src={commentItem.author.avatarUrl} alt={commentItem.author.name} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-semibold text-neutral-900">{commentItem.author.name}</span>
<span className="text-xs text-neutral-500">{createdAgo}</span>
</div>
</div>
<Badge variant="outline" className="rounded-full border-dashed px-3 py-1 text-[11px] uppercase text-neutral-600">
{commentItem.visibility === "PUBLIC" ? "Público" : "Interno"}
</Badge>
</div>
<div
className="prose prose-sm mt-3 max-w-none text-neutral-800"
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(commentItem.body ?? "") }}
/>
</div>
)
})
)}
</div>
</CardContent>
</Card>
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-5 py-4">
<CardTitle className="text-lg font-semibold text-neutral-900">Linha do tempo</CardTitle>
</CardHeader>
<CardContent className="space-y-5 px-5 pb-6 text-sm text-neutral-700">
{ticket.timeline.length === 0 ? (
<p className="text-sm text-neutral-500">Nenhum evento registrado ainda.</p>
) : (
ticket.timeline
.slice()
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.map((event) => {
const label = timelineLabels[event.type] ?? event.type
const when = formatDistanceToNow(event.createdAt, { addSuffix: true, locale: ptBR })
return (
<div key={event.id} className="flex flex-col gap-1 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<span className="text-sm font-semibold text-neutral-900">{label}</span>
<span className="text-xs text-neutral-500">{when}</span>
</div>
)
})
)}
</CardContent>
</Card>
</div>
</div>
)
}
interface DetailItemProps {
label: string
value: string
subtitle?: string | null
}
function DetailItem({ label, value, subtitle }: DetailItemProps) {
return (
<div className="rounded-xl border border-dashed border-slate-200 bg-white/60 px-4 py-3 shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<p className="text-xs uppercase tracking-wide text-neutral-500">{label}</p>
<p className="text-sm font-medium text-neutral-900">{value}</p>
{subtitle ? <p className="text-xs text-neutral-500">{subtitle}</p> : null}
</div>
)
}

View file

@ -0,0 +1,197 @@
"use client"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useMutation } from "convex/react"
import { toast } from "sonner"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { TicketPriority } from "@/lib/schemas/ticket"
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { useAuth } from "@/lib/auth-client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { CategorySelectFields } from "@/components/tickets/category-select"
const priorityLabel: Record<TicketPriority, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
function toHtml(text: string) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
return `<p>${escaped.replace(/\n/g, "<br />")}</p>`
}
export function PortalTicketForm() {
const router = useRouter()
const { convexUserId, session } = useAuth()
const createTicket = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment)
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const [subject, setSubject] = useState("")
const [summary, setSummary] = useState("")
const [description, setDescription] = useState("")
const [priority, setPriority] = useState<TicketPriority>("MEDIUM")
const [categoryId, setCategoryId] = useState<string | null>(null)
const [subcategoryId, setSubcategoryId] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const isFormValid = useMemo(() => {
return Boolean(subject.trim() && categoryId && subcategoryId)
}, [subject, categoryId, subcategoryId])
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!convexUserId || !isFormValid || isSubmitting) return
const trimmedSubject = subject.trim()
const trimmedSummary = summary.trim()
setIsSubmitting(true)
toast.loading("Abrindo chamado...", { id: "portal-new-ticket" })
try {
const id = await createTicket({
actorId: convexUserId as Id<"users">,
tenantId,
subject: trimmedSubject,
summary: trimmedSummary || undefined,
priority,
channel: "MANUAL",
queueId: undefined,
requesterId: convexUserId as Id<"users">,
categoryId: categoryId as Id<"ticketCategories">,
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
})
if (description.trim().length > 0) {
const htmlBody = sanitizeEditorHtml(toHtml(description.trim()))
await addComment({
ticketId: id as Id<"tickets">,
authorId: convexUserId as Id<"users">,
visibility: "PUBLIC",
body: htmlBody,
attachments: [],
})
}
toast.success("Chamado criado com sucesso!", { id: "portal-new-ticket" })
router.replace(`/portal/tickets/${id}`)
} catch (error) {
console.error(error)
toast.error("Não foi possível abrir o chamado.", { id: "portal-new-ticket" })
} finally {
setIsSubmitting(false)
}
}
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-5 py-5">
<CardTitle className="text-xl font-semibold text-neutral-900">Abrir novo chamado</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-5 pb-6">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-3">
<div className="space-y-1">
<label htmlFor="subject" className="text-sm font-medium text-neutral-800">
Assunto
</label>
<Input
id="subject"
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Ex.: Problema de acesso ao sistema"
required
/>
</div>
<div className="space-y-1">
<label htmlFor="summary" className="text-sm font-medium text-neutral-800">
Resumo (opcional)
</label>
<Input
id="summary"
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder="Descreva rapidamente o que está acontecendo"
/>
</div>
<div className="space-y-1">
<label htmlFor="description" className="text-sm font-medium text-neutral-800">
Detalhes
</label>
<Textarea
id="description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Compartilhe passos para reproduzir, mensagens de erro ou informações adicionais."
className="min-h-[140px] resize-y rounded-xl border border-slate-200 px-4 py-3 text-sm text-neutral-800 shadow-sm focus-visible:border-neutral-900 focus-visible:ring-neutral-900/20"
/>
</div>
</div>
<div className="space-y-4">
<div className="space-y-1">
<span className="text-sm font-medium text-neutral-800">Prioridade</span>
<Select value={priority} onValueChange={(value) => setPriority(value as TicketPriority)}>
<SelectTrigger className="h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-neutral-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{(Object.keys(priorityLabel) as TicketPriority[]).map((option) => (
<SelectItem key={option} value={option} className="text-sm">
{priorityLabel[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<CategorySelectFields
tenantId={tenantId}
categoryId={categoryId}
subcategoryId={subcategoryId}
onCategoryChange={setCategoryId}
onSubcategoryChange={setSubcategoryId}
layout="stacked"
categoryLabel="Categoria"
subcategoryLabel="Subcategoria"
secondaryEmptyLabel="Selecione uma categoria"
/>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => router.push("/portal/tickets")}
>
Cancelar
</Button>
<Button
type="submit"
disabled={!isFormValid || isSubmitting}
className="rounded-full bg-neutral-900 px-6 text-sm font-semibold text-white hover:bg-neutral-900/90"
>
Registrar chamado
</Button>
</div>
</form>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,89 @@
"use client"
import { useMemo } from "react"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Skeleton } from "@/components/ui/skeleton"
import { PortalTicketCard } from "@/components/portal/portal-ticket-card"
export function PortalTicketList() {
const { convexUserId, session } = useAuth()
const ticketsRaw = useQuery(
api.tickets.list,
convexUserId
? {
tenantId: session?.user.tenantId ?? DEFAULT_TENANT_ID,
viewerId: convexUserId as Id<"users">,
limit: 100,
}
: "skip"
)
const tickets = useMemo(() => {
if (!ticketsRaw) return []
return mapTicketsFromServerList((ticketsRaw as unknown[]) ?? [])
}, [ticketsRaw])
if (ticketsRaw === undefined) {
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-5 py-5">
<CardTitle className="text-lg font-semibold text-neutral-900">Carregando chamados...</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 px-5 pb-6">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-[132px] w-full rounded-xl" />
))}
</CardContent>
</Card>
)
}
if (!tickets.length) {
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-5 py-5">
<CardTitle className="text-lg font-semibold text-neutral-900">Meus chamados</CardTitle>
</CardHeader>
<CardContent className="px-5 pb-6">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<span className="text-2xl">📭</span>
</EmptyMedia>
<EmptyTitle className="text-neutral-900">Nenhum chamado aberto</EmptyTitle>
<EmptyDescription className="text-neutral-600">
Quando você registrar um chamado, ele aparecerá aqui. Clique em Abrir chamado para iniciar um novo atendimento.
</EmptyDescription>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-neutral-900">Meus chamados</h2>
<p className="text-sm text-neutral-600">Acompanhe seus tickets e veja as últimas atualizações.</p>
</div>
</div>
<div className="grid gap-4">
{(tickets as Ticket[]).map((ticket) => (
<PortalTicketCard key={ticket.id} ticket={ticket} />
))}
</div>
</div>
)
}

View file

@ -0,0 +1,172 @@
"use client"
import { useMemo } from "react"
import { useQuery } from "convex/react"
import { IconInbox, IconAlertTriangle, IconFilter } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
const PRIORITY_LABELS: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Crítica",
}
const STATUS_LABELS: Record<string, string> = {
NEW: "Novo",
OPEN: "Em andamento",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Encerrado",
}
export function BacklogReport() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const data = useQuery(
api.reports.backlogOverview,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
const mostCriticalPriority = useMemo(() => {
if (!data) return null
const entries = Object.entries(data.priorityCounts)
if (entries.length === 0) return null
return entries.reduce((prev, current) => (current[1] > prev[1] ? current : prev))
}, [data])
if (!data) {
return (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-32 rounded-2xl" />
))}
</div>
)
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconInbox className="size-4 text-neutral-500" /> Tickets em aberto
</CardTitle>
<CardDescription className="text-neutral-600">Backlog total em atendimento.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totalOpen}</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconAlertTriangle className="size-4 text-amber-500" /> Prioridade predominante
</CardTitle>
<CardDescription className="text-neutral-600">Volume por prioridade no backlog.</CardDescription>
</CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900">
{mostCriticalPriority ? (
<span>
{PRIORITY_LABELS[mostCriticalPriority[0]] ?? mostCriticalPriority[0]} ({mostCriticalPriority[1]})
</span>
) : (
"—"
)}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconFilter className="size-4 text-neutral-500" /> Status acompanhados
</CardTitle>
<CardDescription className="text-neutral-600">Distribuição dos tickets por status.</CardDescription>
</CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900">
{Object.keys(data.statusCounts).length}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Status do backlog</CardTitle>
<CardDescription className="text-neutral-600">
Acompanhe a evolução dos tickets pelas fases do fluxo de atendimento.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Object.entries(data.statusCounts).map(([status, total]) => (
<div key={status} className="rounded-xl border border-slate-200 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
{STATUS_LABELS[status] ?? status}
</p>
<p className="mt-2 text-2xl font-semibold text-neutral-900">{total}</p>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Backlog por prioridade</CardTitle>
<CardDescription className="text-neutral-600">
Analise a pressão de atendimento conforme o nível de urgência.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{Object.entries(data.priorityCounts).map(([priority, total]) => (
<div key={priority} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-sm font-medium text-neutral-800">
{PRIORITY_LABELS[priority] ?? priority}
</span>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{total} ticket{total === 1 ? "" : "s"}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Filas com maior backlog</CardTitle>
<CardDescription className="text-neutral-600">
Identifique onde concentrar esforços de atendimento.
</CardDescription>
</CardHeader>
<CardContent>
{data.queueCounts.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Nenhuma fila com tickets abertos no momento.
</p>
) : (
<ul className="space-y-3">
{data.queueCounts.map((queue) => (
<li key={queue.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<div className="flex flex-col">
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>
</div>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{queue.total} em aberto
</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,111 @@
"use client"
import { useQuery } from "convex/react"
import { IconMoodSmile, IconStars, IconMessageCircle2 } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
function formatScore(value: number | null) {
if (value === null) return "—"
return value.toFixed(2)
}
export function CsatReport() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const data = useQuery(
api.reports.csatOverview,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
if (!data) {
return (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-32 rounded-2xl" />
))}
</div>
)
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconMoodSmile className="size-4 text-teal-500" /> CSAT médio
</CardTitle>
<CardDescription className="text-neutral-600">Média das respostas recebidas.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{formatScore(data.averageScore)}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconStars className="size-4 text-violet-500" /> Total de respostas
</CardTitle>
<CardDescription className="text-neutral-600">Avaliações coletadas nos tickets.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totalSurveys}</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconMessageCircle2 className="size-4 text-sky-500" /> Últimas avaliações
</CardTitle>
<CardDescription className="text-neutral-600">Até 10 registros mais recentes.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{data.recent.length === 0 ? (
<p className="text-sm text-neutral-500">Ainda não coletamos nenhuma avaliação.</p>
) : (
data.recent.map((item) => (
<div key={`${item.ticketId}-${item.receivedAt}`} className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm">
<span>#{item.reference}</span>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
Nota {item.score}
</Badge>
</div>
))
)}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Distribuição das notas</CardTitle>
<CardDescription className="text-neutral-600">
Frequência de respostas para cada valor na escala de 1 a 5.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{data.distribution.map((entry) => (
<li key={entry.score} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
Nota {entry.score}
</Badge>
<span className="text-sm text-neutral-700">{entry.total} respostas</span>
</div>
<span className="text-sm font-medium text-neutral-900">
{data.totalSurveys === 0 ? "0%" : `${((entry.total / data.totalSurveys) * 100).toFixed(0)}%`}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,124 @@
"use client"
import { useMemo } from "react"
import { useQuery } from "convex/react"
import { IconAlertTriangle, IconGraph, IconClockHour4 } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
function formatMinutes(value: number | null) {
if (value === null) return "—"
if (value < 60) return `${value.toFixed(0)} min`
const hours = Math.floor(value / 60)
const minutes = Math.round(value % 60)
if (minutes === 0) return `${hours}h`
return `${hours}h ${minutes}min`
}
export function SlaReport() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const data = useQuery(
api.reports.slaOverview,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
const queueTotal = useMemo(() => data?.queueBreakdown.reduce((acc, queue) => acc + queue.open, 0) ?? 0, [data])
if (!data) {
return (
<div className="space-y-6">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-32 rounded-2xl" />
))}
</div>
)
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-4">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tickets abertos</CardTitle>
<CardDescription className="text-neutral-600">Chamados ativos acompanhados pelo SLA.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totals.open}</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconAlertTriangle className="size-4 text-amber-500" /> Vencidos
</CardTitle>
<CardDescription className="text-neutral-600">Tickets que ultrapassaram o prazo previsto.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totals.overdue}</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconClockHour4 className="size-4 text-neutral-500" /> Tempo resposta médio
</CardTitle>
<CardDescription className="text-neutral-600">Com base nos tickets respondidos.</CardDescription>
</CardHeader>
<CardContent className="text-2xl font-semibold text-neutral-900">
{formatMinutes(data.response.averageFirstResponseMinutes ?? null)}
<p className="mt-1 text-xs text-neutral-500">{data.response.responsesRegistered} registros</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconGraph className="size-4 text-neutral-500" /> Tempo resolução médio
</CardTitle>
<CardDescription className="text-neutral-600">Chamados finalizados no período analisado.</CardDescription>
</CardHeader>
<CardContent className="text-2xl font-semibold text-neutral-900">
{formatMinutes(data.resolution.averageResolutionMinutes ?? null)}
<p className="mt-1 text-xs text-neutral-500">{data.resolution.resolvedCount} resolvidos</p>
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-lg font-semibold text-neutral-900">Fila x Volume aberto</CardTitle>
<CardDescription className="text-neutral-600">
Distribuição dos {queueTotal} tickets abertos por fila de atendimento.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{data.queueBreakdown.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Nenhuma fila com tickets ativos no momento.
</p>
) : (
<ul className="space-y-3">
{data.queueBreakdown.map((queue) => (
<li key={queue.id} className="flex items-center justify-between gap-4 rounded-xl border border-slate-200 px-4 py-3">
<div className="flex flex-col">
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>
<span className="text-xs text-neutral-500">{((queue.open / Math.max(queueTotal, 1)) * 100).toFixed(0)}% do volume aberto</span>
</div>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{queue.open} tickets
</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -1,5 +1,13 @@
import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
"use client"
import { useMemo } from "react"
import { useQuery } from "convex/react"
import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Badge } from "@/components/ui/badge"
import {
Card,
@ -9,74 +17,153 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
function formatMinutes(value: number | null) {
if (value === null) return "—"
if (value < 60) return `${Math.round(value)} min`
const hours = Math.floor(value / 60)
const minutes = Math.round(value % 60)
if (minutes === 0) return `${hours}h`
return `${hours}h ${minutes}min`
}
function formatScore(value: number | null) {
if (value === null) return "—"
return value.toFixed(2)
}
export function SectionCards() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const dashboard = useQuery(
api.reports.dashboardOverview,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
const trendInfo = useMemo(() => {
if (!dashboard?.newTickets) return { value: null, label: "Aguardando dados", icon: IconTrendingUp }
const trend = dashboard.newTickets.trendPercentage
if (trend === null) {
return { value: null, label: "Sem histórico", icon: IconTrendingUp }
}
const positive = trend >= 0
const icon = positive ? IconTrendingUp : IconTrendingDown
const label = `${positive ? "+" : ""}${trend.toFixed(1)}%`
return { value: trend, label, icon }
}, [dashboard])
const responseDelta = useMemo(() => {
if (!dashboard?.firstResponse) return { delta: null, label: "Sem dados", positive: false }
const delta = dashboard.firstResponse.deltaMinutes
if (delta === null) return { delta: null, label: "Sem comparação", positive: false }
const positive = delta <= 0
const value = `${delta > 0 ? "+" : ""}${Math.round(delta)} min`
return { delta, label: value, positive }
}, [dashboard])
const TrendIcon = trendInfo.icon
return (
<div className="grid grid-cols-1 gap-4 px-4 sm:grid-cols-2 xl:grid-cols-4 xl:px-8">
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3">
<CardDescription>Tickets novos</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">128</CardTitle>
<CardTitle className="text-3xl font-semibold tabular-nums">
{dashboard ? dashboard.newTickets.last24h : <Skeleton className="h-8 w-20" />}
</CardTitle>
<CardAction>
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
<IconTrendingUp className="size-3.5" />
+8%
<Badge
variant="outline"
className={`rounded-full gap-1 px-2 py-1 text-xs ${
trendInfo.value !== null && trendInfo.value < 0 ? "text-red-500" : ""
}`}
>
<TrendIcon className="size-3.5" />
{trendInfo.label}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<div className="flex gap-2 text-foreground">
Volume acima da média semanal <IconTrendingUp className="size-4" />
{trendInfo.value === null
? "Aguardando histórico"
: trendInfo.value >= 0
? "Volume acima do período anterior"
: "Volume abaixo do período anterior"}
</div>
<span>Últimas 24h considerando e-mail e WhatsApp.</span>
<span>Comparação com as 24h anteriores.</span>
</CardFooter>
</Card>
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3">
<CardDescription>Tempo médio da 1ª resposta</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">12m</CardTitle>
<CardTitle className="text-3xl font-semibold tabular-nums">
{dashboard ? formatMinutes(dashboard.firstResponse.averageMinutes) : <Skeleton className="h-8 w-24" />}
</CardTitle>
<CardAction>
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
<IconTrendingDown className="size-3.5" />
-3m
<Badge
variant="outline"
className={`rounded-full gap-1 px-2 py-1 text-xs ${
responseDelta.delta !== null && !responseDelta.positive ? "text-amber-500" : ""
}`}
>
{responseDelta.delta !== null && responseDelta.delta > 0 ? (
<IconTrendingUp className="size-3.5" />
) : (
<IconTrendingDown className="size-3.5" />
)}
{responseDelta.label}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<span className="text-foreground">SLAs cumpridos em 92% dos tickets</span>
<span>Considera filas Prioridade P1P3.</span>
<span className="text-foreground">
{dashboard
? `${dashboard.firstResponse.responsesCount} tickets com primeira resposta`
: "Carregando amostra"}
</span>
<span>Média móvel dos últimos 7 dias.</span>
</CardFooter>
</Card>
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3">
<CardDescription>Tickets aguardando ação</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">45</CardTitle>
<CardTitle className="text-3xl font-semibold tabular-nums">
{dashboard ? dashboard.awaitingAction.total : <Skeleton className="h-8 w-16" />}
</CardTitle>
<CardAction>
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
<IconClockHour4 className="size-3.5" />
12 em risco
{dashboard ? `${dashboard.awaitingAction.atRisk} em risco` : "—"}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<span className="text-foreground">Distribuir entre times prioritários</span>
<span>Inclui status "Aberto", "Pendente" e "Em espera".</span>
<span className="text-foreground">Inclui status "Novo", "Aberto" e "Em espera".</span>
<span>Atrasos calculados com base no prazo de SLA.</span>
</CardFooter>
</Card>
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3">
<CardDescription>CSAT das últimas 100 interações</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">4,7</CardTitle>
<CardDescription>CSAT recente</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">
{dashboard ? formatScore(dashboard.csat.averageScore) : <Skeleton className="h-8 w-12" />}
</CardTitle>
<CardAction>
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
<IconMessages className="size-3.5" />
63 pesquisas
{dashboard ? `${dashboard.csat.totalSurveys} pesquisas` : "—"}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<span className="text-foreground">Destaque: fila Field Services</span>
<span>CSAT com escala de 1 a 5.</span>
<span className="text-foreground">Notas de satisfação recebidas nos últimos períodos.</span>
<span>Escala de 1 a 5 pontos.</span>
</CardFooter>
</Card>
</div>

View file

@ -0,0 +1,280 @@
"use client"
import { useMemo, useState } from "react"
import Link from "next/link"
import { toast } from "sonner"
import { Settings2, Share2, ShieldCheck, UserCog, UserPlus, Users2, Layers3 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { useAuth, signOut } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { LucideIcon } from "lucide-react"
type RoleRequirement = "admin" | "staff"
type SettingsAction = {
title: string
description: string
href: string
cta: string
requiredRole?: RoleRequirement
icon: LucideIcon
}
const ROLE_LABELS: Record<string, string> = {
admin: "Administrador",
manager: "Gestor",
agent: "Agente",
collaborator: "Colaborador",
customer: "Cliente",
}
const SETTINGS_ACTIONS: SettingsAction[] = [
{
title: "Times & papéis",
description: "Controle quem pode atuar nas filas e atribua permissões refinadas por equipe.",
href: "/admin/teams",
cta: "Gerenciar times",
requiredRole: "admin",
icon: Users2,
},
{
title: "Canais & roteamento",
description: "Configure canais, horários de atendimento e regras automáticas de distribuição.",
href: "/admin/channels",
cta: "Abrir canais",
requiredRole: "admin",
icon: Share2,
},
{
title: "Campos e categorias",
description: "Ajuste categorias, subcategorias e campos personalizados para qualificar tickets.",
href: "/admin/fields",
cta: "Editar estrutura",
requiredRole: "admin",
icon: Layers3,
},
{
title: "Convites e acessos",
description: "Convide novos usuários, revise papéis e acompanhe quem tem acesso ao workspace.",
href: "/admin",
cta: "Abrir painel",
requiredRole: "admin",
icon: UserPlus,
},
{
title: "Preferências da equipe",
description: "Defina padrões de notificação e comportamento do modo play para toda a equipe.",
href: "#preferencias",
cta: "Ajustar preferências",
requiredRole: "staff",
icon: Settings2,
},
{
title: "Políticas e segurança",
description: "Acompanhe SLAs críticos, rastreie integrações e revise auditorias de acesso.",
href: "/admin/slas",
cta: "Revisar SLAs",
requiredRole: "admin",
icon: ShieldCheck,
},
]
export function SettingsContent() {
const { session, isAdmin, isStaff } = useAuth()
const [isSigningOut, setIsSigningOut] = useState(false)
const normalizedRole = session?.user.role?.toLowerCase() ?? "agent"
const roleLabel = ROLE_LABELS[normalizedRole] ?? "Agente"
const tenant = session?.user.tenantId ?? DEFAULT_TENANT_ID
const sessionExpiry = useMemo(() => {
const expiresAt = session?.session?.expiresAt
if (!expiresAt) return null
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
}).format(new Date(expiresAt))
}, [session?.session?.expiresAt])
async function handleSignOut() {
if (isSigningOut) return
setIsSigningOut(true)
try {
await signOut()
} catch (error) {
console.error(error)
toast.error("Não foi possível encerrar a sessão.")
setIsSigningOut(false)
}
}
function canAccess(requiredRole?: RoleRequirement) {
if (!requiredRole) return true
if (requiredRole === "admin") return isAdmin
if (requiredRole === "staff") return isStaff
return false
}
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 pb-12 lg:px-6">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.7fr)_minmax(0,1fr)] lg:items-start">
<Card id="preferencias" className="border border-border/70">
<CardHeader className="flex flex-col gap-1">
<CardTitle className="text-2xl font-semibold text-neutral-900">Perfil</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Dados sincronizados via Better Auth e utilizados para provisionamento no Convex.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<dl className="grid gap-4 text-sm text-neutral-700 sm:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs uppercase tracking-wide text-neutral-500">Nome</dt>
<dd className="font-medium text-neutral-900">{session?.user.name ?? "—"}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs uppercase tracking-wide text-neutral-500">E-mail</dt>
<dd className="font-medium text-neutral-900">{session?.user.email ?? "—"}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs uppercase tracking-wide text-neutral-500">Tenant</dt>
<dd>
<Badge variant="outline" className="rounded-full border-dashed px-2.5 py-1 text-xs uppercase tracking-wide">
{tenant}
</Badge>
</dd>
</div>
<div className="space-y-1">
<dt className="text-xs uppercase tracking-wide text-neutral-500">Papel</dt>
<dd>
<Badge className="rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide">
{roleLabel}
</Badge>
</dd>
</div>
</dl>
<Separator />
<div className="space-y-2 text-sm text-neutral-600">
<div className="flex items-center justify-between">
<span className="font-medium text-neutral-800">Sessão ativa</span>
{session?.session?.id ? (
<code className="rounded-md bg-slate-100 px-2 py-1 text-xs font-mono text-neutral-700">
{session.session.id.slice(0, 8)}
</code>
) : null}
</div>
<p>{sessionExpiry ? `Expira em ${sessionExpiry}` : "Sessão em background com renovação automática."}</p>
<p className="text-xs text-neutral-500">
Alterações no perfil refletem instantaneamente no painel administrativo e nos relatórios.
</p>
</div>
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled>
Editar perfil (em breve)
</Button>
<Button size="sm" variant="outline" asChild>
<Link href="mailto:suporte@sistema.dev">Solicitar ajustes</Link>
</Button>
<Button size="sm" variant="destructive" onClick={handleSignOut} disabled={isSigningOut}>
Encerrar sessão
</Button>
</CardFooter>
</Card>
<Card className="border border-border/70">
<CardHeader className="flex flex-col gap-1">
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<UserCog className="size-4 text-neutral-500" /> Preferências rápidas
</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Ajustes pessoais aplicados localmente para acelerar seu fluxo de trabalho.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<PreferenceItem
title="Abertura de tickets"
description="Sempre abrir detalhes em nova aba ao clicar na listagem."
/>
<PreferenceItem
title="Notificações"
description="Receber alertas sonoros ao entrar novos tickets urgentes."
/>
<PreferenceItem
title="Modo play"
description="Priorizar tickets da fila 'Chamados' ao iniciar uma nova sessão."
/>
</CardContent>
</Card>
</div>
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold text-neutral-900">Administração do workspace</h2>
<p className="text-sm text-neutral-600">
Centralize a gestão de times, canais e políticas. Recursos marcados como restritos dependem de perfil administrador.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{SETTINGS_ACTIONS.map((action) => {
const allowed = canAccess(action.requiredRole)
const Icon = action.icon
return (
<Card key={action.title} className="border border-border/70">
<CardHeader className="flex flex-row items-start gap-3">
<div className="rounded-full bg-neutral-100 p-2 text-neutral-500">
<Icon className="size-4" />
</div>
<div className="flex flex-1 flex-col gap-1">
<CardTitle className="text-sm font-semibold text-neutral-900">{action.title}</CardTitle>
<CardDescription className="text-xs text-neutral-600">{action.description}</CardDescription>
</div>
{!allowed ? <Badge variant="outline" className="rounded-full border-dashed px-2 py-0.5 text-[10px] uppercase">Restrito</Badge> : null}
</CardHeader>
<CardFooter className="justify-end">
{allowed ? (
<Button asChild size="sm">
<Link href={action.href}>{action.cta}</Link>
</Button>
) : (
<Button size="sm" variant="outline" disabled>
Acesso restrito
</Button>
)}
</CardFooter>
</Card>
)
})}
</div>
</section>
</div>
)
}
type PreferenceItemProps = {
title: string
description: string
}
function PreferenceItem({ title, description }: PreferenceItemProps) {
const [enabled, setEnabled] = useState(false)
return (
<div className="flex items-start justify-between gap-4 rounded-xl border border-dashed border-slate-200/80 bg-white/70 p-4 shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<div className="space-y-1">
<p className="text-sm font-medium text-neutral-800">{title}</p>
<p className="text-xs text-neutral-500">{description}</p>
</div>
<Button
size="sm"
variant={enabled ? "default" : "outline"}
onClick={() => setEnabled((prev) => !prev)}
className="min-w-[96px]"
>
{enabled ? "Ativado" : "Ativar"}
</Button>
</div>
)
}

View file

@ -14,14 +14,18 @@ import { useAuth } from "@/lib/auth-client"
export function TicketsView() {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
const { convexUserId } = useAuth()
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined
const queues = useQuery(
api.queues.summary,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as TicketQueueSummary[] | undefined
const ticketsRaw = useQuery(
api.tickets.list,
convexUserId
? {
tenantId: DEFAULT_TENANT_ID,
tenantId,
viewerId: convexUserId as Id<"users">,
status: filters.status ?? undefined,
priority: filters.priority ?? undefined,