Merge pull request #15 from esdrasrenan/feat/convex-tickets-core
feat: secure convex admin flows with real metrics
This commit is contained in:
commit
bb20342157
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.
|
- SLAs (BullMQ + Redis), notificacoes, ingest de e-mail, portal cliente, etc.
|
||||||
|
|
||||||
## Backlog imediato
|
## Backlog imediato
|
||||||
- [ ] Expor portal do cliente com listagem de tickets filtrada por `viewerId` (Convex + UI)
|
- [x] 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] 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
|
- [ ] 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
|
### Iniciativa atual — Autenticação real e personas
|
||||||
- [x] Migrar placeholder para Better Auth + Prisma (handlers Next, cliente React e sync Convex).
|
- [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] 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.
|
- [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.
|
- [x] 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] 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
|
## Proximas entregas sugeridas
|
||||||
1. Entregar portal do cliente (listagem, detalhes e criação de ticket) consumindo RBAC e mapeadores atualizados.
|
1. Finalizar onboarding/offboarding de agentes com convites Better Auth, sincronização Convex e trilhas de auditoria.
|
||||||
2. Evoluir painel administrativo com gerenciamento de filas/categorias e convites com Better Auth.
|
2. Evoluir painel administrativo com gestão de categorias avançadas e permissões granulares para agentes.
|
||||||
3. Introduzir relatórios e métricas (workSummary, SLA) protegidos por `requireStaff/requireAdmin`.
|
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.
|
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.
|
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.
|
- 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
|
### Próximos passos imediatos
|
||||||
- [ ] Consolidar o painel administrativo com fluxo completo de convites (criação, exibição e revogação) utilizando Better Auth.
|
- [ ] Implementar fluxo completo de convites (criação, expiração, revogação) integrado ao Better Auth e Convex.
|
||||||
- [ ] Iniciar o portal do cliente com listagem de tickets filtrada por `viewerId` e detalhamento básico.
|
- [ ] Adicionar testes Vitest/E2E cobrindo dashboards, relatórios e guardas de RBAC no front.
|
||||||
- [ ] Cobrir o fluxo de autenticação (login/convite/reset) com testes Vitest focados em regressões críticas.
|
- [ ] 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 bootstrap from "../bootstrap.js";
|
||||||
import type * as categories from "../categories.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 files from "../files.js";
|
||||||
import type * as queues from "../queues.js";
|
import type * as queues from "../queues.js";
|
||||||
import type * as rbac from "../rbac.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 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 tickets from "../tickets.js";
|
||||||
import type * as users from "../users.js";
|
import type * as users from "../users.js";
|
||||||
|
|
||||||
|
|
@ -34,10 +38,14 @@ import type {
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
bootstrap: typeof bootstrap;
|
bootstrap: typeof bootstrap;
|
||||||
categories: typeof categories;
|
categories: typeof categories;
|
||||||
|
fields: typeof fields;
|
||||||
files: typeof files;
|
files: typeof files;
|
||||||
queues: typeof queues;
|
queues: typeof queues;
|
||||||
rbac: typeof rbac;
|
rbac: typeof rbac;
|
||||||
|
reports: typeof reports;
|
||||||
seed: typeof seed;
|
seed: typeof seed;
|
||||||
|
slas: typeof slas;
|
||||||
|
teams: typeof teams;
|
||||||
tickets: typeof tickets;
|
tickets: typeof tickets;
|
||||||
users: typeof users;
|
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 { mutation, query } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
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> = {
|
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
||||||
"Suporte N1": "Chamados",
|
"Suporte N1": "Chamados",
|
||||||
|
|
@ -15,11 +19,75 @@ function renameQueueString(value: string) {
|
||||||
return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value;
|
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({
|
export const summary = query({
|
||||||
args: { tenantId: v.string() },
|
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||||
handler: async (ctx, { tenantId }) => {
|
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();
|
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(
|
const result = await Promise.all(
|
||||||
queues.map(async (qItem) => {
|
queues.map(async (qItem) => {
|
||||||
const pending = await ctx.db
|
const pending = await ctx.db
|
||||||
|
|
@ -28,7 +96,7 @@ export const summary = query({
|
||||||
.collect();
|
.collect();
|
||||||
const waiting = pending.filter((t) => t.status === "PENDING" || t.status === "ON_HOLD").length;
|
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 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 };
|
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())),
|
teams: v.optional(v.array(v.string())),
|
||||||
})
|
})
|
||||||
.index("by_tenant_email", ["tenantId", "email"])
|
.index("by_tenant_email", ["tenantId", "email"])
|
||||||
.index("by_tenant_role", ["tenantId", "role"]),
|
.index("by_tenant_role", ["tenantId", "role"])
|
||||||
|
.index("by_tenant", ["tenantId"]),
|
||||||
|
|
||||||
queues: defineTable({
|
queues: defineTable({
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
|
@ -129,4 +130,27 @@ export default defineSchema({
|
||||||
.index("by_category_order", ["categoryId", "order"])
|
.index("by_category_order", ["categoryId", "order"])
|
||||||
.index("by_category_slug", ["categoryId", "slug"])
|
.index("by_category_slug", ["categoryId", "slug"])
|
||||||
.index("by_tenant_slug", ["tenantId", "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 { ConvexError, v } from "convex/values";
|
||||||
import { Id, type Doc } from "./_generated/dataModel";
|
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> = {
|
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
||||||
"Suporte N1": "Chamados",
|
"Suporte N1": "Chamados",
|
||||||
|
|
@ -52,7 +52,7 @@ export const list = query({
|
||||||
if (!args.viewerId) {
|
if (!args.viewerId) {
|
||||||
return []
|
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
|
// Choose best index based on provided args for efficiency
|
||||||
let base: Doc<"tickets">[] = [];
|
let base: Doc<"tickets">[] = [];
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,45 @@ export const ensureUser = mutation({
|
||||||
.query("users")
|
.query("users")
|
||||||
.withIndex("by_tenant_email", (q) => q.eq("tenantId", args.tenantId).eq("email", args.email))
|
.withIndex("by_tenant_email", (q) => q.eq("tenantId", args.tenantId).eq("email", args.email))
|
||||||
.first();
|
.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", {
|
const id = await ctx.db.insert("users", {
|
||||||
tenantId: args.tenantId,
|
tenantId: args.tenantId,
|
||||||
email: args.email,
|
email: args.email,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
export default {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
"@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 password = process.env.SEED_USER_PASSWORD ?? "admin123"
|
||||||
const name = process.env.SEED_USER_NAME ?? "Administrador"
|
const name = process.env.SEED_USER_NAME ?? "Administrador"
|
||||||
const role = process.env.SEED_USER_ROLE ?? "admin"
|
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() {
|
async function main() {
|
||||||
const hashedPassword = await hashPassword(password)
|
const hashedPassword = await hashPassword(password)
|
||||||
|
|
@ -26,7 +26,7 @@ async function main() {
|
||||||
tenantId,
|
tenantId,
|
||||||
accounts: {
|
accounts: {
|
||||||
create: {
|
create: {
|
||||||
providerId: "email",
|
providerId: "credential",
|
||||||
accountId: email,
|
accountId: email,
|
||||||
password: hashedPassword,
|
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(`✅ Usuario seed criado/atualizado: ${user.email}`)
|
||||||
console.log(` ID: ${user.id}`)
|
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,
|
tenantId,
|
||||||
accounts: {
|
accounts: {
|
||||||
create: {
|
create: {
|
||||||
providerId: "email",
|
providerId: "credential",
|
||||||
accountId: emailInput,
|
accountId: emailInput,
|
||||||
password: hashedPassword,
|
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 type { Metadata } from "next"
|
||||||
import Link from "next/link"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Portal do cliente",
|
title: "Portal do cliente",
|
||||||
|
|
@ -9,26 +7,5 @@ export const metadata: Metadata = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PortalPage() {
|
export default function PortalPage() {
|
||||||
return (
|
redirect("/portal/tickets")
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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,
|
Timer,
|
||||||
Plug,
|
Plug,
|
||||||
Layers3,
|
Layers3,
|
||||||
|
Settings,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
|
|
||||||
|
|
@ -32,43 +33,69 @@ import {
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
|
||||||
|
import type { LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
const navigation = {
|
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"],
|
versions: ["0.0.1"],
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
{
|
||||||
title: "Operação",
|
title: "Operação",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Dashboard", url: "/dashboard", icon: LayoutDashboard },
|
{ title: "Dashboard", url: "/dashboard", icon: LayoutDashboard, requiredRole: "staff" },
|
||||||
{ title: "Tickets", url: "/tickets", icon: Ticket },
|
{ title: "Tickets", url: "/tickets", icon: Ticket, requiredRole: "staff" },
|
||||||
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft },
|
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" },
|
||||||
{ title: "Modo Play", url: "/play", icon: PlayCircle },
|
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
|
||||||
{ title: "Base de conhecimento", url: "/knowledge", icon: BookOpen },
|
{ title: "Base de conhecimento", url: "/knowledge", icon: BookOpen, requiredRole: "staff" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Relatorios",
|
title: "Relatórios",
|
||||||
items: [
|
requiredRole: "staff",
|
||||||
{ title: "SLA e produtividade", url: "/reports/sla", icon: Gauge },
|
items: [
|
||||||
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy },
|
{ title: "SLA e produtividade", url: "/reports/sla", icon: Gauge, requiredRole: "staff" },
|
||||||
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3 },
|
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
|
||||||
],
|
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
|
||||||
},
|
],
|
||||||
{
|
},
|
||||||
title: "Configuração",
|
{
|
||||||
items: [
|
title: "Administração",
|
||||||
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints },
|
requiredRole: "admin",
|
||||||
{ title: "Times & papéis", url: "/admin/teams", icon: Users },
|
items: [
|
||||||
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3 },
|
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
|
||||||
{ title: "SLAs", url: "/admin/slas", icon: Timer },
|
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" },
|
||||||
{ title: "Integrações", url: "/admin/integrations", icon: Plug },
|
{ 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" },
|
||||||
],
|
],
|
||||||
} as const
|
},
|
||||||
|
{
|
||||||
|
title: "Conta",
|
||||||
|
requiredRole: "staff",
|
||||||
|
items: [{ title: "Configurações", url: "/settings", icon: Settings, requiredRole: "staff" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const { isAdmin, isStaff, isCustomer } = useAuth()
|
||||||
|
|
||||||
function isActive(url: string) {
|
function isActive(url: string) {
|
||||||
if (!pathname) return false
|
if (!pathname) return false
|
||||||
|
|
@ -77,6 +104,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
}
|
}
|
||||||
return pathname === url || pathname.startsWith(`${url}/`)
|
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 (
|
return (
|
||||||
<Sidebar {...props}>
|
<Sidebar {...props}>
|
||||||
|
|
@ -89,25 +124,30 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
<SearchForm placeholder="Buscar tickets, macros ou artigos" />
|
<SearchForm placeholder="Buscar tickets, macros ou artigos" />
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{navigation.navMain.map((group) => (
|
{navigation.navMain.map((group) => {
|
||||||
<SidebarGroup key={group.title}>
|
if (!canAccess(group.requiredRole)) return null
|
||||||
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
|
const visibleItems = group.items.filter((item) => canAccess(item.requiredRole))
|
||||||
<SidebarGroupContent>
|
if (visibleItems.length === 0) return null
|
||||||
<SidebarMenu>
|
return (
|
||||||
{group.items.map((item) => (
|
<SidebarGroup key={group.title}>
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
|
||||||
<SidebarMenuButton asChild isActive={isActive(item.url)}>
|
<SidebarGroupContent>
|
||||||
<a href={item.url} className="gap-2">
|
<SidebarMenu>
|
||||||
<item.icon className="size-4" />
|
{visibleItems.map((item) => (
|
||||||
<span>{item.title}</span>
|
<SidebarMenuItem key={item.title}>
|
||||||
</a>
|
<SidebarMenuButton asChild isActive={isActive(item.url)}>
|
||||||
</SidebarMenuButton>
|
<a href={item.url} className="gap-2">
|
||||||
</SidebarMenuItem>
|
<item.icon className="size-4" />
|
||||||
))}
|
<span>{item.title}</span>
|
||||||
</SidebarMenu>
|
</a>
|
||||||
</SidebarGroupContent>
|
</SidebarMenuButton>
|
||||||
</SidebarGroup>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
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 { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -18,6 +24,7 @@ import {
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -32,85 +39,11 @@ import {
|
||||||
|
|
||||||
export const description = "Distribuição semanal de tickets por canal"
|
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() {
|
export function ChartAreaInteractive() {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const [timeRange, setTimeRange] = React.useState("90d")
|
const [timeRange, setTimeRange] = React.useState("90d")
|
||||||
|
const { session, convexUserId } = useAuth()
|
||||||
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
|
|
@ -118,29 +51,60 @@ export function ChartAreaInteractive() {
|
||||||
}
|
}
|
||||||
}, [isMobile])
|
}, [isMobile])
|
||||||
|
|
||||||
const filteredData = chartData.filter((item) => {
|
const report = useQuery(
|
||||||
const date = new Date(item.date)
|
api.reports.ticketsByChannel,
|
||||||
const referenceDate = new Date("2024-08-31")
|
convexUserId
|
||||||
let daysToSubtract = 90
|
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange })
|
||||||
if (timeRange === "30d") {
|
: "skip"
|
||||||
daysToSubtract = 30
|
)
|
||||||
} else if (timeRange === "7d") {
|
|
||||||
daysToSubtract = 7
|
const channels = React.useMemo(() => report?.channels ?? [], [report])
|
||||||
}
|
|
||||||
const startDate = new Date(referenceDate)
|
const palette = React.useMemo(
|
||||||
startDate.setDate(referenceDate.getDate() - daysToSubtract)
|
() => [
|
||||||
return date >= startDate
|
"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 (
|
return (
|
||||||
<Card className="@container/card">
|
<Card className="@container/card">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Entrada de tickets por canal</CardTitle>
|
<CardTitle>Entrada de tickets por canal</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
<span className="hidden @[540px]/card:block">
|
<span className="hidden @[540px]/card:block">
|
||||||
Comparativo entre e-mail e WhatsApp
|
Distribuição dos canais nos últimos {timeRange.replace("d", " dias")}
|
||||||
</span>
|
</span>
|
||||||
<span className="@[540px]/card:hidden">Últimos 90 dias</span>
|
<span className="@[540px]/card:hidden">Período: {timeRange}</span>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<CardAction>
|
<CardAction>
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
|
|
@ -177,86 +141,83 @@ export function ChartAreaInteractive() {
|
||||||
</CardAction>
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||||
<ChartContainer
|
{report === undefined ? (
|
||||||
config={chartConfig}
|
<div className="flex h-[250px] items-center justify-center">
|
||||||
className="aspect-auto h-[250px] w-full"
|
<Skeleton className="h-24 w-full" />
|
||||||
>
|
</div>
|
||||||
<AreaChart data={filteredData}>
|
) : chartData.length === 0 || channels.length === 0 ? (
|
||||||
<defs>
|
<div className="flex h-[250px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
|
||||||
<linearGradient id="fillEmail" x1="0" y1="0" x2="0" y2="1">
|
Sem dados suficientes no período selecionado.
|
||||||
<stop
|
</div>
|
||||||
offset="5%"
|
) : (
|
||||||
stopColor="var(--chart-1)"
|
<ChartContainer
|
||||||
stopOpacity={0.85}
|
config={chartConfig}
|
||||||
/>
|
className="aspect-auto h-[250px] w-full"
|
||||||
<stop
|
>
|
||||||
offset="95%"
|
<AreaChart data={chartData}>
|
||||||
stopColor="var(--chart-1)"
|
<defs>
|
||||||
stopOpacity={0.1}
|
{channels.map((channel) => (
|
||||||
/>
|
<linearGradient key={channel} id={`fill-${channel}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
</linearGradient>
|
<stop
|
||||||
<linearGradient id="fillWhatsapp" x1="0" y1="0" x2="0" y2="1">
|
offset="5%"
|
||||||
<stop
|
stopColor={chartConfig[channel]?.color ?? "var(--chart-1)"}
|
||||||
offset="5%"
|
stopOpacity={0.85}
|
||||||
stopColor="var(--chart-2)"
|
/>
|
||||||
stopOpacity={0.85}
|
<stop
|
||||||
/>
|
offset="95%"
|
||||||
<stop
|
stopColor={chartConfig[channel]?.color ?? "var(--chart-1)"}
|
||||||
offset="95%"
|
stopOpacity={0.1}
|
||||||
stopColor="var(--chart-2)"
|
/>
|
||||||
stopOpacity={0.1}
|
</linearGradient>
|
||||||
/>
|
))}
|
||||||
</linearGradient>
|
</defs>
|
||||||
</defs>
|
<CartesianGrid vertical={false} />
|
||||||
<CartesianGrid vertical={false} />
|
<XAxis
|
||||||
<XAxis
|
dataKey="date"
|
||||||
dataKey="date"
|
tickLine={false}
|
||||||
tickLine={false}
|
axisLine={false}
|
||||||
axisLine={false}
|
tickMargin={8}
|
||||||
tickMargin={8}
|
minTickGap={32}
|
||||||
minTickGap={32}
|
tickFormatter={(value) => {
|
||||||
tickFormatter={(value) => {
|
const date = new Date(value)
|
||||||
const date = new Date(value)
|
return date.toLocaleDateString("pt-BR", {
|
||||||
return date.toLocaleDateString("pt-BR", {
|
month: "short",
|
||||||
month: "short",
|
day: "2-digit",
|
||||||
day: "2-digit",
|
})
|
||||||
})
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<ChartTooltip
|
||||||
<ChartTooltip
|
cursor={false}
|
||||||
cursor={false}
|
content={
|
||||||
content={
|
<ChartTooltipContent
|
||||||
<ChartTooltipContent
|
labelFormatter={(value) =>
|
||||||
labelFormatter={(value) =>
|
new Date(value).toLocaleDateString("pt-BR", {
|
||||||
new Date(value).toLocaleDateString("pt-BR", {
|
day: "2-digit",
|
||||||
day: "2-digit",
|
month: "long",
|
||||||
month: "long",
|
})
|
||||||
})
|
}
|
||||||
}
|
indicator="dot"
|
||||||
indicator="dot"
|
/>
|
||||||
/>
|
}
|
||||||
}
|
/>
|
||||||
/>
|
{channels
|
||||||
<Area
|
.slice()
|
||||||
dataKey="whatsapp"
|
.reverse()
|
||||||
type="natural"
|
.map((channel) => (
|
||||||
fill="url(#fillWhatsapp)"
|
<Area
|
||||||
stroke="var(--chart-2)"
|
key={channel}
|
||||||
strokeWidth={2}
|
dataKey={channel}
|
||||||
stackId="a"
|
type="natural"
|
||||||
name={chartConfig.whatsapp.label}
|
fill={`url(#fill-${channel})`}
|
||||||
/>
|
stroke={chartConfig[channel]?.color ?? "var(--chart-1)"}
|
||||||
<Area
|
strokeWidth={2}
|
||||||
dataKey="email"
|
stackId="a"
|
||||||
type="natural"
|
name={chartConfig[channel]?.label ?? channel}
|
||||||
fill="url(#fillEmail)"
|
/>
|
||||||
stroke="var(--chart-1)"
|
))}
|
||||||
strokeWidth={2}
|
</AreaChart>
|
||||||
stackId="a"
|
</ChartContainer>
|
||||||
name={chartConfig.email.label}
|
)}
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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,84 +1,171 @@
|
||||||
import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
|
"use client"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { useMemo } from "react"
|
||||||
import {
|
import { useQuery } from "convex/react"
|
||||||
Card,
|
import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
|
||||||
CardAction,
|
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
||||||
CardDescription,
|
import { api } from "@/convex/_generated/api"
|
||||||
CardFooter,
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
CardHeader,
|
import { useAuth } from "@/lib/auth-client"
|
||||||
CardTitle,
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
} from "@/components/ui/card"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
export function SectionCards() {
|
Card,
|
||||||
return (
|
CardAction,
|
||||||
<div className="grid grid-cols-1 gap-4 px-4 sm:grid-cols-2 xl:grid-cols-4 xl:px-8">
|
CardDescription,
|
||||||
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
|
CardFooter,
|
||||||
<CardHeader className="gap-3">
|
CardHeader,
|
||||||
<CardDescription>Tickets novos</CardDescription>
|
CardTitle,
|
||||||
<CardTitle className="text-3xl font-semibold tabular-nums">128</CardTitle>
|
} from "@/components/ui/card"
|
||||||
<CardAction>
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
|
|
||||||
<IconTrendingUp className="size-3.5" />
|
function formatMinutes(value: number | null) {
|
||||||
+8%
|
if (value === null) return "—"
|
||||||
</Badge>
|
if (value < 60) return `${Math.round(value)} min`
|
||||||
</CardAction>
|
const hours = Math.floor(value / 60)
|
||||||
</CardHeader>
|
const minutes = Math.round(value % 60)
|
||||||
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
|
if (minutes === 0) return `${hours}h`
|
||||||
<div className="flex gap-2 text-foreground">
|
return `${hours}h ${minutes}min`
|
||||||
Volume acima da média semanal <IconTrendingUp className="size-4" />
|
}
|
||||||
</div>
|
|
||||||
<span>Últimas 24h considerando e-mail e WhatsApp.</span>
|
function formatScore(value: number | null) {
|
||||||
</CardFooter>
|
if (value === null) return "—"
|
||||||
</Card>
|
return value.toFixed(2)
|
||||||
<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>
|
export function SectionCards() {
|
||||||
<CardTitle className="text-3xl font-semibold tabular-nums">12m</CardTitle>
|
const { session, convexUserId } = useAuth()
|
||||||
<CardAction>
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
|
|
||||||
<IconTrendingDown className="size-3.5" />
|
const dashboard = useQuery(
|
||||||
-3m
|
api.reports.dashboardOverview,
|
||||||
</Badge>
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
</CardAction>
|
)
|
||||||
</CardHeader>
|
|
||||||
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
|
const trendInfo = useMemo(() => {
|
||||||
<span className="text-foreground">SLAs cumpridos em 92% dos tickets</span>
|
if (!dashboard?.newTickets) return { value: null, label: "Aguardando dados", icon: IconTrendingUp }
|
||||||
<span>Considera filas Prioridade P1–P3.</span>
|
const trend = dashboard.newTickets.trendPercentage
|
||||||
</CardFooter>
|
if (trend === null) {
|
||||||
</Card>
|
return { value: null, label: "Sem histórico", icon: IconTrendingUp }
|
||||||
<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">
|
const positive = trend >= 0
|
||||||
<CardDescription>Tickets aguardando ação</CardDescription>
|
const icon = positive ? IconTrendingUp : IconTrendingDown
|
||||||
<CardTitle className="text-3xl font-semibold tabular-nums">45</CardTitle>
|
const label = `${positive ? "+" : ""}${trend.toFixed(1)}%`
|
||||||
<CardAction>
|
return { value: trend, label, icon }
|
||||||
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
|
}, [dashboard])
|
||||||
<IconClockHour4 className="size-3.5" />
|
|
||||||
12 em risco
|
const responseDelta = useMemo(() => {
|
||||||
</Badge>
|
if (!dashboard?.firstResponse) return { delta: null, label: "Sem dados", positive: false }
|
||||||
</CardAction>
|
const delta = dashboard.firstResponse.deltaMinutes
|
||||||
</CardHeader>
|
if (delta === null) return { delta: null, label: "Sem comparação", positive: false }
|
||||||
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
|
const positive = delta <= 0
|
||||||
<span className="text-foreground">Distribuir entre times prioritários</span>
|
const value = `${delta > 0 ? "+" : ""}${Math.round(delta)} min`
|
||||||
<span>Inclui status "Aberto", "Pendente" e "Em espera".</span>
|
return { delta, label: value, positive }
|
||||||
</CardFooter>
|
}, [dashboard])
|
||||||
</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">
|
const TrendIcon = trendInfo.icon
|
||||||
<CardHeader className="gap-3">
|
|
||||||
<CardDescription>CSAT das últimas 100 interações</CardDescription>
|
return (
|
||||||
<CardTitle className="text-3xl font-semibold tabular-nums">4,7</CardTitle>
|
<div className="grid grid-cols-1 gap-4 px-4 sm:grid-cols-2 xl:grid-cols-4 xl:px-8">
|
||||||
<CardAction>
|
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
|
||||||
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
|
<CardHeader className="gap-3">
|
||||||
<IconMessages className="size-3.5" />
|
<CardDescription>Tickets novos</CardDescription>
|
||||||
63 pesquisas
|
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||||
</Badge>
|
{dashboard ? dashboard.newTickets.last24h : <Skeleton className="h-8 w-20" />}
|
||||||
</CardAction>
|
</CardTitle>
|
||||||
</CardHeader>
|
<CardAction>
|
||||||
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
|
<Badge
|
||||||
<span className="text-foreground">Destaque: fila Field Services</span>
|
variant="outline"
|
||||||
<span>CSAT com escala de 1 a 5.</span>
|
className={`rounded-full gap-1 px-2 py-1 text-xs ${
|
||||||
</CardFooter>
|
trendInfo.value !== null && trendInfo.value < 0 ? "text-red-500" : ""
|
||||||
</Card>
|
}`}
|
||||||
</div>
|
>
|
||||||
)
|
<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">
|
||||||
|
{trendInfo.value === null
|
||||||
|
? "Aguardando histórico"
|
||||||
|
: trendInfo.value >= 0
|
||||||
|
? "Volume acima do período anterior"
|
||||||
|
: "Volume abaixo do período anterior"}
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
{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 ${
|
||||||
|
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">
|
||||||
|
{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">
|
||||||
|
{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" />
|
||||||
|
{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">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 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" />
|
||||||
|
{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">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() {
|
export function TicketsView() {
|
||||||
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
|
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(
|
const ticketsRaw = useQuery(
|
||||||
api.tickets.list,
|
api.tickets.list,
|
||||||
convexUserId
|
convexUserId
|
||||||
? {
|
? {
|
||||||
tenantId: DEFAULT_TENANT_ID,
|
tenantId,
|
||||||
viewerId: convexUserId as Id<"users">,
|
viewerId: convexUserId as Id<"users">,
|
||||||
status: filters.status ?? undefined,
|
status: filters.status ?? undefined,
|
||||||
priority: filters.priority ?? undefined,
|
priority: filters.priority ?? undefined,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue