diff --git a/Captura de tela 2025-10-07 133523.png b/Captura de tela 2025-10-07 133523.png new file mode 100644 index 0000000..813dbe5 Binary files /dev/null and b/Captura de tela 2025-10-07 133523.png differ diff --git a/PROXIMOS_PASSOS.md b/PROXIMOS_PASSOS.md index 54bc7c5..a7f1653 100644 --- a/PROXIMOS_PASSOS.md +++ b/PROXIMOS_PASSOS.md @@ -7,8 +7,8 @@ Lista priorizada de evoluções propostas para o Sistema de Chamados. Confira `a - [x] Criar perfil **Gestor da Empresa (cliente)** com permissões específicas - [x] Ver todos os chamados da sua empresa - [x] Acessar relatórios e dashboards resumidos - - [ ] Exportar relatórios em PDF ou CSV -- [x] Manter perfis: Administrador, Técnico, Gestor da Empresa, Usuário Final + - [x] Exportar relatórios em PDF ou CSV +- [x] Manter perfis: Administrador, Gestor, Agente e Colaborador --- @@ -34,19 +34,19 @@ Lista priorizada de evoluções propostas para o Sistema de Chamados. Confira `a - [ ] Criar **relatório de horas por cliente (CSV/Dashboard)** - [ ] Separar por atendimento interno e externo - [ ] Filtrar por período (dia, semana, mês) -- [ ] Permitir exportar relatórios completos (CSV ou PDF) +- [x] Permitir exportar relatórios completos (CSV ou PDF) --- # ⏱️ Controle de tempo e contratos -- [ ] Adicionar botão **Play interno** (atendimento remoto) -- [ ] Adicionar botão **Play externo** (atendimento presencial) -- [ ] Separar contagem de horas por tipo (interno/externo) +- [x] Adicionar botão **Play interno** (atendimento remoto) +- [x] Adicionar botão **Play externo** (atendimento presencial) +- [x] Separar contagem de horas por tipo (interno/externo) - [ ] Exibir e somar **horas gastas por cliente** (com base no tipo) - [ ] Incluir no cadastro: - [ ] Horas contratadas por mês - - [ ] Tipo de cliente: mensalista ou avulso + - [x] Tipo de cliente: mensalista ou avulso - [ ] Enviar alerta automático por e-mail quando atingir limite de horas --- diff --git a/convex/migrations.ts b/convex/migrations.ts index cb7274a..5a27e1e 100644 --- a/convex/migrations.ts +++ b/convex/migrations.ts @@ -6,7 +6,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server" const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret" -const VALID_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR", "CUSTOMER"]) +const VALID_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]) const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]) function normalizeEmail(value: string) { @@ -30,6 +30,7 @@ type ImportedQueue = { type ImportedCompany = { slug: string name: string + isAvulso?: boolean | null cnpj?: string | null domain?: string | null phone?: string | null @@ -43,6 +44,8 @@ function normalizeRole(role: string | null | undefined) { if (!role) return "AGENT" const normalized = role.toUpperCase() if (VALID_ROLES.has(normalized)) return normalized + // map legacy CUSTOMER to MANAGER + if (normalized === "CUSTOMER") return "MANAGER" return "AGENT" } @@ -182,6 +185,7 @@ async function ensureCompany( tenantId, name: data.name, slug, + isAvulso: data.isAvulso ?? undefined, cnpj: data.cnpj ?? undefined, domain: data.domain ?? undefined, phone: data.phone ?? undefined, @@ -195,6 +199,7 @@ async function ensureCompany( if (existing) { const needsPatch = existing.name !== payload.name || + (existing as any).isAvulso !== (payload.isAvulso ?? (existing as any).isAvulso) || existing.cnpj !== (payload.cnpj ?? undefined) || existing.domain !== (payload.domain ?? undefined) || existing.phone !== (payload.phone ?? undefined) || @@ -203,6 +208,7 @@ async function ensureCompany( if (needsPatch) { await ctx.db.patch(existing._id, { name: payload.name, + isAvulso: payload.isAvulso, cnpj: payload.cnpj, domain: payload.domain, phone: payload.phone, @@ -344,6 +350,7 @@ export const exportTenantSnapshot = query({ companies: companies.map((company) => ({ slug: company.slug, name: company.name, + isAvulso: (company as any).isAvulso ?? false, cnpj: company.cnpj ?? null, domain: company.domain ?? null, phone: company.phone ?? null, diff --git a/convex/queues.ts b/convex/queues.ts index 9b6c2f9..4705d3c 100644 --- a/convex/queues.ts +++ b/convex/queues.ts @@ -5,7 +5,7 @@ import type { Id } from "./_generated/dataModel"; import { requireAdmin, requireStaff } from "./rbac"; -type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED"; +type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; const STATUS_NORMALIZE_MAP: Record = { NEW: "PENDING", @@ -15,7 +15,7 @@ const STATUS_NORMALIZE_MAP: Record = { ON_HOLD: "PAUSED", PAUSED: "PAUSED", RESOLVED: "RESOLVED", - CLOSED: "CLOSED", + CLOSED: "RESOLVED", }; function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { @@ -123,7 +123,7 @@ export const summary = query({ }).length; const open = pending.filter((t) => { const status = normalizeStatus(t.status); - return status !== "RESOLVED" && status !== "CLOSED"; + return status !== "RESOLVED"; }).length; const breached = 0; return { id: qItem._id, name: renameQueueString(qItem.name), pending: open, waiting, breached }; diff --git a/convex/rbac.ts b/convex/rbac.ts index f729a6f..89fb981 100644 --- a/convex/rbac.ts +++ b/convex/rbac.ts @@ -4,7 +4,6 @@ import type { Id } from "./_generated/dataModel" import type { MutationCtx, QueryCtx } from "./_generated/server" const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]) -const CUSTOMER_ROLE = "CUSTOMER" const MANAGER_ROLE = "MANAGER" type Ctx = QueryCtx | MutationCtx @@ -45,13 +44,7 @@ export async function requireAdmin(ctx: Ctx, userId: Id<"users">, tenantId?: str return result } -export async function requireCustomer(ctx: Ctx, userId: Id<"users">, tenantId?: string) { - const result = await requireUser(ctx, userId, tenantId) - if (result.role !== CUSTOMER_ROLE) { - throw new ConvexError("Acesso restrito ao portal do cliente") - } - return result -} +// removed customer role; use requireCompanyManager or requireStaff as appropriate export async function requireCompanyManager(ctx: Ctx, userId: Id<"users">, tenantId?: string) { const result = await requireUser(ctx, userId, tenantId) diff --git a/convex/reports.ts b/convex/reports.ts index 4d25bd9..924830e 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -5,7 +5,7 @@ import type { Doc, Id } from "./_generated/dataModel"; import { requireStaff } from "./rbac"; -type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED"; +type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; const STATUS_NORMALIZE_MAP: Record = { NEW: "PENDING", @@ -15,7 +15,7 @@ const STATUS_NORMALIZE_MAP: Record = { ON_HOLD: "PAUSED", PAUSED: "PAUSED", RESOLVED: "RESOLVED", - CLOSED: "CLOSED", + CLOSED: "RESOLVED", }; function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { @@ -128,7 +128,7 @@ function formatDateKey(timestamp: number) { } export const slaOverview = query({ - args: { tenantId: v.string(), viewerId: v.id("users") }, + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) }, handler: async (ctx, { tenantId, viewerId }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer); @@ -138,7 +138,7 @@ export const slaOverview = query({ const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); const resolvedTickets = tickets.filter((ticket) => { const status = normalizeStatus(ticket.status); - return status === "RESOLVED" || status === "CLOSED"; + return status === "RESOLVED"; }); const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); @@ -179,11 +179,17 @@ export const slaOverview = query({ }); export const csatOverview = query({ - args: { tenantId: v.string(), viewerId: v.id("users") }, - handler: async (ctx, { tenantId, viewerId }) => { + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) }, + handler: async (ctx, { tenantId, viewerId, range }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer); - const surveys = await collectCsatSurveys(ctx, tickets); + const surveysAll = await collectCsatSurveys(ctx, tickets); + 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 surveys = surveysAll.filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs); const averageScore = average(surveys.map((item) => item.score)); const distribution = [1, 2, 3, 4, 5].map((score) => ({ @@ -205,28 +211,37 @@ export const csatOverview = query({ score: item.score, receivedAt: item.receivedAt, })), + rangeDays: days, }; }, }); export const backlogOverview = query({ - args: { tenantId: v.string(), viewerId: v.id("users") }, - handler: async (ctx, { tenantId, viewerId }) => { + args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) }, + handler: async (ctx, { tenantId, viewerId, range }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer); - const statusCounts = tickets.reduce>((acc, ticket) => { + // Optional range filter (createdAt) for reporting purposes + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; + const end = new Date(); + end.setUTCHours(0, 0, 0, 0); + const endMs = end.getTime() + ONE_DAY_MS; + const startMs = endMs - days * ONE_DAY_MS; + const inRange = tickets.filter((t) => t.createdAt >= startMs && t.createdAt < endMs); + + const statusCounts = inRange.reduce>((acc, ticket) => { const status = normalizeStatus(ticket.status); acc[status] = (acc[status] ?? 0) + 1; return acc; }, {} as Record); - const priorityCounts = tickets.reduce>((acc, ticket) => { + const priorityCounts = inRange.reduce>((acc, ticket) => { acc[ticket.priority] = (acc[ticket.priority] ?? 0) + 1; return acc; }, {}); - const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); + const openTickets = inRange.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); const queueMap = new Map(); for (const ticket of openTickets) { @@ -245,6 +260,7 @@ export const backlogOverview = query({ } return { + rangeDays: days, statusCounts, priorityCounts, queueCounts: Array.from(queueMap.entries()).map(([id, data]) => ({ diff --git a/convex/schema.ts b/convex/schema.ts index f7b9b43..03adc56 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -20,6 +20,7 @@ export default defineSchema({ tenantId: v.string(), name: v.string(), slug: v.string(), + isAvulso: v.optional(v.boolean()), cnpj: v.optional(v.string()), domain: v.optional(v.string()), phone: v.optional(v.string()), @@ -91,6 +92,8 @@ export default defineSchema({ ) ), totalWorkedMs: v.optional(v.number()), + internalWorkedMs: v.optional(v.number()), + externalWorkedMs: v.optional(v.number()), activeSessionId: v.optional(v.id("ticketWorkSessions")), }) .index("by_tenant_status", ["tenantId", "status"]) @@ -141,6 +144,7 @@ export default defineSchema({ ticketWorkSessions: defineTable({ ticketId: v.id("tickets"), agentId: v.id("users"), + workType: v.optional(v.string()), // INTERNAL | EXTERNAL startedAt: v.number(), stoppedAt: v.optional(v.number()), durationMs: v.optional(v.number()), diff --git a/convex/seed.ts b/convex/seed.ts index f29b202..9ae1b39 100644 --- a/convex/seed.ts +++ b/convex/seed.ts @@ -71,7 +71,7 @@ export const seedDemo = mutation({ function defaultAvatar(name: string, email: string, role: string) { const normalizedRole = role.toUpperCase(); - if (normalizedRole === "CUSTOMER" || normalizedRole === "MANAGER") { + if (normalizedRole === "MANAGER") { return `https://i.pravatar.cc/150?u=${encodeURIComponent(email)}`; } const first = name.split(" ")[0] ?? email; @@ -130,7 +130,7 @@ export const seedDemo = mutation({ avatarUrl?: string; }): Promise> { const normalizedEmail = params.email.trim().toLowerCase(); - const normalizedRole = (params.role ?? "CUSTOMER").toUpperCase(); + const normalizedRole = (params.role ?? "MANAGER").toUpperCase(); const desiredAvatar = params.avatarUrl ?? defaultAvatar(params.name, normalizedEmail, normalizedRole); const existing = await ctx.db .query("users") @@ -139,7 +139,7 @@ export const seedDemo = mutation({ if (existing) { const updates: Record = {}; if (existing.name !== params.name) updates.name = params.name; - if ((existing.role ?? "CUSTOMER") !== normalizedRole) updates.role = normalizedRole; + if ((existing.role ?? "MANAGER") !== normalizedRole) updates.role = normalizedRole; if ((existing.avatarUrl ?? undefined) !== desiredAvatar) updates.avatarUrl = desiredAvatar; if ((existing.companyId ?? undefined) !== (params.companyId ?? undefined)) updates.companyId = params.companyId ?? undefined; if (Object.keys(updates).length > 0) { @@ -217,13 +217,13 @@ export const seedDemo = mutation({ const joaoAtlasId = await ensureUser({ name: "João Pedro Ramos", email: "joao.ramos@atlasengenharia.com.br", - role: "CUSTOMER", + role: "MANAGER", companyId: atlasCompanyId, }); await ensureUser({ name: "Aline Rezende", email: "aline.rezende@atlasengenharia.com.br", - role: "CUSTOMER", + role: "MANAGER", companyId: atlasCompanyId, }); @@ -237,19 +237,19 @@ export const seedDemo = mutation({ const ricardoOmniId = await ensureUser({ name: "Ricardo Matos", email: "ricardo.matos@omnisaude.com.br", - role: "CUSTOMER", + role: "MANAGER", companyId: omniCompanyId, }); await ensureUser({ name: "Luciana Prado", email: "luciana.prado@omnisaude.com.br", - role: "CUSTOMER", + role: "MANAGER", companyId: omniCompanyId, }); const clienteDemoId = await ensureUser({ name: "Cliente Demo", email: "cliente.demo@sistema.dev", - role: "CUSTOMER", + role: "MANAGER", companyId: omniCompanyId, }); diff --git a/convex/tickets.ts b/convex/tickets.ts index 12e64b1..42ae8f8 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -3,7 +3,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import { Id, type Doc } from "./_generated/dataModel"; -import { requireCustomer, requireStaff, requireUser } from "./rbac"; +import { requireStaff, requireUser } from "./rbac"; const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]); const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]); @@ -13,14 +13,13 @@ const PAUSE_REASON_LABELS: Record = { IN_PROCEDURE: "Em procedimento", }; -type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED"; +type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; const STATUS_LABELS: Record = { PENDING: "Pendente", AWAITING_ATTENDANCE: "Aguardando atendimento", PAUSED: "Pausado", RESOLVED: "Resolvido", - CLOSED: "Fechado", }; const LEGACY_STATUS_MAP: Record = { @@ -31,7 +30,7 @@ const LEGACY_STATUS_MAP: Record = { ON_HOLD: "PAUSED", PAUSED: "PAUSED", RESOLVED: "RESOLVED", - CLOSED: "CLOSED", + CLOSED: "RESOLVED", }; function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { @@ -277,9 +276,7 @@ export const list = query({ } let filtered = base; - if (role === "CUSTOMER") { - filtered = filtered.filter((t) => t.requesterId === args.viewerId); - } else if (role === "MANAGER") { + if (role === "MANAGER") { if (!user.companyId) { throw new ConvexError("Gestor não possui empresa vinculada") } @@ -310,6 +307,7 @@ export const list = query({ const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null; const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null; const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null; + const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null; const queueName = normalizeQueueName(queue); const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null; let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null; @@ -342,6 +340,7 @@ export const list = query({ priority: t.priority, channel: t.channel, queue: queueName, + company: company ? { id: company._id, name: company.name, isAvulso: (company as any).isAvulso ?? false } : null, requester: requester && { id: requester._id, name: requester.name, @@ -371,11 +370,14 @@ export const list = query({ subcategory: subcategorySummary, workSummary: { totalWorkedMs: t.totalWorkedMs ?? 0, + internalWorkedMs: (t as any).internalWorkedMs ?? 0, + externalWorkedMs: (t as any).externalWorkedMs ?? 0, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, + workType: (activeSession as any).workType ?? "INTERNAL", } : null, }, @@ -393,9 +395,7 @@ export const getById = query({ const { user, role } = await requireUser(ctx, viewerId, tenantId) const t = await ctx.db.get(id); if (!t || t.tenantId !== tenantId) return null; - if (role === "CUSTOMER" && t.requesterId !== viewerId) { - throw new ConvexError("Acesso restrito ao solicitante") - } + // no customer role; managers are constrained to company via ensureManagerTicketAccess let requester: Doc<"users"> | null = null if (role === "MANAGER") { requester = (await ensureManagerTicketAccess(ctx, user, t)) ?? null @@ -405,6 +405,7 @@ export const getById = query({ } const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null; const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null; + const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null; const queueName = normalizeQueueName(queue); const category = t.categoryId ? await ctx.db.get(t.categoryId) : null; const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null; @@ -463,6 +464,7 @@ export const getById = query({ priority: t.priority, channel: t.channel, queue: queueName, + company: company ? { id: company._id, name: company.name, isAvulso: (company as any).isAvulso ?? false } : null, requester: requester && { id: requester._id, name: requester.name, @@ -503,11 +505,14 @@ export const getById = query({ : null, workSummary: { totalWorkedMs: t.totalWorkedMs ?? 0, + internalWorkedMs: (t as any).internalWorkedMs ?? 0, + externalWorkedMs: (t as any).externalWorkedMs ?? 0, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, + workType: (activeSession as any).workType ?? "INTERNAL", } : null, }, @@ -557,9 +562,7 @@ export const create = mutation({ }, handler: async (ctx, args) => { const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId) - if (role === "CUSTOMER" && args.requesterId !== args.actorId) { - throw new ConvexError("Clientes só podem abrir chamados para si mesmos") - } + // no customer role; managers validated below if (args.assigneeId && (!role || !INTERNAL_STAFF_ROLES.has(role))) { throw new ConvexError("Somente a equipe interna pode definir o responsável") @@ -706,12 +709,9 @@ export const addComment = mutation({ } if (ticketDoc.requesterId === args.authorId) { - if (normalizedRole === "CUSTOMER") { - await requireCustomer(ctx, args.authorId, ticketDoc.tenantId) - if (args.visibility !== "PUBLIC") { - throw new ConvexError("Clientes só podem registrar comentários públicos") - } - } else if (STAFF_ROLES.has(normalizedRole)) { + // requester commenting: managers restricted to PUBLIC (handled above); + // internal staff require staff permission + if (STAFF_ROLES.has(normalizedRole)) { await requireTicketStaff(ctx, args.authorId, ticketDoc) } else { throw new ConvexError("Autor não possui permissão para comentar") @@ -768,9 +768,7 @@ export const updateComment = mutation({ } const normalizedRole = (actor.role ?? "AGENT").toUpperCase() if (ticketDoc.requesterId === actorId) { - if (normalizedRole === "CUSTOMER") { - await requireCustomer(ctx, actorId, ticketDoc.tenantId) - } else if (STAFF_ROLES.has(normalizedRole)) { + if (STAFF_ROLES.has(normalizedRole)) { await requireTicketStaff(ctx, actorId, ticketDoc) } else { throw new ConvexError("Autor não possui permissão para editar") @@ -828,9 +826,7 @@ export const removeCommentAttachment = mutation({ const normalizedRole = (actor.role ?? "AGENT").toUpperCase() if (ticketDoc.requesterId === actorId) { - if (normalizedRole === "CUSTOMER") { - await requireCustomer(ctx, actorId, ticketDoc.tenantId) - } else if (STAFF_ROLES.has(normalizedRole)) { + if (STAFF_ROLES.has(normalizedRole)) { await requireTicketStaff(ctx, actorId, ticketDoc) } else { throw new ConvexError("Autor não possui permissão para alterar anexos") @@ -1051,11 +1047,14 @@ export const workSummary = query({ return { ticketId, totalWorkedMs: ticket.totalWorkedMs ?? 0, + internalWorkedMs: (ticket as any).internalWorkedMs ?? 0, + externalWorkedMs: (ticket as any).externalWorkedMs ?? 0, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, + workType: (activeSession as any).workType ?? "INTERNAL", } : null, } @@ -1083,8 +1082,8 @@ export const updatePriority = mutation({ }); export const startWork = mutation({ - args: { ticketId: v.id("tickets"), actorId: v.id("users") }, - handler: async (ctx, { ticketId, actorId }) => { + args: { ticketId: v.id("tickets"), actorId: v.id("users"), workType: v.optional(v.string()) }, + handler: async (ctx, { ticketId, actorId, workType }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") @@ -1098,6 +1097,7 @@ export const startWork = mutation({ const sessionId = await ctx.db.insert("ticketWorkSessions", { ticketId, agentId: actorId, + workType: (workType ?? "INTERNAL").toUpperCase(), startedAt: now, }) @@ -1111,7 +1111,7 @@ export const startWork = mutation({ await ctx.db.insert("ticketEvents", { ticketId, type: "WORK_STARTED", - payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, sessionId }, + payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, sessionId, workType: (workType ?? "INTERNAL").toUpperCase() }, createdAt: now, }) @@ -1156,10 +1156,16 @@ export const pauseWork = mutation({ pauseNote: note ?? "", }) + const sessionType = ((session as any).workType ?? "INTERNAL").toUpperCase() + const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0 + const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0 + await ctx.db.patch(ticketId, { working: false, activeSessionId: undefined, totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs, + internalWorkedMs: ((ticket as any).internalWorkedMs ?? 0) + deltaInternal, + externalWorkedMs: ((ticket as any).externalWorkedMs ?? 0) + deltaExternal, updatedAt: now, }) @@ -1173,6 +1179,7 @@ export const pauseWork = mutation({ actorAvatar: actor?.avatarUrl, sessionId: session._id, sessionDurationMs: durationMs, + workType: sessionType, pauseReason: reason, pauseReasonLabel: PAUSE_REASON_LABELS[reason], pauseNote: note ?? "", @@ -1256,7 +1263,7 @@ export const playNext = mutation({ } candidates = candidates.filter( - (t) => t.status !== "RESOLVED" && t.status !== "CLOSED" && !t.assigneeId + (t) => t.status !== "RESOLVED" && !t.assigneeId ); if (candidates.length === 0) return null; diff --git a/convex/users.ts b/convex/users.ts index 7dadbd8..59eb77c 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -110,3 +110,17 @@ export const deleteUser = mutation({ }, }); +export const assignCompany = mutation({ + args: { tenantId: v.string(), email: v.string(), companyId: v.id("companies"), actorId: v.id("users") }, + handler: async (ctx, { tenantId, email, companyId, actorId }) => { + await requireAdmin(ctx, actorId, tenantId) + const user = await ctx.db + .query("users") + .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email)) + .first() + if (!user) throw new ConvexError("Usuário não encontrado no Convex") + await ctx.db.patch(user._id, { companyId }) + const updated = await ctx.db.get(user._id) + return updated + }, +}) diff --git a/middleware.ts b/middleware.ts index f6e3708..0d6de97 100644 --- a/middleware.ts +++ b/middleware.ts @@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from "next/server" import { getCookieCache } from "better-auth/cookies" const PUBLIC_PATHS = [/^\/login$/, /^\/api\/auth/, /^\/_next\//, /^\/favicon/] -const CUSTOMER_ALLOWED_PATHS = [/^\/portal(?:$|\/)/, /^\/api\/auth/, /^\/_next\//, /^\/favicon/] const ADMIN_ONLY_PATHS = [/^\/admin(?:$|\/)/] const PORTAL_HOME = "/portal" const APP_HOME = "/dashboard" @@ -24,21 +23,9 @@ export async function middleware(request: NextRequest) { const role = (session.user as { role?: string })?.role?.toLowerCase() ?? "agent" - if (role === "customer") { - const canAccess = CUSTOMER_ALLOWED_PATHS.some((pattern) => pattern.test(pathname)) - if (!canAccess) { - const redirectUrl = new URL(PORTAL_HOME, request.url) - redirectUrl.searchParams.set("callbackUrl", pathname + search) - return NextResponse.redirect(redirectUrl) - } - } else { - if (pathname.startsWith(PORTAL_HOME)) { - return NextResponse.redirect(new URL(APP_HOME, request.url)) - } - const isAdmin = role === "admin" - if (!isAdmin && ADMIN_ONLY_PATHS.some((pattern) => pattern.test(pathname))) { - return NextResponse.redirect(new URL(APP_HOME, request.url)) - } + const isAdmin = role === "admin" + if (!isAdmin && ADMIN_ONLY_PATHS.some((pattern) => pattern.test(pathname))) { + return NextResponse.redirect(new URL(APP_HOME, request.url)) } return NextResponse.next() @@ -48,4 +35,3 @@ export const config = { runtime: "nodejs", matcher: ["/(.*)"], } - diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d3210e3..e129fb0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,20 +7,18 @@ datasource db { url = env("DATABASE_URL") } -enum UserRole { - ADMIN - MANAGER - AGENT - COLLABORATOR - CUSTOMER -} +enum UserRole { + ADMIN + MANAGER + AGENT + COLLABORATOR +} enum TicketStatus { PENDING AWAITING_ATTENDANCE PAUSED RESOLVED - CLOSED } enum TicketPriority { @@ -74,6 +72,7 @@ model Company { tenantId String name String slug String + isAvulso Boolean @default(false) cnpj String? domain String? phone String? diff --git a/scripts/import-convex-to-prisma.mjs b/scripts/import-convex-to-prisma.mjs index 1dff888..78f8245 100644 --- a/scripts/import-convex-to-prisma.mjs +++ b/scripts/import-convex-to-prisma.mjs @@ -26,7 +26,7 @@ if (!secret) { process.exit(1) } -const allowedRoles = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR", "CUSTOMER"]) +const allowedRoles = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]) const client = new ConvexHttpClient(convexUrl) @@ -61,7 +61,7 @@ const STATUS_MAP = { ON_HOLD: "PAUSED", PAUSED: "PAUSED", RESOLVED: "RESOLVED", - CLOSED: "CLOSED", + CLOSED: "RESOLVED", } function normalizeStatus(status) { @@ -84,6 +84,7 @@ async function upsertCompanies(snapshotCompanies) { }, update: { name: company.name, + isAvulso: Boolean(company.isAvulso ?? false), cnpj: company.cnpj ?? null, domain: company.domain ?? null, phone: company.phone ?? null, @@ -94,6 +95,7 @@ async function upsertCompanies(snapshotCompanies) { tenantId, name: company.name, slug, + isAvulso: Boolean(company.isAvulso ?? false), cnpj: company.cnpj ?? null, domain: company.domain ?? null, phone: company.phone ?? null, @@ -117,8 +119,8 @@ async function upsertUsers(snapshotUsers, companyMap) { const normalizedEmail = normalizeEmail(user.email) if (!normalizedEmail) continue - const normalizedRole = (user.role ?? "CUSTOMER").toUpperCase() - const role = allowedRoles.has(normalizedRole) ? normalizedRole : "CUSTOMER" + const normalizedRole = (user.role ?? "MANAGER").toUpperCase() + const role = allowedRoles.has(normalizedRole) ? normalizedRole : "MANAGER" const companyId = user.companySlug ? companyMap.get(user.companySlug) ?? null : null const record = await prisma.user.upsert({ @@ -195,7 +197,7 @@ async function upsertUsers(snapshotUsers, companyMap) { await prisma.user.update({ where: { id: staff.id }, data: { - role: "CUSTOMER", + role: "MANAGER", }, }) } diff --git a/scripts/seed-auth.mjs b/scripts/seed-auth.mjs index 92a0f72..1001aa9 100644 --- a/scripts/seed-auth.mjs +++ b/scripts/seed-auth.mjs @@ -29,7 +29,7 @@ const defaultUsers = singleUserFromEnv ?? [ email: "cliente.demo@sistema.dev", password: "cliente123", name: "Cliente Demo", - role: "customer", + role: "manager", tenantId, }, { @@ -50,28 +50,28 @@ const defaultUsers = singleUserFromEnv ?? [ email: "joao.ramos@atlasengenharia.com.br", password: "cliente123", name: "João Pedro Ramos", - role: "customer", + role: "manager", tenantId, }, { email: "aline.rezende@atlasengenharia.com.br", password: "cliente123", name: "Aline Rezende", - role: "customer", + role: "manager", tenantId, }, { email: "ricardo.matos@omnisaude.com.br", password: "cliente123", name: "Ricardo Matos", - role: "customer", + role: "manager", tenantId, }, { email: "luciana.prado@omnisaude.com.br", password: "cliente123", name: "Luciana Prado", - role: "customer", + role: "manager", tenantId, }, { diff --git a/src/app/admin/companies/page.tsx b/src/app/admin/companies/page.tsx new file mode 100644 index 0000000..450c06f --- /dev/null +++ b/src/app/admin/companies/page.tsx @@ -0,0 +1,26 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { prisma } from "@/lib/prisma" +import { AdminCompaniesManager } from "@/components/admin/companies/admin-companies-manager" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export default async function AdminCompaniesPage() { + const companiesRaw = await prisma.company.findMany({ orderBy: { name: "asc" } }) + const companies = companiesRaw.map((c: any) => ({ ...c, isAvulso: Boolean(c.isAvulso ?? false) })) + return ( + + } + > +
+ +
+
+ ) +} diff --git a/src/app/api/admin/companies/[id]/route.ts b/src/app/api/admin/companies/[id]/route.ts new file mode 100644 index 0000000..73fb94b --- /dev/null +++ b/src/app/api/admin/companies/[id]/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server" + +import { prisma } from "@/lib/prisma" +import { assertAdminSession } from "@/lib/auth-server" + +export const runtime = "nodejs" + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await assertAdminSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + const { id } = await params + const body = await request.json() + + const updates: Record = {} + for (const key of ["name", "slug", "cnpj", "domain", "phone", "description", "address"]) { + if (key in body) updates[key] = body[key] ?? null + } + if ("isAvulso" in body) updates.isAvulso = Boolean(body.isAvulso) + + try { + const company = await prisma.company.update({ where: { id }, data: updates as any }) + return NextResponse.json({ company }) + } catch (error) { + console.error("Failed to update company", error) + return NextResponse.json({ error: "Falha ao atualizar empresa" }, { status: 500 }) + } +} diff --git a/src/app/api/admin/companies/route.ts b/src/app/api/admin/companies/route.ts new file mode 100644 index 0000000..beeef63 --- /dev/null +++ b/src/app/api/admin/companies/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server" + +import { prisma } from "@/lib/prisma" +import { assertAdminSession } from "@/lib/auth-server" + +export const runtime = "nodejs" + +export async function GET() { + const session = await assertAdminSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + const companies = await prisma.company.findMany({ + orderBy: { name: "asc" }, + }) + return NextResponse.json({ companies }) +} + +export async function POST(request: Request) { + const session = await assertAdminSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + const body = await request.json() + const { name, slug, isAvulso, cnpj, domain, phone, description, address } = body ?? {} + if (!name || !slug) { + return NextResponse.json({ error: "Nome e slug são obrigatórios" }, { status: 400 }) + } + + try { + const company = await prisma.company.create({ + data: ({ + tenantId: session.user.tenantId ?? "tenant-atlas", + name: String(name), + slug: String(slug), + isAvulso: Boolean(isAvulso ?? false), + cnpj: cnpj ? String(cnpj) : null, + domain: domain ? String(domain) : null, + phone: phone ? String(phone) : null, + description: description ? String(description) : null, + address: address ? String(address) : null, + } as any), + }) + return NextResponse.json({ company }) + } catch (error) { + console.error("Failed to create company", error) + return NextResponse.json({ error: "Falha ao criar empresa" }, { status: 500 }) + } +} diff --git a/src/app/api/admin/users/assign-company/route.ts b/src/app/api/admin/users/assign-company/route.ts new file mode 100644 index 0000000..a5761b9 --- /dev/null +++ b/src/app/api/admin/users/assign-company/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { assertAdminSession } from "@/lib/auth-server" +import { api } from "@/convex/_generated/api" + +export const runtime = "nodejs" + +export async function POST(request: Request) { + const session = await assertAdminSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + const body = await request.json().catch(() => null) as { email?: string; companyId?: string } + const email = body?.email?.trim().toLowerCase() + const companyId = body?.companyId + if (!email || !companyId) { + return NextResponse.json({ error: "Informe e-mail e empresa" }, { status: 400 }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + const client = new ConvexHttpClient(convexUrl) + + try { + await client.mutation(api.users.assignCompany, { + tenantId: session.user.tenantId ?? "tenant-atlas", + email, + companyId: companyId as any, + actorId: (session.user as any).convexUserId ?? (session.user.id as any), + }) + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("Failed to assign company", error) + return NextResponse.json({ error: "Falha ao vincular usuário" }, { status: 500 }) + } +} + diff --git a/src/app/api/reports/backlog.csv/route.ts b/src/app/api/reports/backlog.csv/route.ts new file mode 100644 index 0000000..addc2db --- /dev/null +++ b/src/app/api/reports/backlog.csv/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" + +export const runtime = "nodejs" + +function csvEscape(value: unknown): string { + const s = value == null ? "" : String(value) + if (/[",\n]/.test(s)) { + return '"' + s.replace(/"/g, '""') + '"' + } + return s +} + +function rowsToCsv(rows: Array>): string { + return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n" +} + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch (error) { + console.error("Failed to synchronize user with Convex for backlog CSV", error) + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + try { + const { searchParams } = new URL(request.url) + const range = searchParams.get("range") ?? undefined + const report = await client.query(api.reports.backlogOverview, { + tenantId, + viewerId: viewerId as unknown as Id<"users">, + range, + }) + + const rows: Array> = [] + rows.push(["Relatório", "Backlog"]) + rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : "—"]) + rows.push([]) + rows.push(["Seção", "Chave", "Valor"]) // header + + // Status + const STATUS_PT: Record = { + PENDING: "Pendentes", + AWAITING_ATTENDANCE: "Aguardando atendimento", + PAUSED: "Pausados", + RESOLVED: "Resolvidos", + } + for (const [status, total] of Object.entries(report.statusCounts)) { + rows.push(["Status", STATUS_PT[status] ?? status, total]) + } + + // Prioridade + const PRIORITY_PT: Record = { + LOW: "Baixa", + MEDIUM: "Média", + HIGH: "Alta", + URGENT: "Crítica", + } + for (const [priority, total] of Object.entries(report.priorityCounts)) { + rows.push(["Prioridade", PRIORITY_PT[priority] ?? priority, total]) + } + + // Filas + for (const q of report.queueCounts) { + rows.push(["Fila", q.name || q.id, q.total]) + } + + rows.push(["Abertos", "Total", report.totalOpen]) + + const csv = rowsToCsv(rows) + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv; charset=UTF-8", + "Content-Disposition": `attachment; filename="backlog-${tenantId}-${report.rangeDays ?? 'all'}d.csv"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("Failed to generate backlog CSV", error) + return NextResponse.json({ error: "Falha ao gerar CSV do backlog" }, { status: 500 }) + } +} diff --git a/src/app/api/reports/csat.csv/route.ts b/src/app/api/reports/csat.csv/route.ts new file mode 100644 index 0000000..a57e350 --- /dev/null +++ b/src/app/api/reports/csat.csv/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" + +export const runtime = "nodejs" + +function csvEscape(value: unknown): string { + const s = value == null ? "" : String(value) + if (/[",\n]/.test(s)) { + return '"' + s.replace(/"/g, '""') + '"' + } + return s +} + +function rowsToCsv(rows: Array>): string { + return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n" +} + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const { searchParams } = new URL(request.url) + const range = searchParams.get("range") ?? undefined + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch (error) { + console.error("Failed to synchronize user with Convex for CSAT CSV", error) + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + try { + const report = await client.query(api.reports.csatOverview, { + tenantId, + viewerId: viewerId as unknown as Id<"users">, + range, + }) + + const rows: Array> = [] + rows.push(["Relatório", "CSAT"]) + rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')]) + rows.push([]) + rows.push(["Métrica", "Valor"]) // header + rows.push(["CSAT médio", report.averageScore ?? "—"]) + rows.push(["Total de respostas", report.totalSurveys ?? 0]) + rows.push([]) + rows.push(["Distribuição", "Total"]) + for (const entry of report.distribution ?? []) { + rows.push([`Nota ${entry.score}`, entry.total]) + } + rows.push([]) + rows.push(["Recentes", "Nota", "Recebido em"]) + for (const item of report.recent ?? []) { + const date = new Date(item.receivedAt).toISOString() + rows.push([`#${item.reference}`, item.score, date]) + } + + const csv = rowsToCsv(rows) + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv; charset=UTF-8", + "Content-Disposition": `attachment; filename="csat-${tenantId}-${report.rangeDays ?? '90'}d.csv"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("Failed to generate CSAT CSV", error) + return NextResponse.json({ error: "Falha ao gerar CSV de CSAT" }, { status: 500 }) + } +} + diff --git a/src/app/api/reports/sla.csv/route.ts b/src/app/api/reports/sla.csv/route.ts new file mode 100644 index 0000000..94187cb --- /dev/null +++ b/src/app/api/reports/sla.csv/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" + +export const runtime = "nodejs" + +function csvEscape(value: unknown): string { + const s = value == null ? "" : String(value) + if (/[",\n]/.test(s)) { + return '"' + s.replace(/"/g, '""') + '"' + } + return s +} + +function rowsToCsv(rows: Array>): string { + return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n" +} + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const { searchParams } = new URL(request.url) + const range = searchParams.get("range") ?? undefined + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch (error) { + console.error("Failed to synchronize user with Convex for SLA CSV", error) + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + try { + const report = await client.query(api.reports.slaOverview, { + tenantId, + viewerId: viewerId as unknown as Id<"users">, + range, + }) + + const rows: Array> = [] + rows.push(["Relatório", "SLA e produtividade"]) + rows.push(["Período", range ?? "—"]) + rows.push([]) + + rows.push(["Métrica", "Valor"]) // header + rows.push(["Tickets totais", report.totals.total]) + rows.push(["Tickets abertos", report.totals.open]) + rows.push(["Tickets resolvidos", report.totals.resolved]) + rows.push(["Atrasados (SLA)", report.totals.overdue]) + rows.push([]) + rows.push(["Tempo médio de 1ª resposta (min)", report.response.averageFirstResponseMinutes ?? "—"]) + rows.push(["Respostas registradas", report.response.responsesRegistered ?? 0]) + rows.push(["Tempo médio de resolução (min)", report.resolution.averageResolutionMinutes ?? "—"]) + rows.push(["Tickets resolvidos (amostra)", report.resolution.resolvedCount ?? 0]) + rows.push([]) + rows.push(["Fila", "Abertos"]) + for (const q of report.queueBreakdown ?? []) { + rows.push([q.name || q.id, q.open]) + } + + const csv = rowsToCsv(rows) + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv; charset=UTF-8", + "Content-Disposition": `attachment; filename="sla-${tenantId}.csv"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("Failed to generate SLA CSV", error) + return NextResponse.json({ error: "Falha ao gerar CSV de SLA" }, { status: 500 }) + } +} + diff --git a/src/app/api/reports/tickets-by-channel.csv/route.ts b/src/app/api/reports/tickets-by-channel.csv/route.ts new file mode 100644 index 0000000..de7d958 --- /dev/null +++ b/src/app/api/reports/tickets-by-channel.csv/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" + +export const runtime = "nodejs" + +function csvEscape(value: unknown): string { + const s = value == null ? "" : String(value) + if (/[",\n]/.test(s)) { + return '"' + s.replace(/"/g, '""') + '"' + } + return s +} + +function rowsToCsv(rows: Array>): string { + return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n" +} + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const { searchParams } = new URL(request.url) + const range = searchParams.get("range") ?? undefined // "7d" | "30d" | undefined(=90d) + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch (error) { + console.error("Failed to synchronize user with Convex for channel CSV", error) + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + try { + const report = await client.query(api.reports.ticketsByChannel, { + tenantId, + viewerId: viewerId as unknown as Id<"users">, + range, + }) + + const channels = report.channels + const CHANNEL_PT: Record = { + EMAIL: "E-mail", + PHONE: "Telefone", + CHAT: "Chat", + WHATSAPP: "WhatsApp", + API: "API", + MANUAL: "Manual", + WEB: "Portal", + PORTAL: "Portal", + } + const header = ["Data", ...channels.map((ch) => CHANNEL_PT[ch] ?? ch)] + const rows: Array> = [] + rows.push(["Relatório", "Tickets por canal"]) + rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')]) + rows.push([]) + rows.push(header) + + for (const point of report.points) { + const values = channels.map((ch) => point.values[ch] ?? 0) + rows.push([point.date, ...values]) + } + + const csv = rowsToCsv(rows) + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv; charset=UTF-8", + "Content-Disposition": `attachment; filename="tickets-by-channel-${tenantId}-${range ?? '90d'}.csv"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("Failed to generate tickets-by-channel CSV", error) + return NextResponse.json({ error: "Falha ao gerar CSV de tickets por canal" }, { status: 500 }) + } +} diff --git a/src/app/api/tickets/[id]/export/pdf/route.ts b/src/app/api/tickets/[id]/export/pdf/route.ts index d953b7d..d48eb9f 100644 --- a/src/app/api/tickets/[id]/export/pdf/route.ts +++ b/src/app/api/tickets/[id]/export/pdf/route.ts @@ -1,5 +1,9 @@ import { NextResponse } from "next/server" -import PDFDocument from "pdfkit" +// Use the standalone build to avoid AFM filesystem lookups +// and ensure compatibility in serverless/traced environments. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore – no ambient types for this path; declared in types/ +import PDFDocument from "pdfkit/js/pdfkit.standalone.js" import { format } from "date-fns" import { ptBR } from "date-fns/locale" import { ConvexHttpClient } from "convex/browser" @@ -7,16 +11,44 @@ import { ConvexHttpClient } from "convex/browser" import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { env } from "@/lib/env" -import { assertStaffSession } from "@/lib/auth-server" +import { assertAuthenticatedSession } from "@/lib/auth-server" import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket" import { DEFAULT_TENANT_ID } from "@/lib/constants" +// Force Node.js runtime for pdfkit compatibility +export const runtime = "nodejs" + const statusLabel: Record = { PENDING: "Pendente", AWAITING_ATTENDANCE: "Aguardando atendimento", PAUSED: "Pausado", RESOLVED: "Resolvido", - CLOSED: "Fechado", +} + +const statusColors: Record = { + PENDING: "#64748B", // slate-500 + AWAITING_ATTENDANCE: "#0EA5E9", // sky-500 + PAUSED: "#F59E0B", // amber-500 + RESOLVED: "#10B981", // emerald-500 +} + +const priorityLabel: Record = { + LOW: "Baixa", + MEDIUM: "Média", + HIGH: "Alta", + URGENT: "Urgente", + CRITICAL: "Crítica", +} + +const channelLabel: Record = { + EMAIL: "E-mail", + PHONE: "Telefone", + CHAT: "Chat", + PORTAL: "Portal", + WEB: "Portal", + API: "API", + SOCIAL: "Redes sociais", + OTHER: "Outro", } const timelineLabel: Record = { @@ -31,6 +63,12 @@ const timelineLabel: Record = { WORK_STARTED: "Atendimento iniciado", WORK_PAUSED: "Atendimento pausado", CATEGORY_CHANGED: "Categoria alterada", + MANAGER_NOTIFIED: "Gestor notificado", + SUBJECT_CHANGED: "Assunto atualizado", + SUMMARY_CHANGED: "Resumo atualizado", + VISIT_SCHEDULED: "Visita agendada", + CSAT_RECEIVED: "CSAT recebido", + CSAT_RATED: "CSAT avaliado", } function formatDateTime(date: Date | null | undefined) { @@ -57,9 +95,104 @@ function decodeHtmlEntities(input: string) { .replace(/ /g, " ") } +function stringifyPayload(payload: unknown): string | null { + if (!payload) return null + if (typeof payload === "object") { + if (Array.isArray(payload)) { + if (payload.length === 0) return null + } else if (payload) { + if (Object.keys(payload as Record).length === 0) return null + } + } + if (typeof payload === "string" && payload.trim() === "") return null + try { + return JSON.stringify(payload, null, 2) + } catch { + return String(payload) + } +} + +function formatDurationMs(ms: number | null | undefined) { + if (!ms || ms <= 0) return null + const totalSeconds = Math.floor(ms / 1000) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m` + if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s` + return `${seconds}s` +} + +function buildTimelineMessage(type: string, payload: any): string | null { + if (!payload || typeof payload !== "object") payload = {} + const to = payload.toLabel ?? payload.to + const assignee = payload.assigneeName ?? payload.assigneeId + const queue = payload.queueName ?? payload.queueId + const requester = payload.requesterName + const author = payload.authorName ?? payload.authorId + const actor = payload.actorName ?? payload.actorId + const attachmentName = payload.attachmentName + const subjectTo = payload.to + const pauseReason = payload.pauseReasonLabel ?? payload.pauseReason + const pauseNote = payload.pauseNote + const sessionDuration = formatDurationMs(payload.sessionDurationMs) + const categoryName = payload.categoryName + const subcategoryName = payload.subcategoryName + + switch (type) { + case "STATUS_CHANGED": + return to ? `Status alterado para ${to}` : "Status alterado" + case "ASSIGNEE_CHANGED": + return assignee ? `Responsável alterado para ${assignee}` : "Responsável alterado" + case "QUEUE_CHANGED": + return queue ? `Fila alterada para ${queue}` : "Fila alterada" + case "PRIORITY_CHANGED": + return to ? `Prioridade alterada para ${to}` : "Prioridade alterada" + case "CREATED": + return requester ? `Criado por ${requester}` : "Criado" + case "COMMENT_ADDED": + return author ? `Comentário adicionado por ${author}` : "Comentário adicionado" + case "COMMENT_EDITED": { + const who = actor ?? author + return who ? `Comentário editado por ${who}` : "Comentário editado" + } + case "SUBJECT_CHANGED": + return subjectTo ? `Assunto alterado para "${subjectTo}"` : "Assunto alterado" + case "SUMMARY_CHANGED": + return "Resumo atualizado" + case "ATTACHMENT_REMOVED": + return attachmentName ? `Anexo removido: ${attachmentName}` : "Anexo removido" + case "WORK_PAUSED": { + const parts: string[] = [] + if (pauseReason) parts.push(`Motivo: ${pauseReason}`) + if (sessionDuration) parts.push(`Tempo registrado: ${sessionDuration}`) + if (pauseNote) parts.push(`Observação: ${pauseNote}`) + return parts.length > 0 ? parts.join(" • ") : "Atendimento pausado" + } + case "WORK_STARTED": + return "Atendimento iniciado" + case "CATEGORY_CHANGED": { + if (categoryName || subcategoryName) { + return `Categoria alterada para ${categoryName ?? ""}${subcategoryName ? ` • ${subcategoryName}` : ""}`.trim() + } + return "Categoria removida" + } + case "MANAGER_NOTIFIED": + return "Gestor notificado" + case "VISIT_SCHEDULED": + return "Visita agendada" + case "CSAT_RECEIVED": + return "CSAT recebido" + case "CSAT_RATED": + return "CSAT avaliado" + default: + return null + } +} + export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) { const { id: ticketId } = await context.params - const session = await assertStaffSession() + const session = await assertAuthenticatedSession() if (!session) { return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) } @@ -111,10 +244,10 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st } const ticket = mapTicketWithDetailsFromServer(ticketRaw) - const doc = new PDFDocument({ size: "A4", margin: 48 }) + const doc = new PDFDocument({ size: "A4", margin: 56 }) const chunks: Buffer[] = [] - doc.on("data", (chunk) => { + doc.on("data", (chunk: any) => { chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk) }) @@ -123,24 +256,58 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st doc.on("error", reject) }) + // Título doc.font("Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference} — ${ticket.subject}`) - doc.moveDown(0.5) + doc.moveDown(0.25) + // Linha abaixo do título doc + .strokeColor("#E2E8F0") + .moveTo(doc.page.margins.left, doc.y) + .lineTo(doc.page.width - doc.page.margins.right, doc.y) + .stroke() + + // Badge de status + doc.moveDown(0.5) + const statusText = statusLabel[ticket.status] ?? ticket.status + const badgeColor = statusColors[ticket.status] ?? "#475569" + const badgeFontSize = 10 + const badgePaddingX = 6 + const badgePaddingY = 3 + const badgeX = doc.page.margins.left + const badgeY = doc.y + doc.save() + doc.font("Helvetica-Bold").fontSize(badgeFontSize) + const badgeTextWidth = doc.widthOfString(statusText) + const badgeHeight = badgeFontSize + badgePaddingY * 2 + const badgeWidth = badgeTextWidth + badgePaddingX * 2 + ;(doc as any).roundedRect?.(badgeX, badgeY, badgeWidth, badgeHeight, 4) ?? doc.rect(badgeX, badgeY, badgeWidth, badgeHeight) + doc.fill(badgeColor) + doc.fillColor("#FFFFFF").text(statusText, badgeX + badgePaddingX, badgeY + badgePaddingY) + doc.restore() + doc.y = badgeY + badgeHeight + 8 + + // Metadados básicos + doc + .fillColor("#0F172A") .font("Helvetica") .fontSize(11) - .text(`Status: ${statusLabel[ticket.status] ?? ticket.status}`) + .text(`Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`, { lineGap: 2 }) .moveDown(0.15) - .text(`Prioridade: ${ticket.priority}`) + .text(`Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`, { lineGap: 2 }) .moveDown(0.15) - .text(`Canal: ${ticket.channel}`) - .moveDown(0.15) - .text(`Fila: ${ticket.queue ?? "—"}`) + .text(`Fila: ${ticket.queue ?? "—"}`, { lineGap: 2 }) doc.moveDown(0.75) doc .font("Helvetica-Bold") .fontSize(12) .text("Solicitante") + doc + .strokeColor("#E2E8F0") + .moveTo(doc.page.margins.left, doc.y) + .lineTo(doc.page.width - doc.page.margins.right, doc.y) + .stroke() + doc.moveDown(0.3) doc .font("Helvetica") .fontSize(11) @@ -148,6 +315,12 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st doc.moveDown(0.5) doc.font("Helvetica-Bold").fontSize(12).text("Responsável") + doc + .strokeColor("#E2E8F0") + .moveTo(doc.page.margins.left, doc.y) + .lineTo(doc.page.width - doc.page.margins.right, doc.y) + .stroke() + doc.moveDown(0.3) doc .font("Helvetica") .fontSize(11) @@ -155,6 +328,12 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st doc.moveDown(0.75) doc.font("Helvetica-Bold").fontSize(12).text("Datas") + doc + .strokeColor("#E2E8F0") + .moveTo(doc.page.margins.left, doc.y) + .lineTo(doc.page.width - doc.page.margins.right, doc.y) + .stroke() + doc.moveDown(0.3) doc .font("Helvetica") .fontSize(11) @@ -167,25 +346,35 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st if (ticket.summary) { doc.moveDown(0.75) doc.font("Helvetica-Bold").fontSize(12).text("Resumo") + doc + .strokeColor("#E2E8F0") + .moveTo(doc.page.margins.left, doc.y) + .lineTo(doc.page.width - doc.page.margins.right, doc.y) + .stroke() doc .font("Helvetica") .fontSize(11) - .text(ticket.summary, { align: "justify" }) + .text(ticket.summary, { align: "justify", lineGap: 2 }) } if (ticket.description) { doc.moveDown(0.75) doc.font("Helvetica-Bold").fontSize(12).text("Descrição") + doc + .strokeColor("#E2E8F0") + .moveTo(doc.page.margins.left, doc.y) + .lineTo(doc.page.width - doc.page.margins.right, doc.y) + .stroke() doc .font("Helvetica") .fontSize(11) - .text(htmlToPlainText(ticket.description), { align: "justify" }) + .text(htmlToPlainText(ticket.description), { align: "justify", lineGap: 2 }) } if (ticket.comments.length > 0) { doc.addPage() doc.font("Helvetica-Bold").fontSize(14).text("Comentários") - doc.moveDown(0.5) + doc.moveDown(0.6) const commentsSorted = [...ticket.comments].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) commentsSorted.forEach((comment, index) => { const visibility = @@ -193,15 +382,14 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st doc .font("Helvetica-Bold") .fontSize(11) - .text( - `${comment.author.name} • ${visibility} • ${formatDateTime(comment.createdAt)}` - ) + .text(`${comment.author.name} • ${visibility} • ${formatDateTime(comment.createdAt)}`) + doc.moveDown(0.15) const body = htmlToPlainText(comment.body) if (body) { doc .font("Helvetica") .fontSize(11) - .text(body, { align: "justify" }) + .text(body, { align: "justify", lineGap: 2, indent: 6 }) } if (comment.attachments.length > 0) { doc.moveDown(0.25) @@ -210,17 +398,17 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st doc .font("Helvetica") .fontSize(10) - .text(`• ${attachment.name ?? attachment.id}`, { indent: 12 }) + .text(`• ${attachment.name ?? attachment.id}`, { indent: 16, lineGap: 1 }) }) } if (index < commentsSorted.length - 1) { - doc.moveDown(0.75) + doc.moveDown(1) doc .strokeColor("#E2E8F0") .moveTo(doc.x, doc.y) .lineTo(doc.page.width - doc.page.margins.right, doc.y) .stroke() - doc.moveDown(0.75) + doc.moveDown(0.9) } }) } @@ -228,7 +416,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st if (ticket.timeline.length > 0) { doc.addPage() doc.font("Helvetica-Bold").fontSize(14).text("Linha do tempo") - doc.moveDown(0.5) + doc.moveDown(0.6) const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) timelineSorted.forEach((event) => { const label = timelineLabel[event.type] ?? event.type @@ -236,14 +424,24 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st .font("Helvetica-Bold") .fontSize(11) .text(`${label} • ${formatDateTime(event.createdAt)}`) - if (event.payload) { - const payloadText = JSON.stringify(event.payload, null, 2) + doc.moveDown(0.15) + + const friendly = buildTimelineMessage(event.type, event.payload) + if (friendly) { doc .font("Helvetica") .fontSize(10) - .text(payloadText, { indent: 12 }) + .text(friendly, { indent: 16, lineGap: 1 }) + } else { + const payloadText = stringifyPayload(event.payload) + if (payloadText) { + doc + .font("Helvetica") + .fontSize(10) + .text(payloadText, { indent: 16, lineGap: 1 }) + } } - doc.moveDown(0.5) + doc.moveDown(0.7) }) } diff --git a/src/app/reports/csat/page.tsx b/src/app/reports/csat/page.tsx index 83d0715..c24934b 100644 --- a/src/app/reports/csat/page.tsx +++ b/src/app/reports/csat/page.tsx @@ -11,6 +11,13 @@ export default function ReportsCsatPage() { + + Exportar CSV + + + } /> } > diff --git a/src/app/reports/sla/page.tsx b/src/app/reports/sla/page.tsx index 824f935..91cec01 100644 --- a/src/app/reports/sla/page.tsx +++ b/src/app/reports/sla/page.tsx @@ -11,6 +11,13 @@ export default function ReportsSlaPage() { + + Exportar CSV + + + } /> } > diff --git a/src/components/admin/admin-users-manager.tsx b/src/components/admin/admin-users-manager.tsx index fe4a2ba..2e03d45 100644 --- a/src/components/admin/admin-users-manager.tsx +++ b/src/components/admin/admin-users-manager.tsx @@ -95,9 +95,26 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d const [lastInviteLink, setLastInviteLink] = useState(null) const [revokingId, setRevokingId] = useState(null) const [isPending, startTransition] = useTransition() + const [companies, setCompanies] = useState>([]) + const [linkEmail, setLinkEmail] = useState("") + const [linkCompanyId, setLinkCompanyId] = useState("") const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions]) + // load companies for association + useMemo(() => { + void (async () => { + try { + const r = await fetch("/api/admin/companies", { credentials: "include" }) + const j = await r.json() + const items = (j.companies ?? []).map((c: any) => ({ id: c.id as string, name: c.name as string })) + setCompanies(items) + } catch { + // noop + } + })() + }, []) + async function handleInviteSubmit(event: React.FormEvent) { event.preventDefault() if (!email || !email.includes("@")) { @@ -238,9 +255,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d {normalizedRoles.map((item) => ( - {item === "customer" - ? "Cliente" - : item === "admin" + {item === "admin" ? "Administrador" : item === "manager" ? "Gestor" @@ -294,6 +309,63 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d + + + Vincular usuário a empresa + Associe um colaborador à sua empresa (usado para escopo de gestores e relatórios). + + +
{ + e.preventDefault() + if (!linkEmail || !linkCompanyId) { + toast.error("Informe e-mail e empresa") + return + } + startTransition(async () => { + toast.loading("Vinculando...", { id: "assign-company" }) + try { + const r = await fetch("/api/admin/users/assign-company", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: linkEmail, companyId: linkCompanyId }), + credentials: "include", + }) + if (!r.ok) throw new Error("failed") + toast.success("Usuário vinculado à empresa!", { id: "assign-company" }) + } catch { + toast.error("Não foi possível vincular", { id: "assign-company" }) + } + }) + }} + > +
+ + setLinkEmail(e.target.value)} placeholder="colaborador@empresa.com" /> +
+
+ + +
+
+ +
+
+
+
+ Convites emitidos diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx new file mode 100644 index 0000000..bcd077c --- /dev/null +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -0,0 +1,213 @@ +"use client" + +import { useMemo, useState, useTransition } from "react" +import { toast } from "sonner" + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +type Company = { + id: string + tenantId: string + name: string + slug: string + isAvulso: boolean + cnpj: string | null + domain: string | null + phone: string | null + description: string | null + address: string | null +} + +export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) { + const [companies, setCompanies] = useState(initialCompanies) + const [isPending, startTransition] = useTransition() + const [form, setForm] = useState>({}) + const [editingId, setEditingId] = useState(null) + + const resetForm = () => setForm({}) + + async function refresh() { + const r = await fetch("/api/admin/companies", { credentials: "include" }) + const json = (await r.json()) as { companies: Company[] } + setCompanies(json.companies) + } + + function handleEdit(c: Company) { + setEditingId(c.id) + setForm({ ...c }) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const payload = { + name: form.name?.trim(), + slug: form.slug?.trim(), + isAvulso: Boolean(form.isAvulso ?? false), + cnpj: form.cnpj?.trim() || null, + domain: form.domain?.trim() || null, + phone: form.phone?.trim() || null, + description: form.description?.trim() || null, + address: form.address?.trim() || null, + } + if (!payload.name || !payload.slug) { + toast.error("Informe nome e slug válidos") + return + } + startTransition(async () => { + toast.loading(editingId ? "Atualizando empresa..." : "Criando empresa...", { id: "companies" }) + try { + if (editingId) { + const r = await fetch(`/api/admin/companies/${editingId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + credentials: "include", + }) + if (!r.ok) throw new Error("update_failed") + } else { + const r = await fetch(`/api/admin/companies`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + credentials: "include", + }) + if (!r.ok) throw new Error("create_failed") + } + await refresh() + resetForm() + setEditingId(null) + toast.success(editingId ? "Empresa atualizada" : "Empresa criada", { id: "companies" }) + } catch { + toast.error("Não foi possível salvar", { id: "companies" }) + } + }) + } + + async function toggleAvulso(c: Company) { + startTransition(async () => { + try { + const r = await fetch(`/api/admin/companies/${c.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isAvulso: !c.isAvulso }), + credentials: "include", + }) + if (!r.ok) throw new Error("toggle_failed") + await refresh() + } catch { + toast.error("Não foi possível atualizar o cliente avulso") + } + }) + } + + return ( +
+ + + Nova empresa + Cadastre um cliente/empresa e defina se é avulso. + + +
+
+ + setForm((p) => ({ ...p, name: e.target.value }))} /> +
+
+ + setForm((p) => ({ ...p, slug: e.target.value }))} /> +
+
+ + setForm((p) => ({ ...p, cnpj: e.target.value }))} /> +
+
+ + setForm((p) => ({ ...p, domain: e.target.value }))} /> +
+
+ + setForm((p) => ({ ...p, phone: e.target.value }))} /> +
+
+ + setForm((p) => ({ ...p, address: e.target.value }))} /> +
+
+ setForm((p) => ({ ...p, isAvulso: Boolean(v) }))} + id="is-avulso" + /> + +
+
+ + {editingId ? ( + + ) : null} +
+
+
+
+ + + + Empresas cadastradas + Gerencie empresas e o status de cliente avulso. + + + + + + Nome + Slug + Avulso + Domínio + Telefone + CNPJ + Ações + + + + {companies.map((c) => ( + + {c.name} + {c.slug} + + + + {c.domain ?? "—"} + {c.phone ?? "—"} + {c.cnpj ?? "—"} + + + + + ))} + +
+
+
+
+ ) +} + diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index b878c8d..b604159 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -40,7 +40,7 @@ import { useAuth } from "@/lib/auth-client" import type { LucideIcon } from "lucide-react" -type NavRoleRequirement = "staff" | "admin" | "customer" +type NavRoleRequirement = "staff" | "admin" type NavigationItem = { title: string @@ -91,6 +91,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = { }, { title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" }, { title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" }, + { title: "Empresas & clientes", url: "/admin/companies", icon: Users, requiredRole: "admin" }, { title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" }, { title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" }, ], @@ -105,7 +106,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = { export function AppSidebar({ ...props }: React.ComponentProps) { const pathname = usePathname() - const { session, isLoading, isAdmin, isStaff, isCustomer } = useAuth() + const { session, isLoading, isAdmin, isStaff } = useAuth() const [isHydrated, setIsHydrated] = React.useState(false) React.useEffect(() => { @@ -128,7 +129,6 @@ export function AppSidebar({ ...props }: React.ComponentProps) { if (!requiredRole) return true if (requiredRole === "admin") return isAdmin if (requiredRole === "staff") return isStaff - if (requiredRole === "customer") return isCustomer return false } diff --git a/src/components/chart-area-interactive.tsx b/src/components/chart-area-interactive.tsx index 5a12f10..f85b51e 100644 --- a/src/components/chart-area-interactive.tsx +++ b/src/components/chart-area-interactive.tsx @@ -9,14 +9,15 @@ import type { Id } from "@/convex/_generated/dataModel" import { useAuth } from "@/lib/auth-client" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { useIsMobile } from "@/hooks/use-mobile" -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Button } from "@/components/ui/button" import { ChartConfig, ChartContainer, @@ -111,19 +112,27 @@ export function ChartAreaInteractive() { return ( - Entrada de tickets por canal - + Entrada de tickets por canal + Distribuição dos canais nos últimos {timeRange.replace("d", " dias")} Período: {timeRange} - - + + 90 dias diff --git a/src/components/portal/portal-shell.tsx b/src/components/portal/portal-shell.tsx index 252de57..320460f 100644 --- a/src/components/portal/portal-shell.tsx +++ b/src/components/portal/portal-shell.tsx @@ -23,7 +23,7 @@ const navItems = [ export function PortalShell({ children }: PortalShellProps) { const pathname = usePathname() const router = useRouter() - const { session, isCustomer } = useAuth() + const { session } = useAuth() const [isSigningOut, setIsSigningOut] = useState(false) const initials = useMemo(() => { @@ -107,11 +107,7 @@ export function PortalShell({ children }: PortalShellProps) {
- {!isCustomer ? ( -
- Este portal é voltado a clientes. Algumas ações podem não estar disponíveis para o seu perfil. -
- ) : null} + {null} {children}