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:
parent
0ec5b49e8a
commit
29a647f6c6
43 changed files with 4992 additions and 363 deletions
32
agents.md
32
agents.md
|
|
@ -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.
|
||||
|
|
|
|||
8
web/convex/_generated/api.d.ts
vendored
8
web/convex/_generated/api.d.ts
vendored
|
|
@ -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
209
web/convex/fields.ts
Normal 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,
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -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
341
web/convex/reports.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -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
138
web/convex/slas.ts
Normal 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
232
web/convex/teams.ts
Normal 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 ?? [],
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
|
@ -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">[] = [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export default {
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
17
web/src/app/admin/channels/page.tsx
Normal file
17
web/src/app/admin/channels/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
web/src/app/admin/fields/page.tsx
Normal file
17
web/src/app/admin/fields/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
web/src/app/admin/slas/page.tsx
Normal file
17
web/src/app/admin/slas/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
web/src/app/admin/teams/page.tsx
Normal file
17
web/src/app/admin/teams/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -86,7 +86,7 @@ export async function POST(request: Request) {
|
|||
tenantId,
|
||||
accounts: {
|
||||
create: {
|
||||
providerId: "email",
|
||||
providerId: "credential",
|
||||
accountId: emailInput,
|
||||
password: hashedPassword,
|
||||
},
|
||||
|
|
|
|||
7
web/src/app/portal/layout.tsx
Normal file
7
web/src/app/portal/layout.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
5
web/src/app/portal/tickets/[id]/page.tsx
Normal file
5
web/src/app/portal/tickets/[id]/page.tsx
Normal 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} />
|
||||
}
|
||||
12
web/src/app/portal/tickets/new/page.tsx
Normal file
12
web/src/app/portal/tickets/new/page.tsx
Normal 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 />
|
||||
}
|
||||
12
web/src/app/portal/tickets/page.tsx
Normal file
12
web/src/app/portal/tickets/page.tsx
Normal 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 />
|
||||
}
|
||||
17
web/src/app/reports/backlog/page.tsx
Normal file
17
web/src/app/reports/backlog/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
web/src/app/reports/csat/page.tsx
Normal file
17
web/src/app/reports/csat/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
web/src/app/reports/sla/page.tsx
Normal file
17
web/src/app/reports/sla/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
web/src/app/settings/page.tsx
Normal file
13
web/src/app/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
551
web/src/components/admin/fields/fields-manager.tsx
Normal file
551
web/src/components/admin/fields/fields-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
322
web/src/components/admin/queues/queues-manager.tsx
Normal file
322
web/src/components/admin/queues/queues-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
390
web/src/components/admin/slas/slas-manager.tsx
Normal file
390
web/src/components/admin/slas/slas-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
428
web/src/components/admin/teams/teams-manager.tsx
Normal file
428
web/src/components/admin/teams/teams-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
122
web/src/components/portal/portal-shell.tsx
Normal file
122
web/src/components/portal/portal-shell.tsx
Normal 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>© {new Date().getFullYear()} Sistema de chamados</span>
|
||||
<span>Suporte: suporte@sistema.dev</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
web/src/components/portal/portal-ticket-card.tsx
Normal file
104
web/src/components/portal/portal-ticket-card.tsx
Normal 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>·</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>
|
||||
)
|
||||
}
|
||||
303
web/src/components/portal/portal-ticket-detail.tsx
Normal file
303
web/src/components/portal/portal-ticket-detail.tsx
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
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>
|
||||
)
|
||||
}
|
||||
197
web/src/components/portal/portal-ticket-form.tsx
Normal file
197
web/src/components/portal/portal-ticket-form.tsx
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
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>
|
||||
)
|
||||
}
|
||||
89
web/src/components/portal/portal-ticket-list.tsx
Normal file
89
web/src/components/portal/portal-ticket-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
web/src/components/reports/backlog-report.tsx
Normal file
172
web/src/components/reports/backlog-report.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
web/src/components/reports/csat-report.tsx
Normal file
111
web/src/components/reports/csat-report.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
web/src/components/reports/sla-report.tsx
Normal file
124
web/src/components/reports/sla-report.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 P1–P3.</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>
|
||||
|
|
|
|||
280
web/src/components/settings/settings-content.tsx
Normal file
280
web/src/components/settings/settings-content.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue