feat: CSV exports, PDF improvements, play internal/external with hour split, roles cleanup, admin companies with 'Cliente avulso', ticket list spacing/alignment fixes, status translations and mappings
This commit is contained in:
parent
addd4ce6e8
commit
3bafcc5a0a
45 changed files with 1401 additions and 256 deletions
BIN
Captura de tela 2025-10-07 133523.png
Normal file
BIN
Captura de tela 2025-10-07 133523.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
|
|
@ -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] Criar perfil **Gestor da Empresa (cliente)** com permissões específicas
|
||||||
- [x] Ver todos os chamados da sua empresa
|
- [x] Ver todos os chamados da sua empresa
|
||||||
- [x] Acessar relatórios e dashboards resumidos
|
- [x] Acessar relatórios e dashboards resumidos
|
||||||
- [ ] Exportar relatórios em PDF ou CSV
|
- [x] Exportar relatórios em PDF ou CSV
|
||||||
- [x] Manter perfis: Administrador, Técnico, Gestor da Empresa, Usuário Final
|
- [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)**
|
- [ ] Criar **relatório de horas por cliente (CSV/Dashboard)**
|
||||||
- [ ] Separar por atendimento interno e externo
|
- [ ] Separar por atendimento interno e externo
|
||||||
- [ ] Filtrar por período (dia, semana, mês)
|
- [ ] 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
|
# ⏱️ Controle de tempo e contratos
|
||||||
|
|
||||||
- [ ] Adicionar botão **Play interno** (atendimento remoto)
|
- [x] Adicionar botão **Play interno** (atendimento remoto)
|
||||||
- [ ] Adicionar botão **Play externo** (atendimento presencial)
|
- [x] Adicionar botão **Play externo** (atendimento presencial)
|
||||||
- [ ] Separar contagem de horas por tipo (interno/externo)
|
- [x] Separar contagem de horas por tipo (interno/externo)
|
||||||
- [ ] Exibir e somar **horas gastas por cliente** (com base no tipo)
|
- [ ] Exibir e somar **horas gastas por cliente** (com base no tipo)
|
||||||
- [ ] Incluir no cadastro:
|
- [ ] Incluir no cadastro:
|
||||||
- [ ] Horas contratadas por mês
|
- [ ] 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
|
- [ ] Enviar alerta automático por e-mail quando atingir limite de horas
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||||
|
|
||||||
const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret"
|
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"])
|
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"])
|
||||||
|
|
||||||
function normalizeEmail(value: string) {
|
function normalizeEmail(value: string) {
|
||||||
|
|
@ -30,6 +30,7 @@ type ImportedQueue = {
|
||||||
type ImportedCompany = {
|
type ImportedCompany = {
|
||||||
slug: string
|
slug: string
|
||||||
name: string
|
name: string
|
||||||
|
isAvulso?: boolean | null
|
||||||
cnpj?: string | null
|
cnpj?: string | null
|
||||||
domain?: string | null
|
domain?: string | null
|
||||||
phone?: string | null
|
phone?: string | null
|
||||||
|
|
@ -43,6 +44,8 @@ function normalizeRole(role: string | null | undefined) {
|
||||||
if (!role) return "AGENT"
|
if (!role) return "AGENT"
|
||||||
const normalized = role.toUpperCase()
|
const normalized = role.toUpperCase()
|
||||||
if (VALID_ROLES.has(normalized)) return normalized
|
if (VALID_ROLES.has(normalized)) return normalized
|
||||||
|
// map legacy CUSTOMER to MANAGER
|
||||||
|
if (normalized === "CUSTOMER") return "MANAGER"
|
||||||
return "AGENT"
|
return "AGENT"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,6 +185,7 @@ async function ensureCompany(
|
||||||
tenantId,
|
tenantId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
slug,
|
slug,
|
||||||
|
isAvulso: data.isAvulso ?? undefined,
|
||||||
cnpj: data.cnpj ?? undefined,
|
cnpj: data.cnpj ?? undefined,
|
||||||
domain: data.domain ?? undefined,
|
domain: data.domain ?? undefined,
|
||||||
phone: data.phone ?? undefined,
|
phone: data.phone ?? undefined,
|
||||||
|
|
@ -195,6 +199,7 @@ async function ensureCompany(
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const needsPatch =
|
const needsPatch =
|
||||||
existing.name !== payload.name ||
|
existing.name !== payload.name ||
|
||||||
|
(existing as any).isAvulso !== (payload.isAvulso ?? (existing as any).isAvulso) ||
|
||||||
existing.cnpj !== (payload.cnpj ?? undefined) ||
|
existing.cnpj !== (payload.cnpj ?? undefined) ||
|
||||||
existing.domain !== (payload.domain ?? undefined) ||
|
existing.domain !== (payload.domain ?? undefined) ||
|
||||||
existing.phone !== (payload.phone ?? undefined) ||
|
existing.phone !== (payload.phone ?? undefined) ||
|
||||||
|
|
@ -203,6 +208,7 @@ async function ensureCompany(
|
||||||
if (needsPatch) {
|
if (needsPatch) {
|
||||||
await ctx.db.patch(existing._id, {
|
await ctx.db.patch(existing._id, {
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
|
isAvulso: payload.isAvulso,
|
||||||
cnpj: payload.cnpj,
|
cnpj: payload.cnpj,
|
||||||
domain: payload.domain,
|
domain: payload.domain,
|
||||||
phone: payload.phone,
|
phone: payload.phone,
|
||||||
|
|
@ -344,6 +350,7 @@ export const exportTenantSnapshot = query({
|
||||||
companies: companies.map((company) => ({
|
companies: companies.map((company) => ({
|
||||||
slug: company.slug,
|
slug: company.slug,
|
||||||
name: company.name,
|
name: company.name,
|
||||||
|
isAvulso: (company as any).isAvulso ?? false,
|
||||||
cnpj: company.cnpj ?? null,
|
cnpj: company.cnpj ?? null,
|
||||||
domain: company.domain ?? null,
|
domain: company.domain ?? null,
|
||||||
phone: company.phone ?? null,
|
phone: company.phone ?? null,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type { Id } from "./_generated/dataModel";
|
||||||
|
|
||||||
import { requireAdmin, requireStaff } from "./rbac";
|
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<string, TicketStatusNormalized> = {
|
const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
|
||||||
NEW: "PENDING",
|
NEW: "PENDING",
|
||||||
|
|
@ -15,7 +15,7 @@ const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
|
||||||
ON_HOLD: "PAUSED",
|
ON_HOLD: "PAUSED",
|
||||||
PAUSED: "PAUSED",
|
PAUSED: "PAUSED",
|
||||||
RESOLVED: "RESOLVED",
|
RESOLVED: "RESOLVED",
|
||||||
CLOSED: "CLOSED",
|
CLOSED: "RESOLVED",
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
||||||
|
|
@ -123,7 +123,7 @@ export const summary = query({
|
||||||
}).length;
|
}).length;
|
||||||
const open = pending.filter((t) => {
|
const open = pending.filter((t) => {
|
||||||
const status = normalizeStatus(t.status);
|
const status = normalizeStatus(t.status);
|
||||||
return status !== "RESOLVED" && status !== "CLOSED";
|
return status !== "RESOLVED";
|
||||||
}).length;
|
}).length;
|
||||||
const breached = 0;
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import type { Id } from "./_generated/dataModel"
|
||||||
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||||
|
|
||||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
||||||
const CUSTOMER_ROLE = "CUSTOMER"
|
|
||||||
const MANAGER_ROLE = "MANAGER"
|
const MANAGER_ROLE = "MANAGER"
|
||||||
|
|
||||||
type Ctx = QueryCtx | MutationCtx
|
type Ctx = QueryCtx | MutationCtx
|
||||||
|
|
@ -45,13 +44,7 @@ export async function requireAdmin(ctx: Ctx, userId: Id<"users">, tenantId?: str
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireCustomer(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
// removed customer role; use requireCompanyManager or requireStaff as appropriate
|
||||||
const result = await requireUser(ctx, userId, tenantId)
|
|
||||||
if (result.role !== CUSTOMER_ROLE) {
|
|
||||||
throw new ConvexError("Acesso restrito ao portal do cliente")
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireCompanyManager(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
export async function requireCompanyManager(ctx: Ctx, userId: Id<"users">, tenantId?: string) {
|
||||||
const result = await requireUser(ctx, userId, tenantId)
|
const result = await requireUser(ctx, userId, tenantId)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type { Doc, Id } from "./_generated/dataModel";
|
||||||
|
|
||||||
import { requireStaff } from "./rbac";
|
import { requireStaff } from "./rbac";
|
||||||
|
|
||||||
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED";
|
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
|
||||||
|
|
||||||
const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
|
const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
|
||||||
NEW: "PENDING",
|
NEW: "PENDING",
|
||||||
|
|
@ -15,7 +15,7 @@ const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
|
||||||
ON_HOLD: "PAUSED",
|
ON_HOLD: "PAUSED",
|
||||||
PAUSED: "PAUSED",
|
PAUSED: "PAUSED",
|
||||||
RESOLVED: "RESOLVED",
|
RESOLVED: "RESOLVED",
|
||||||
CLOSED: "CLOSED",
|
CLOSED: "RESOLVED",
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
||||||
|
|
@ -128,7 +128,7 @@ function formatDateKey(timestamp: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const slaOverview = query({
|
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 }) => {
|
handler: async (ctx, { tenantId, viewerId }) => {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
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 openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
|
||||||
const resolvedTickets = tickets.filter((ticket) => {
|
const resolvedTickets = tickets.filter((ticket) => {
|
||||||
const status = normalizeStatus(ticket.status);
|
const status = normalizeStatus(ticket.status);
|
||||||
return status === "RESOLVED" || status === "CLOSED";
|
return status === "RESOLVED";
|
||||||
});
|
});
|
||||||
const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
|
const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
|
||||||
|
|
||||||
|
|
@ -179,11 +179,17 @@ export const slaOverview = query({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const csatOverview = query({
|
export const csatOverview = 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 }) => {
|
handler: async (ctx, { tenantId, viewerId, range }) => {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
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 averageScore = average(surveys.map((item) => item.score));
|
||||||
const distribution = [1, 2, 3, 4, 5].map((score) => ({
|
const distribution = [1, 2, 3, 4, 5].map((score) => ({
|
||||||
|
|
@ -205,28 +211,37 @@ export const csatOverview = query({
|
||||||
score: item.score,
|
score: item.score,
|
||||||
receivedAt: item.receivedAt,
|
receivedAt: item.receivedAt,
|
||||||
})),
|
})),
|
||||||
|
rangeDays: days,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const backlogOverview = query({
|
export const backlogOverview = 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 }) => {
|
handler: async (ctx, { tenantId, viewerId, range }) => {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
const tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||||
|
|
||||||
const statusCounts = tickets.reduce<Record<TicketStatusNormalized, number>>((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<Record<TicketStatusNormalized, number>>((acc, ticket) => {
|
||||||
const status = normalizeStatus(ticket.status);
|
const status = normalizeStatus(ticket.status);
|
||||||
acc[status] = (acc[status] ?? 0) + 1;
|
acc[status] = (acc[status] ?? 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<TicketStatusNormalized, number>);
|
}, {} as Record<TicketStatusNormalized, number>);
|
||||||
|
|
||||||
const priorityCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
|
const priorityCounts = inRange.reduce<Record<string, number>>((acc, ticket) => {
|
||||||
acc[ticket.priority] = (acc[ticket.priority] ?? 0) + 1;
|
acc[ticket.priority] = (acc[ticket.priority] ?? 0) + 1;
|
||||||
return acc;
|
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<string, { name: string; count: number }>();
|
const queueMap = new Map<string, { name: string; count: number }>();
|
||||||
for (const ticket of openTickets) {
|
for (const ticket of openTickets) {
|
||||||
|
|
@ -245,6 +260,7 @@ export const backlogOverview = query({
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
rangeDays: days,
|
||||||
statusCounts,
|
statusCounts,
|
||||||
priorityCounts,
|
priorityCounts,
|
||||||
queueCounts: Array.from(queueMap.entries()).map(([id, data]) => ({
|
queueCounts: Array.from(queueMap.entries()).map(([id, data]) => ({
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export default defineSchema({
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
slug: v.string(),
|
slug: v.string(),
|
||||||
|
isAvulso: v.optional(v.boolean()),
|
||||||
cnpj: v.optional(v.string()),
|
cnpj: v.optional(v.string()),
|
||||||
domain: v.optional(v.string()),
|
domain: v.optional(v.string()),
|
||||||
phone: v.optional(v.string()),
|
phone: v.optional(v.string()),
|
||||||
|
|
@ -91,6 +92,8 @@ export default defineSchema({
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
totalWorkedMs: v.optional(v.number()),
|
totalWorkedMs: v.optional(v.number()),
|
||||||
|
internalWorkedMs: v.optional(v.number()),
|
||||||
|
externalWorkedMs: v.optional(v.number()),
|
||||||
activeSessionId: v.optional(v.id("ticketWorkSessions")),
|
activeSessionId: v.optional(v.id("ticketWorkSessions")),
|
||||||
})
|
})
|
||||||
.index("by_tenant_status", ["tenantId", "status"])
|
.index("by_tenant_status", ["tenantId", "status"])
|
||||||
|
|
@ -141,6 +144,7 @@ export default defineSchema({
|
||||||
ticketWorkSessions: defineTable({
|
ticketWorkSessions: defineTable({
|
||||||
ticketId: v.id("tickets"),
|
ticketId: v.id("tickets"),
|
||||||
agentId: v.id("users"),
|
agentId: v.id("users"),
|
||||||
|
workType: v.optional(v.string()), // INTERNAL | EXTERNAL
|
||||||
startedAt: v.number(),
|
startedAt: v.number(),
|
||||||
stoppedAt: v.optional(v.number()),
|
stoppedAt: v.optional(v.number()),
|
||||||
durationMs: v.optional(v.number()),
|
durationMs: v.optional(v.number()),
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ export const seedDemo = mutation({
|
||||||
|
|
||||||
function defaultAvatar(name: string, email: string, role: string) {
|
function defaultAvatar(name: string, email: string, role: string) {
|
||||||
const normalizedRole = role.toUpperCase();
|
const normalizedRole = role.toUpperCase();
|
||||||
if (normalizedRole === "CUSTOMER" || normalizedRole === "MANAGER") {
|
if (normalizedRole === "MANAGER") {
|
||||||
return `https://i.pravatar.cc/150?u=${encodeURIComponent(email)}`;
|
return `https://i.pravatar.cc/150?u=${encodeURIComponent(email)}`;
|
||||||
}
|
}
|
||||||
const first = name.split(" ")[0] ?? email;
|
const first = name.split(" ")[0] ?? email;
|
||||||
|
|
@ -130,7 +130,7 @@ export const seedDemo = mutation({
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
}): Promise<Id<"users">> {
|
}): Promise<Id<"users">> {
|
||||||
const normalizedEmail = params.email.trim().toLowerCase();
|
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 desiredAvatar = params.avatarUrl ?? defaultAvatar(params.name, normalizedEmail, normalizedRole);
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query("users")
|
.query("users")
|
||||||
|
|
@ -139,7 +139,7 @@ export const seedDemo = mutation({
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
if (existing.name !== params.name) updates.name = params.name;
|
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.avatarUrl ?? undefined) !== desiredAvatar) updates.avatarUrl = desiredAvatar;
|
||||||
if ((existing.companyId ?? undefined) !== (params.companyId ?? undefined)) updates.companyId = params.companyId ?? undefined;
|
if ((existing.companyId ?? undefined) !== (params.companyId ?? undefined)) updates.companyId = params.companyId ?? undefined;
|
||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
|
|
@ -217,13 +217,13 @@ export const seedDemo = mutation({
|
||||||
const joaoAtlasId = await ensureUser({
|
const joaoAtlasId = await ensureUser({
|
||||||
name: "João Pedro Ramos",
|
name: "João Pedro Ramos",
|
||||||
email: "joao.ramos@atlasengenharia.com.br",
|
email: "joao.ramos@atlasengenharia.com.br",
|
||||||
role: "CUSTOMER",
|
role: "MANAGER",
|
||||||
companyId: atlasCompanyId,
|
companyId: atlasCompanyId,
|
||||||
});
|
});
|
||||||
await ensureUser({
|
await ensureUser({
|
||||||
name: "Aline Rezende",
|
name: "Aline Rezende",
|
||||||
email: "aline.rezende@atlasengenharia.com.br",
|
email: "aline.rezende@atlasengenharia.com.br",
|
||||||
role: "CUSTOMER",
|
role: "MANAGER",
|
||||||
companyId: atlasCompanyId,
|
companyId: atlasCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -237,19 +237,19 @@ export const seedDemo = mutation({
|
||||||
const ricardoOmniId = await ensureUser({
|
const ricardoOmniId = await ensureUser({
|
||||||
name: "Ricardo Matos",
|
name: "Ricardo Matos",
|
||||||
email: "ricardo.matos@omnisaude.com.br",
|
email: "ricardo.matos@omnisaude.com.br",
|
||||||
role: "CUSTOMER",
|
role: "MANAGER",
|
||||||
companyId: omniCompanyId,
|
companyId: omniCompanyId,
|
||||||
});
|
});
|
||||||
await ensureUser({
|
await ensureUser({
|
||||||
name: "Luciana Prado",
|
name: "Luciana Prado",
|
||||||
email: "luciana.prado@omnisaude.com.br",
|
email: "luciana.prado@omnisaude.com.br",
|
||||||
role: "CUSTOMER",
|
role: "MANAGER",
|
||||||
companyId: omniCompanyId,
|
companyId: omniCompanyId,
|
||||||
});
|
});
|
||||||
const clienteDemoId = await ensureUser({
|
const clienteDemoId = await ensureUser({
|
||||||
name: "Cliente Demo",
|
name: "Cliente Demo",
|
||||||
email: "cliente.demo@sistema.dev",
|
email: "cliente.demo@sistema.dev",
|
||||||
role: "CUSTOMER",
|
role: "MANAGER",
|
||||||
companyId: omniCompanyId,
|
companyId: omniCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { MutationCtx, QueryCtx } 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 { requireCustomer, requireStaff, requireUser } from "./rbac";
|
import { requireStaff, requireUser } from "./rbac";
|
||||||
|
|
||||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
|
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
|
||||||
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]);
|
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]);
|
||||||
|
|
@ -13,14 +13,13 @@ const PAUSE_REASON_LABELS: Record<string, string> = {
|
||||||
IN_PROCEDURE: "Em procedimento",
|
IN_PROCEDURE: "Em procedimento",
|
||||||
};
|
};
|
||||||
|
|
||||||
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED";
|
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
|
||||||
|
|
||||||
const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
|
const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
|
||||||
PENDING: "Pendente",
|
PENDING: "Pendente",
|
||||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||||
PAUSED: "Pausado",
|
PAUSED: "Pausado",
|
||||||
RESOLVED: "Resolvido",
|
RESOLVED: "Resolvido",
|
||||||
CLOSED: "Fechado",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
|
const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
|
||||||
|
|
@ -31,7 +30,7 @@ const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
|
||||||
ON_HOLD: "PAUSED",
|
ON_HOLD: "PAUSED",
|
||||||
PAUSED: "PAUSED",
|
PAUSED: "PAUSED",
|
||||||
RESOLVED: "RESOLVED",
|
RESOLVED: "RESOLVED",
|
||||||
CLOSED: "CLOSED",
|
CLOSED: "RESOLVED",
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
||||||
|
|
@ -277,9 +276,7 @@ export const list = query({
|
||||||
}
|
}
|
||||||
let filtered = base;
|
let filtered = base;
|
||||||
|
|
||||||
if (role === "CUSTOMER") {
|
if (role === "MANAGER") {
|
||||||
filtered = filtered.filter((t) => t.requesterId === args.viewerId);
|
|
||||||
} else if (role === "MANAGER") {
|
|
||||||
if (!user.companyId) {
|
if (!user.companyId) {
|
||||||
throw new ConvexError("Gestor não possui empresa vinculada")
|
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 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 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 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 queueName = normalizeQueueName(queue);
|
||||||
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
|
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
|
||||||
let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null;
|
let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null;
|
||||||
|
|
@ -342,6 +340,7 @@ export const list = query({
|
||||||
priority: t.priority,
|
priority: t.priority,
|
||||||
channel: t.channel,
|
channel: t.channel,
|
||||||
queue: queueName,
|
queue: queueName,
|
||||||
|
company: company ? { id: company._id, name: company.name, isAvulso: (company as any).isAvulso ?? false } : null,
|
||||||
requester: requester && {
|
requester: requester && {
|
||||||
id: requester._id,
|
id: requester._id,
|
||||||
name: requester.name,
|
name: requester.name,
|
||||||
|
|
@ -371,11 +370,14 @@ export const list = query({
|
||||||
subcategory: subcategorySummary,
|
subcategory: subcategorySummary,
|
||||||
workSummary: {
|
workSummary: {
|
||||||
totalWorkedMs: t.totalWorkedMs ?? 0,
|
totalWorkedMs: t.totalWorkedMs ?? 0,
|
||||||
|
internalWorkedMs: (t as any).internalWorkedMs ?? 0,
|
||||||
|
externalWorkedMs: (t as any).externalWorkedMs ?? 0,
|
||||||
activeSession: activeSession
|
activeSession: activeSession
|
||||||
? {
|
? {
|
||||||
id: activeSession._id,
|
id: activeSession._id,
|
||||||
agentId: activeSession.agentId,
|
agentId: activeSession.agentId,
|
||||||
startedAt: activeSession.startedAt,
|
startedAt: activeSession.startedAt,
|
||||||
|
workType: (activeSession as any).workType ?? "INTERNAL",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
|
|
@ -393,9 +395,7 @@ export const getById = query({
|
||||||
const { user, role } = await requireUser(ctx, viewerId, tenantId)
|
const { user, role } = await requireUser(ctx, viewerId, tenantId)
|
||||||
const t = await ctx.db.get(id);
|
const t = await ctx.db.get(id);
|
||||||
if (!t || t.tenantId !== tenantId) return null;
|
if (!t || t.tenantId !== tenantId) return null;
|
||||||
if (role === "CUSTOMER" && t.requesterId !== viewerId) {
|
// no customer role; managers are constrained to company via ensureManagerTicketAccess
|
||||||
throw new ConvexError("Acesso restrito ao solicitante")
|
|
||||||
}
|
|
||||||
let requester: Doc<"users"> | null = null
|
let requester: Doc<"users"> | null = null
|
||||||
if (role === "MANAGER") {
|
if (role === "MANAGER") {
|
||||||
requester = (await ensureManagerTicketAccess(ctx, user, t)) ?? null
|
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 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 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 queueName = normalizeQueueName(queue);
|
||||||
const category = t.categoryId ? await ctx.db.get(t.categoryId) : null;
|
const category = t.categoryId ? await ctx.db.get(t.categoryId) : null;
|
||||||
const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null;
|
const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null;
|
||||||
|
|
@ -463,6 +464,7 @@ export const getById = query({
|
||||||
priority: t.priority,
|
priority: t.priority,
|
||||||
channel: t.channel,
|
channel: t.channel,
|
||||||
queue: queueName,
|
queue: queueName,
|
||||||
|
company: company ? { id: company._id, name: company.name, isAvulso: (company as any).isAvulso ?? false } : null,
|
||||||
requester: requester && {
|
requester: requester && {
|
||||||
id: requester._id,
|
id: requester._id,
|
||||||
name: requester.name,
|
name: requester.name,
|
||||||
|
|
@ -503,11 +505,14 @@ export const getById = query({
|
||||||
: null,
|
: null,
|
||||||
workSummary: {
|
workSummary: {
|
||||||
totalWorkedMs: t.totalWorkedMs ?? 0,
|
totalWorkedMs: t.totalWorkedMs ?? 0,
|
||||||
|
internalWorkedMs: (t as any).internalWorkedMs ?? 0,
|
||||||
|
externalWorkedMs: (t as any).externalWorkedMs ?? 0,
|
||||||
activeSession: activeSession
|
activeSession: activeSession
|
||||||
? {
|
? {
|
||||||
id: activeSession._id,
|
id: activeSession._id,
|
||||||
agentId: activeSession.agentId,
|
agentId: activeSession.agentId,
|
||||||
startedAt: activeSession.startedAt,
|
startedAt: activeSession.startedAt,
|
||||||
|
workType: (activeSession as any).workType ?? "INTERNAL",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
|
|
@ -557,9 +562,7 @@ export const create = mutation({
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId)
|
const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId)
|
||||||
if (role === "CUSTOMER" && args.requesterId !== args.actorId) {
|
// no customer role; managers validated below
|
||||||
throw new ConvexError("Clientes só podem abrir chamados para si mesmos")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.assigneeId && (!role || !INTERNAL_STAFF_ROLES.has(role))) {
|
if (args.assigneeId && (!role || !INTERNAL_STAFF_ROLES.has(role))) {
|
||||||
throw new ConvexError("Somente a equipe interna pode definir o responsável")
|
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 (ticketDoc.requesterId === args.authorId) {
|
||||||
if (normalizedRole === "CUSTOMER") {
|
// requester commenting: managers restricted to PUBLIC (handled above);
|
||||||
await requireCustomer(ctx, args.authorId, ticketDoc.tenantId)
|
// internal staff require staff permission
|
||||||
if (args.visibility !== "PUBLIC") {
|
if (STAFF_ROLES.has(normalizedRole)) {
|
||||||
throw new ConvexError("Clientes só podem registrar comentários públicos")
|
|
||||||
}
|
|
||||||
} else if (STAFF_ROLES.has(normalizedRole)) {
|
|
||||||
await requireTicketStaff(ctx, args.authorId, ticketDoc)
|
await requireTicketStaff(ctx, args.authorId, ticketDoc)
|
||||||
} else {
|
} else {
|
||||||
throw new ConvexError("Autor não possui permissão para comentar")
|
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()
|
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
||||||
if (ticketDoc.requesterId === actorId) {
|
if (ticketDoc.requesterId === actorId) {
|
||||||
if (normalizedRole === "CUSTOMER") {
|
if (STAFF_ROLES.has(normalizedRole)) {
|
||||||
await requireCustomer(ctx, actorId, ticketDoc.tenantId)
|
|
||||||
} else if (STAFF_ROLES.has(normalizedRole)) {
|
|
||||||
await requireTicketStaff(ctx, actorId, ticketDoc)
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
} else {
|
} else {
|
||||||
throw new ConvexError("Autor não possui permissão para editar")
|
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()
|
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
||||||
if (ticketDoc.requesterId === actorId) {
|
if (ticketDoc.requesterId === actorId) {
|
||||||
if (normalizedRole === "CUSTOMER") {
|
if (STAFF_ROLES.has(normalizedRole)) {
|
||||||
await requireCustomer(ctx, actorId, ticketDoc.tenantId)
|
|
||||||
} else if (STAFF_ROLES.has(normalizedRole)) {
|
|
||||||
await requireTicketStaff(ctx, actorId, ticketDoc)
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
} else {
|
} else {
|
||||||
throw new ConvexError("Autor não possui permissão para alterar anexos")
|
throw new ConvexError("Autor não possui permissão para alterar anexos")
|
||||||
|
|
@ -1051,11 +1047,14 @@ export const workSummary = query({
|
||||||
return {
|
return {
|
||||||
ticketId,
|
ticketId,
|
||||||
totalWorkedMs: ticket.totalWorkedMs ?? 0,
|
totalWorkedMs: ticket.totalWorkedMs ?? 0,
|
||||||
|
internalWorkedMs: (ticket as any).internalWorkedMs ?? 0,
|
||||||
|
externalWorkedMs: (ticket as any).externalWorkedMs ?? 0,
|
||||||
activeSession: activeSession
|
activeSession: activeSession
|
||||||
? {
|
? {
|
||||||
id: activeSession._id,
|
id: activeSession._id,
|
||||||
agentId: activeSession.agentId,
|
agentId: activeSession.agentId,
|
||||||
startedAt: activeSession.startedAt,
|
startedAt: activeSession.startedAt,
|
||||||
|
workType: (activeSession as any).workType ?? "INTERNAL",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}
|
}
|
||||||
|
|
@ -1083,8 +1082,8 @@ export const updatePriority = mutation({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const startWork = mutation({
|
export const startWork = mutation({
|
||||||
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
|
args: { ticketId: v.id("tickets"), actorId: v.id("users"), workType: v.optional(v.string()) },
|
||||||
handler: async (ctx, { ticketId, actorId }) => {
|
handler: async (ctx, { ticketId, actorId, workType }) => {
|
||||||
const ticket = await ctx.db.get(ticketId)
|
const ticket = await ctx.db.get(ticketId)
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
throw new ConvexError("Ticket não encontrado")
|
throw new ConvexError("Ticket não encontrado")
|
||||||
|
|
@ -1098,6 +1097,7 @@ export const startWork = mutation({
|
||||||
const sessionId = await ctx.db.insert("ticketWorkSessions", {
|
const sessionId = await ctx.db.insert("ticketWorkSessions", {
|
||||||
ticketId,
|
ticketId,
|
||||||
agentId: actorId,
|
agentId: actorId,
|
||||||
|
workType: (workType ?? "INTERNAL").toUpperCase(),
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1111,7 +1111,7 @@ export const startWork = mutation({
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId,
|
ticketId,
|
||||||
type: "WORK_STARTED",
|
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,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1156,10 +1156,16 @@ export const pauseWork = mutation({
|
||||||
pauseNote: note ?? "",
|
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, {
|
await ctx.db.patch(ticketId, {
|
||||||
working: false,
|
working: false,
|
||||||
activeSessionId: undefined,
|
activeSessionId: undefined,
|
||||||
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
|
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
|
||||||
|
internalWorkedMs: ((ticket as any).internalWorkedMs ?? 0) + deltaInternal,
|
||||||
|
externalWorkedMs: ((ticket as any).externalWorkedMs ?? 0) + deltaExternal,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1173,6 +1179,7 @@ export const pauseWork = mutation({
|
||||||
actorAvatar: actor?.avatarUrl,
|
actorAvatar: actor?.avatarUrl,
|
||||||
sessionId: session._id,
|
sessionId: session._id,
|
||||||
sessionDurationMs: durationMs,
|
sessionDurationMs: durationMs,
|
||||||
|
workType: sessionType,
|
||||||
pauseReason: reason,
|
pauseReason: reason,
|
||||||
pauseReasonLabel: PAUSE_REASON_LABELS[reason],
|
pauseReasonLabel: PAUSE_REASON_LABELS[reason],
|
||||||
pauseNote: note ?? "",
|
pauseNote: note ?? "",
|
||||||
|
|
@ -1256,7 +1263,7 @@ export const playNext = mutation({
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates = candidates.filter(
|
candidates = candidates.filter(
|
||||||
(t) => t.status !== "RESOLVED" && t.status !== "CLOSED" && !t.assigneeId
|
(t) => t.status !== "RESOLVED" && !t.assigneeId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (candidates.length === 0) return null;
|
if (candidates.length === 0) return null;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from "next/server"
|
||||||
import { getCookieCache } from "better-auth/cookies"
|
import { getCookieCache } from "better-auth/cookies"
|
||||||
|
|
||||||
const PUBLIC_PATHS = [/^\/login$/, /^\/api\/auth/, /^\/_next\//, /^\/favicon/]
|
const PUBLIC_PATHS = [/^\/login$/, /^\/api\/auth/, /^\/_next\//, /^\/favicon/]
|
||||||
const CUSTOMER_ALLOWED_PATHS = [/^\/portal(?:$|\/)/, /^\/api\/auth/, /^\/_next\//, /^\/favicon/]
|
|
||||||
const ADMIN_ONLY_PATHS = [/^\/admin(?:$|\/)/]
|
const ADMIN_ONLY_PATHS = [/^\/admin(?:$|\/)/]
|
||||||
const PORTAL_HOME = "/portal"
|
const PORTAL_HOME = "/portal"
|
||||||
const APP_HOME = "/dashboard"
|
const APP_HOME = "/dashboard"
|
||||||
|
|
@ -24,21 +23,9 @@ export async function middleware(request: NextRequest) {
|
||||||
|
|
||||||
const role = (session.user as { role?: string })?.role?.toLowerCase() ?? "agent"
|
const role = (session.user as { role?: string })?.role?.toLowerCase() ?? "agent"
|
||||||
|
|
||||||
if (role === "customer") {
|
const isAdmin = role === "admin"
|
||||||
const canAccess = CUSTOMER_ALLOWED_PATHS.some((pattern) => pattern.test(pathname))
|
if (!isAdmin && ADMIN_ONLY_PATHS.some((pattern) => pattern.test(pathname))) {
|
||||||
if (!canAccess) {
|
return NextResponse.redirect(new URL(APP_HOME, request.url))
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
|
|
@ -48,4 +35,3 @@ export const config = {
|
||||||
runtime: "nodejs",
|
runtime: "nodejs",
|
||||||
matcher: ["/(.*)"],
|
matcher: ["/(.*)"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ enum UserRole {
|
||||||
MANAGER
|
MANAGER
|
||||||
AGENT
|
AGENT
|
||||||
COLLABORATOR
|
COLLABORATOR
|
||||||
CUSTOMER
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TicketStatus {
|
enum TicketStatus {
|
||||||
|
|
@ -20,7 +19,6 @@ enum TicketStatus {
|
||||||
AWAITING_ATTENDANCE
|
AWAITING_ATTENDANCE
|
||||||
PAUSED
|
PAUSED
|
||||||
RESOLVED
|
RESOLVED
|
||||||
CLOSED
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TicketPriority {
|
enum TicketPriority {
|
||||||
|
|
@ -74,6 +72,7 @@ model Company {
|
||||||
tenantId String
|
tenantId String
|
||||||
name String
|
name String
|
||||||
slug String
|
slug String
|
||||||
|
isAvulso Boolean @default(false)
|
||||||
cnpj String?
|
cnpj String?
|
||||||
domain String?
|
domain String?
|
||||||
phone String?
|
phone String?
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ if (!secret) {
|
||||||
process.exit(1)
|
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)
|
const client = new ConvexHttpClient(convexUrl)
|
||||||
|
|
||||||
|
|
@ -61,7 +61,7 @@ const STATUS_MAP = {
|
||||||
ON_HOLD: "PAUSED",
|
ON_HOLD: "PAUSED",
|
||||||
PAUSED: "PAUSED",
|
PAUSED: "PAUSED",
|
||||||
RESOLVED: "RESOLVED",
|
RESOLVED: "RESOLVED",
|
||||||
CLOSED: "CLOSED",
|
CLOSED: "RESOLVED",
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatus(status) {
|
function normalizeStatus(status) {
|
||||||
|
|
@ -84,6 +84,7 @@ async function upsertCompanies(snapshotCompanies) {
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
name: company.name,
|
name: company.name,
|
||||||
|
isAvulso: Boolean(company.isAvulso ?? false),
|
||||||
cnpj: company.cnpj ?? null,
|
cnpj: company.cnpj ?? null,
|
||||||
domain: company.domain ?? null,
|
domain: company.domain ?? null,
|
||||||
phone: company.phone ?? null,
|
phone: company.phone ?? null,
|
||||||
|
|
@ -94,6 +95,7 @@ async function upsertCompanies(snapshotCompanies) {
|
||||||
tenantId,
|
tenantId,
|
||||||
name: company.name,
|
name: company.name,
|
||||||
slug,
|
slug,
|
||||||
|
isAvulso: Boolean(company.isAvulso ?? false),
|
||||||
cnpj: company.cnpj ?? null,
|
cnpj: company.cnpj ?? null,
|
||||||
domain: company.domain ?? null,
|
domain: company.domain ?? null,
|
||||||
phone: company.phone ?? null,
|
phone: company.phone ?? null,
|
||||||
|
|
@ -117,8 +119,8 @@ async function upsertUsers(snapshotUsers, companyMap) {
|
||||||
const normalizedEmail = normalizeEmail(user.email)
|
const normalizedEmail = normalizeEmail(user.email)
|
||||||
if (!normalizedEmail) continue
|
if (!normalizedEmail) continue
|
||||||
|
|
||||||
const normalizedRole = (user.role ?? "CUSTOMER").toUpperCase()
|
const normalizedRole = (user.role ?? "MANAGER").toUpperCase()
|
||||||
const role = allowedRoles.has(normalizedRole) ? normalizedRole : "CUSTOMER"
|
const role = allowedRoles.has(normalizedRole) ? normalizedRole : "MANAGER"
|
||||||
const companyId = user.companySlug ? companyMap.get(user.companySlug) ?? null : null
|
const companyId = user.companySlug ? companyMap.get(user.companySlug) ?? null : null
|
||||||
|
|
||||||
const record = await prisma.user.upsert({
|
const record = await prisma.user.upsert({
|
||||||
|
|
@ -195,7 +197,7 @@ async function upsertUsers(snapshotUsers, companyMap) {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: staff.id },
|
where: { id: staff.id },
|
||||||
data: {
|
data: {
|
||||||
role: "CUSTOMER",
|
role: "MANAGER",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ const defaultUsers = singleUserFromEnv ?? [
|
||||||
email: "cliente.demo@sistema.dev",
|
email: "cliente.demo@sistema.dev",
|
||||||
password: "cliente123",
|
password: "cliente123",
|
||||||
name: "Cliente Demo",
|
name: "Cliente Demo",
|
||||||
role: "customer",
|
role: "manager",
|
||||||
tenantId,
|
tenantId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -50,28 +50,28 @@ const defaultUsers = singleUserFromEnv ?? [
|
||||||
email: "joao.ramos@atlasengenharia.com.br",
|
email: "joao.ramos@atlasengenharia.com.br",
|
||||||
password: "cliente123",
|
password: "cliente123",
|
||||||
name: "João Pedro Ramos",
|
name: "João Pedro Ramos",
|
||||||
role: "customer",
|
role: "manager",
|
||||||
tenantId,
|
tenantId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: "aline.rezende@atlasengenharia.com.br",
|
email: "aline.rezende@atlasengenharia.com.br",
|
||||||
password: "cliente123",
|
password: "cliente123",
|
||||||
name: "Aline Rezende",
|
name: "Aline Rezende",
|
||||||
role: "customer",
|
role: "manager",
|
||||||
tenantId,
|
tenantId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: "ricardo.matos@omnisaude.com.br",
|
email: "ricardo.matos@omnisaude.com.br",
|
||||||
password: "cliente123",
|
password: "cliente123",
|
||||||
name: "Ricardo Matos",
|
name: "Ricardo Matos",
|
||||||
role: "customer",
|
role: "manager",
|
||||||
tenantId,
|
tenantId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: "luciana.prado@omnisaude.com.br",
|
email: "luciana.prado@omnisaude.com.br",
|
||||||
password: "cliente123",
|
password: "cliente123",
|
||||||
name: "Luciana Prado",
|
name: "Luciana Prado",
|
||||||
role: "customer",
|
role: "manager",
|
||||||
tenantId,
|
tenantId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
26
src/app/admin/companies/page.tsx
Normal file
26
src/app/admin/companies/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<AppShell
|
||||||
|
header={
|
||||||
|
<SiteHeader
|
||||||
|
title="Empresas & Clientes"
|
||||||
|
lead="Gerencie os dados das empresas e controle o faturamento de clientes avulsos."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
|
||||||
|
<AdminCompaniesManager initialCompanies={companies} />
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
src/app/api/admin/companies/[id]/route.ts
Normal file
27
src/app/api/admin/companies/[id]/route.ts
Normal file
|
|
@ -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<string, any> = {}
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/api/admin/companies/route.ts
Normal file
47
src/app/api/admin/companies/route.ts
Normal file
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/admin/users/assign-company/route.ts
Normal file
37
src/app/api/admin/users/assign-company/route.ts
Normal file
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
113
src/app/api/reports/backlog.csv/route.ts
Normal file
113
src/app/api/reports/backlog.csv/route.ts
Normal file
|
|
@ -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<Array<unknown>>): 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<Array<unknown>> = []
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/app/api/reports/csat.csv/route.ts
Normal file
99
src/app/api/reports/csat.csv/route.ts
Normal file
|
|
@ -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<Array<unknown>>): 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<Array<unknown>> = []
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
101
src/app/api/reports/sla.csv/route.ts
Normal file
101
src/app/api/reports/sla.csv/route.ts
Normal file
|
|
@ -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<Array<unknown>>): 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<Array<unknown>> = []
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
102
src/app/api/reports/tickets-by-channel.csv/route.ts
Normal file
102
src/app/api/reports/tickets-by-channel.csv/route.ts
Normal file
|
|
@ -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<Array<unknown>>): 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<string, string> = {
|
||||||
|
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<Array<unknown>> = []
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { NextResponse } from "next/server"
|
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 { format } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { ConvexHttpClient } from "convex/browser"
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
@ -7,16 +11,44 @@ import { ConvexHttpClient } from "convex/browser"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
import { env } from "@/lib/env"
|
import { env } from "@/lib/env"
|
||||||
import { assertStaffSession } from "@/lib/auth-server"
|
import { assertAuthenticatedSession } from "@/lib/auth-server"
|
||||||
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"
|
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
|
||||||
|
// Force Node.js runtime for pdfkit compatibility
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
const statusLabel: Record<string, string> = {
|
||||||
PENDING: "Pendente",
|
PENDING: "Pendente",
|
||||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||||
PAUSED: "Pausado",
|
PAUSED: "Pausado",
|
||||||
RESOLVED: "Resolvido",
|
RESOLVED: "Resolvido",
|
||||||
CLOSED: "Fechado",
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
PENDING: "#64748B", // slate-500
|
||||||
|
AWAITING_ATTENDANCE: "#0EA5E9", // sky-500
|
||||||
|
PAUSED: "#F59E0B", // amber-500
|
||||||
|
RESOLVED: "#10B981", // emerald-500
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityLabel: Record<string, string> = {
|
||||||
|
LOW: "Baixa",
|
||||||
|
MEDIUM: "Média",
|
||||||
|
HIGH: "Alta",
|
||||||
|
URGENT: "Urgente",
|
||||||
|
CRITICAL: "Crítica",
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelLabel: Record<string, string> = {
|
||||||
|
EMAIL: "E-mail",
|
||||||
|
PHONE: "Telefone",
|
||||||
|
CHAT: "Chat",
|
||||||
|
PORTAL: "Portal",
|
||||||
|
WEB: "Portal",
|
||||||
|
API: "API",
|
||||||
|
SOCIAL: "Redes sociais",
|
||||||
|
OTHER: "Outro",
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineLabel: Record<string, string> = {
|
const timelineLabel: Record<string, string> = {
|
||||||
|
|
@ -31,6 +63,12 @@ const timelineLabel: Record<string, string> = {
|
||||||
WORK_STARTED: "Atendimento iniciado",
|
WORK_STARTED: "Atendimento iniciado",
|
||||||
WORK_PAUSED: "Atendimento pausado",
|
WORK_PAUSED: "Atendimento pausado",
|
||||||
CATEGORY_CHANGED: "Categoria alterada",
|
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) {
|
function formatDateTime(date: Date | null | undefined) {
|
||||||
|
|
@ -57,9 +95,104 @@ function decodeHtmlEntities(input: string) {
|
||||||
.replace(/ /g, " ")
|
.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<string, unknown>).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 }> }) {
|
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id: ticketId } = await context.params
|
const { id: ticketId } = await context.params
|
||||||
const session = await assertStaffSession()
|
const session = await assertAuthenticatedSession()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
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 ticket = mapTicketWithDetailsFromServer(ticketRaw)
|
||||||
const doc = new PDFDocument({ size: "A4", margin: 48 })
|
const doc = new PDFDocument({ size: "A4", margin: 56 })
|
||||||
const chunks: Buffer[] = []
|
const chunks: Buffer[] = []
|
||||||
|
|
||||||
doc.on("data", (chunk) => {
|
doc.on("data", (chunk: any) => {
|
||||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
|
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)
|
doc.on("error", reject)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Título
|
||||||
doc.font("Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference} — ${ticket.subject}`)
|
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
|
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")
|
.font("Helvetica")
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
.text(`Status: ${statusLabel[ticket.status] ?? ticket.status}`)
|
.text(`Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`, { lineGap: 2 })
|
||||||
.moveDown(0.15)
|
.moveDown(0.15)
|
||||||
.text(`Prioridade: ${ticket.priority}`)
|
.text(`Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`, { lineGap: 2 })
|
||||||
.moveDown(0.15)
|
.moveDown(0.15)
|
||||||
.text(`Canal: ${ticket.channel}`)
|
.text(`Fila: ${ticket.queue ?? "—"}`, { lineGap: 2 })
|
||||||
.moveDown(0.15)
|
|
||||||
.text(`Fila: ${ticket.queue ?? "—"}`)
|
|
||||||
|
|
||||||
doc.moveDown(0.75)
|
doc.moveDown(0.75)
|
||||||
doc
|
doc
|
||||||
.font("Helvetica-Bold")
|
.font("Helvetica-Bold")
|
||||||
.fontSize(12)
|
.fontSize(12)
|
||||||
.text("Solicitante")
|
.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
|
doc
|
||||||
.font("Helvetica")
|
.font("Helvetica")
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
|
|
@ -148,6 +315,12 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
||||||
|
|
||||||
doc.moveDown(0.5)
|
doc.moveDown(0.5)
|
||||||
doc.font("Helvetica-Bold").fontSize(12).text("Responsável")
|
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
|
doc
|
||||||
.font("Helvetica")
|
.font("Helvetica")
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
|
|
@ -155,6 +328,12 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
||||||
|
|
||||||
doc.moveDown(0.75)
|
doc.moveDown(0.75)
|
||||||
doc.font("Helvetica-Bold").fontSize(12).text("Datas")
|
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
|
doc
|
||||||
.font("Helvetica")
|
.font("Helvetica")
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
|
|
@ -167,25 +346,35 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
||||||
if (ticket.summary) {
|
if (ticket.summary) {
|
||||||
doc.moveDown(0.75)
|
doc.moveDown(0.75)
|
||||||
doc.font("Helvetica-Bold").fontSize(12).text("Resumo")
|
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
|
doc
|
||||||
.font("Helvetica")
|
.font("Helvetica")
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
.text(ticket.summary, { align: "justify" })
|
.text(ticket.summary, { align: "justify", lineGap: 2 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.description) {
|
if (ticket.description) {
|
||||||
doc.moveDown(0.75)
|
doc.moveDown(0.75)
|
||||||
doc.font("Helvetica-Bold").fontSize(12).text("Descrição")
|
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
|
doc
|
||||||
.font("Helvetica")
|
.font("Helvetica")
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
.text(htmlToPlainText(ticket.description), { align: "justify" })
|
.text(htmlToPlainText(ticket.description), { align: "justify", lineGap: 2 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.comments.length > 0) {
|
if (ticket.comments.length > 0) {
|
||||||
doc.addPage()
|
doc.addPage()
|
||||||
doc.font("Helvetica-Bold").fontSize(14).text("Comentários")
|
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())
|
const commentsSorted = [...ticket.comments].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||||
commentsSorted.forEach((comment, index) => {
|
commentsSorted.forEach((comment, index) => {
|
||||||
const visibility =
|
const visibility =
|
||||||
|
|
@ -193,15 +382,14 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
||||||
doc
|
doc
|
||||||
.font("Helvetica-Bold")
|
.font("Helvetica-Bold")
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
.text(
|
.text(`${comment.author.name} • ${visibility} • ${formatDateTime(comment.createdAt)}`)
|
||||||
`${comment.author.name} • ${visibility} • ${formatDateTime(comment.createdAt)}`
|
doc.moveDown(0.15)
|
||||||
)
|
|
||||||
const body = htmlToPlainText(comment.body)
|
const body = htmlToPlainText(comment.body)
|
||||||
if (body) {
|
if (body) {
|
||||||
doc
|
doc
|
||||||
.font("Helvetica")
|
.font("Helvetica")
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
.text(body, { align: "justify" })
|
.text(body, { align: "justify", lineGap: 2, indent: 6 })
|
||||||
}
|
}
|
||||||
if (comment.attachments.length > 0) {
|
if (comment.attachments.length > 0) {
|
||||||
doc.moveDown(0.25)
|
doc.moveDown(0.25)
|
||||||
|
|
@ -210,17 +398,17 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
|
||||||
doc
|
doc
|
||||||
.font("Helvetica")
|
.font("Helvetica")
|
||||||
.fontSize(10)
|
.fontSize(10)
|
||||||
.text(`• ${attachment.name ?? attachment.id}`, { indent: 12 })
|
.text(`• ${attachment.name ?? attachment.id}`, { indent: 16, lineGap: 1 })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (index < commentsSorted.length - 1) {
|
if (index < commentsSorted.length - 1) {
|
||||||
doc.moveDown(0.75)
|
doc.moveDown(1)
|
||||||
doc
|
doc
|
||||||
.strokeColor("#E2E8F0")
|
.strokeColor("#E2E8F0")
|
||||||
.moveTo(doc.x, doc.y)
|
.moveTo(doc.x, doc.y)
|
||||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||||
.stroke()
|
.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) {
|
if (ticket.timeline.length > 0) {
|
||||||
doc.addPage()
|
doc.addPage()
|
||||||
doc.font("Helvetica-Bold").fontSize(14).text("Linha do tempo")
|
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())
|
const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||||
timelineSorted.forEach((event) => {
|
timelineSorted.forEach((event) => {
|
||||||
const label = timelineLabel[event.type] ?? event.type
|
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")
|
.font("Helvetica-Bold")
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
.text(`${label} • ${formatDateTime(event.createdAt)}`)
|
.text(`${label} • ${formatDateTime(event.createdAt)}`)
|
||||||
if (event.payload) {
|
doc.moveDown(0.15)
|
||||||
const payloadText = JSON.stringify(event.payload, null, 2)
|
|
||||||
|
const friendly = buildTimelineMessage(event.type, event.payload)
|
||||||
|
if (friendly) {
|
||||||
doc
|
doc
|
||||||
.font("Helvetica")
|
.font("Helvetica")
|
||||||
.fontSize(10)
|
.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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ export default function ReportsCsatPage() {
|
||||||
<SiteHeader
|
<SiteHeader
|
||||||
title="Relatório de CSAT"
|
title="Relatório de CSAT"
|
||||||
lead="Visualize a satisfação dos clientes e identifique pontos de melhoria na entrega."
|
lead="Visualize a satisfação dos clientes e identifique pontos de melhoria na entrega."
|
||||||
|
secondaryAction={
|
||||||
|
<SiteHeader.SecondaryButton asChild>
|
||||||
|
<a href="/api/reports/csat.csv" download>
|
||||||
|
Exportar CSV
|
||||||
|
</a>
|
||||||
|
</SiteHeader.SecondaryButton>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ export default function ReportsSlaPage() {
|
||||||
<SiteHeader
|
<SiteHeader
|
||||||
title="Relatório de SLA"
|
title="Relatório de SLA"
|
||||||
lead="Acompanhe tempos de resposta, resolução e balanço de filas em tempo real."
|
lead="Acompanhe tempos de resposta, resolução e balanço de filas em tempo real."
|
||||||
|
secondaryAction={
|
||||||
|
<SiteHeader.SecondaryButton asChild>
|
||||||
|
<a href="/api/reports/sla.csv" download>
|
||||||
|
Exportar CSV
|
||||||
|
</a>
|
||||||
|
</SiteHeader.SecondaryButton>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,26 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
const [lastInviteLink, setLastInviteLink] = useState<string | null>(null)
|
const [lastInviteLink, setLastInviteLink] = useState<string | null>(null)
|
||||||
const [revokingId, setRevokingId] = useState<string | null>(null)
|
const [revokingId, setRevokingId] = useState<string | null>(null)
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
const [companies, setCompanies] = useState<Array<{ id: string; name: string }>>([])
|
||||||
|
const [linkEmail, setLinkEmail] = useState("")
|
||||||
|
const [linkCompanyId, setLinkCompanyId] = useState("")
|
||||||
|
|
||||||
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
|
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<HTMLFormElement>) {
|
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!email || !email.includes("@")) {
|
if (!email || !email.includes("@")) {
|
||||||
|
|
@ -238,9 +255,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{normalizedRoles.map((item) => (
|
{normalizedRoles.map((item) => (
|
||||||
<SelectItem key={item} value={item}>
|
<SelectItem key={item} value={item}>
|
||||||
{item === "customer"
|
{item === "admin"
|
||||||
? "Cliente"
|
|
||||||
: item === "admin"
|
|
||||||
? "Administrador"
|
? "Administrador"
|
||||||
: item === "manager"
|
: item === "manager"
|
||||||
? "Gestor"
|
? "Gestor"
|
||||||
|
|
@ -294,6 +309,63 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Vincular usuário a empresa</CardTitle>
|
||||||
|
<CardDescription>Associe um colaborador à sua empresa (usado para escopo de gestores e relatórios).</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
className="grid grid-cols-1 gap-4 md:grid-cols-[1fr_1fr_auto]"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
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" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>E-mail do usuário</Label>
|
||||||
|
<Input value={linkEmail} onChange={(e) => setLinkEmail(e.target.value)} placeholder="colaborador@empresa.com" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Empresa</Label>
|
||||||
|
<Select value={linkCompanyId} onValueChange={setLinkCompanyId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecionar" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button type="submit" disabled={isPending}>Vincular</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Convites emitidos</CardTitle>
|
<CardTitle>Convites emitidos</CardTitle>
|
||||||
|
|
|
||||||
213
src/components/admin/companies/admin-companies-manager.tsx
Normal file
213
src/components/admin/companies/admin-companies-manager.tsx
Normal file
|
|
@ -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<Company[]>(initialCompanies)
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
const [form, setForm] = useState<Partial<Company>>({})
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Nova empresa</CardTitle>
|
||||||
|
<CardDescription>Cadastre um cliente/empresa e defina se é avulso.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Nome</Label>
|
||||||
|
<Input value={form.name ?? ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Slug</Label>
|
||||||
|
<Input value={form.slug ?? ""} onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>CNPJ</Label>
|
||||||
|
<Input value={form.cnpj ?? ""} onChange={(e) => setForm((p) => ({ ...p, cnpj: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Domínio</Label>
|
||||||
|
<Input value={form.domain ?? ""} onChange={(e) => setForm((p) => ({ ...p, domain: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Telefone</Label>
|
||||||
|
<Input value={form.phone ?? ""} onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 md:col-span-2">
|
||||||
|
<Label>Endereço</Label>
|
||||||
|
<Input value={form.address ?? ""} onChange={(e) => setForm((p) => ({ ...p, address: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 md:col-span-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={Boolean(form.isAvulso ?? false)}
|
||||||
|
onCheckedChange={(v) => setForm((p) => ({ ...p, isAvulso: Boolean(v) }))}
|
||||||
|
id="is-avulso"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="is-avulso">Cliente avulso?</Label>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Button type="submit" disabled={isPending}>{editingId ? "Salvar alterações" : "Cadastrar empresa"}</Button>
|
||||||
|
{editingId ? (
|
||||||
|
<Button type="button" variant="ghost" className="ml-2" onClick={() => { resetForm(); setEditingId(null) }}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Empresas cadastradas</CardTitle>
|
||||||
|
<CardDescription>Gerencie empresas e o status de cliente avulso.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Nome</TableHead>
|
||||||
|
<TableHead>Slug</TableHead>
|
||||||
|
<TableHead>Avulso</TableHead>
|
||||||
|
<TableHead>Domínio</TableHead>
|
||||||
|
<TableHead>Telefone</TableHead>
|
||||||
|
<TableHead>CNPJ</TableHead>
|
||||||
|
<TableHead>Ações</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<TableRow key={c.id}>
|
||||||
|
<TableCell className="font-medium">{c.name}</TableCell>
|
||||||
|
<TableCell>{c.slug}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void toggleAvulso(c)}>
|
||||||
|
{c.isAvulso ? "Sim" : "Não"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{c.domain ?? "—"}</TableCell>
|
||||||
|
<TableCell>{c.phone ?? "—"}</TableCell>
|
||||||
|
<TableCell>{c.cnpj ?? "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleEdit(c)}>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ import { useAuth } from "@/lib/auth-client"
|
||||||
|
|
||||||
import type { LucideIcon } from "lucide-react"
|
import type { LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
type NavRoleRequirement = "staff" | "admin" | "customer"
|
type NavRoleRequirement = "staff" | "admin"
|
||||||
|
|
||||||
type NavigationItem = {
|
type NavigationItem = {
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -91,6 +91,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
||||||
},
|
},
|
||||||
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
|
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
|
||||||
{ title: "Times & papéis", url: "/admin/teams", icon: Users, 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: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
|
||||||
{ title: "SLAs", url: "/admin/slas", icon: Timer, 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<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { session, isLoading, isAdmin, isStaff, isCustomer } = useAuth()
|
const { session, isLoading, isAdmin, isStaff } = useAuth()
|
||||||
const [isHydrated, setIsHydrated] = React.useState(false)
|
const [isHydrated, setIsHydrated] = React.useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -128,7 +129,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
if (!requiredRole) return true
|
if (!requiredRole) return true
|
||||||
if (requiredRole === "admin") return isAdmin
|
if (requiredRole === "admin") return isAdmin
|
||||||
if (requiredRole === "staff") return isStaff
|
if (requiredRole === "staff") return isStaff
|
||||||
if (requiredRole === "customer") return isCustomer
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
ChartConfig,
|
ChartConfig,
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
|
|
@ -119,6 +120,14 @@ export function ChartAreaInteractive() {
|
||||||
<span className="@[540px]/card:hidden">Período: {timeRange}</span>
|
<span className="@[540px]/card:hidden">Período: {timeRange}</span>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<CardAction>
|
<CardAction>
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<a
|
||||||
|
href={`/api/reports/tickets-by-channel.csv?range=${timeRange}`}
|
||||||
|
download
|
||||||
|
>
|
||||||
|
Exportar CSV
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
type="single"
|
type="single"
|
||||||
value={timeRange}
|
value={timeRange}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ const navItems = [
|
||||||
export function PortalShell({ children }: PortalShellProps) {
|
export function PortalShell({ children }: PortalShellProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { session, isCustomer } = useAuth()
|
const { session } = useAuth()
|
||||||
const [isSigningOut, setIsSigningOut] = useState(false)
|
const [isSigningOut, setIsSigningOut] = useState(false)
|
||||||
|
|
||||||
const initials = useMemo(() => {
|
const initials = useMemo(() => {
|
||||||
|
|
@ -107,11 +107,7 @@ export function PortalShell({ children }: PortalShellProps) {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-6 py-8">
|
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-6 py-8">
|
||||||
{!isCustomer ? (
|
{null}
|
||||||
<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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<footer className="border-t border-slate-200 bg-white/70">
|
<footer className="border-t border-slate-200 bg-white/70">
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ const statusLabel: Record<Ticket["status"], string> = {
|
||||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||||
PAUSED: "Pausado",
|
PAUSED: "Pausado",
|
||||||
RESOLVED: "Resolvido",
|
RESOLVED: "Resolvido",
|
||||||
CLOSED: "Fechado",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusTone: Record<Ticket["status"], string> = {
|
const statusTone: Record<Ticket["status"], string> = {
|
||||||
|
|
@ -24,7 +23,6 @@ const statusTone: Record<Ticket["status"], string> = {
|
||||||
AWAITING_ATTENDANCE: "bg-sky-100 text-sky-700",
|
AWAITING_ATTENDANCE: "bg-sky-100 text-sky-700",
|
||||||
PAUSED: "bg-violet-100 text-violet-700",
|
PAUSED: "bg-violet-100 text-violet-700",
|
||||||
RESOLVED: "bg-emerald-100 text-emerald-700",
|
RESOLVED: "bg-emerald-100 text-emerald-700",
|
||||||
CLOSED: "bg-slate-100 text-slate-600",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const priorityLabel: Record<Ticket["priority"], string> = {
|
const priorityLabel: Record<Ticket["priority"], string> = {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ const statusLabel: Record<TicketWithDetails["status"], string> = {
|
||||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||||
PAUSED: "Pausado",
|
PAUSED: "Pausado",
|
||||||
RESOLVED: "Resolvido",
|
RESOLVED: "Resolvido",
|
||||||
CLOSED: "Fechado",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
|
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
import { IconInbox, IconAlertTriangle, IconFilter } from "@tabler/icons-react"
|
import { IconInbox, IconAlertTriangle, IconFilter } from "@tabler/icons-react"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { Card, 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 { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
|
@ -23,15 +25,15 @@ const STATUS_LABELS: Record<string, string> = {
|
||||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||||
PAUSED: "Pausados",
|
PAUSED: "Pausados",
|
||||||
RESOLVED: "Resolvidos",
|
RESOLVED: "Resolvidos",
|
||||||
CLOSED: "Encerrados",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BacklogReport() {
|
export function BacklogReport() {
|
||||||
|
const [timeRange, setTimeRange] = useState("90d")
|
||||||
const { session, convexUserId } = useAuth()
|
const { session, convexUserId } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
const data = useQuery(
|
const data = useQuery(
|
||||||
api.reports.backlogOverview,
|
api.reports.backlogOverview,
|
||||||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange } : "skip"
|
||||||
)
|
)
|
||||||
|
|
||||||
const mostCriticalPriority = useMemo(() => {
|
const mostCriticalPriority = useMemo(() => {
|
||||||
|
|
@ -99,6 +101,24 @@ export function BacklogReport() {
|
||||||
<CardDescription className="text-neutral-600">
|
<CardDescription className="text-neutral-600">
|
||||||
Acompanhe a evolução dos tickets pelas fases do fluxo de atendimento.
|
Acompanhe a evolução dos tickets pelas fases do fluxo de atendimento.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<a href={`/api/reports/backlog.csv?range=${timeRange}`} download>
|
||||||
|
Exportar CSV
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={timeRange}
|
||||||
|
onValueChange={setTimeRange}
|
||||||
|
variant="outline"
|
||||||
|
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ const statusStyles: Record<TicketStatus, { label: string; className: string }> =
|
||||||
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
||||||
PAUSED: { label: "Pausado", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
|
PAUSED: { label: "Pausado", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
|
||||||
RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
|
RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
|
||||||
CLOSED: { label: "Fechado", className: "border border-slate-200 bg-slate-200 text-slate-700" },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TicketStatusBadgeProps = { status: TicketStatus }
|
type TicketStatusBadgeProps = { status: TicketStatus }
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,13 @@ import { ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
|
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
|
||||||
|
|
||||||
const STATUS_OPTIONS: TicketStatus[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED", "CLOSED"];
|
const STATUS_OPTIONS: TicketStatus[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"];
|
||||||
|
|
||||||
const statusStyles: Record<StatusKey, { label: string; badgeClass: string }> = {
|
const statusStyles: Record<StatusKey, { label: string; badgeClass: string }> = {
|
||||||
PENDING: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
PENDING: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
||||||
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
||||||
PAUSED: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
|
PAUSED: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
|
||||||
RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" },
|
RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" },
|
||||||
CLOSED: { label: "Fechado", badgeClass: "bg-slate-200 text-slate-700" },
|
|
||||||
NEW: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
NEW: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
||||||
OPEN: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
OPEN: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
||||||
ON_HOLD: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
|
ON_HOLD: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { format, formatDistanceToNow } from "date-fns"
|
import { format, formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { IconClock, IconFileDownload, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
import { IconClock, IconFileTypePdf, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
@ -24,6 +24,12 @@ import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
||||||
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
interface TicketHeaderProps {
|
interface TicketHeaderProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
|
|
@ -128,6 +134,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
return selectedCategoryId !== currentCategoryId || selectedSubcategoryId !== currentSubcategoryId
|
return selectedCategoryId !== currentCategoryId || selectedSubcategoryId !== currentSubcategoryId
|
||||||
}, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId])
|
}, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId])
|
||||||
const currentQueueName = ticket.queue ?? ""
|
const currentQueueName = ticket.queue ?? ""
|
||||||
|
const isAvulso = Boolean((ticket as any).company?.isAvulso ?? false)
|
||||||
const [queueSelection, setQueueSelection] = useState(currentQueueName)
|
const [queueSelection, setQueueSelection] = useState(currentQueueName)
|
||||||
const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName])
|
const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName])
|
||||||
const formDirty = dirty || categoryDirty || queueDirty
|
const formDirty = dirty || categoryDirty || queueDirty
|
||||||
|
|
@ -263,11 +270,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
return {
|
return {
|
||||||
ticketId: ticket.id as Id<"tickets">,
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
totalWorkedMs: ticket.workSummary.totalWorkedMs,
|
totalWorkedMs: ticket.workSummary.totalWorkedMs,
|
||||||
|
internalWorkedMs: ticket.workSummary.internalWorkedMs ?? 0,
|
||||||
|
externalWorkedMs: ticket.workSummary.externalWorkedMs ?? 0,
|
||||||
activeSession: ticket.workSummary.activeSession
|
activeSession: ticket.workSummary.activeSession
|
||||||
? {
|
? {
|
||||||
id: ticket.workSummary.activeSession.id as Id<"ticketWorkSessions">,
|
id: ticket.workSummary.activeSession.id as Id<"ticketWorkSessions">,
|
||||||
agentId: ticket.workSummary.activeSession.agentId as Id<"users">,
|
agentId: ticket.workSummary.activeSession.agentId as Id<"users">,
|
||||||
startedAt: ticket.workSummary.activeSession.startedAt.getTime(),
|
startedAt: ticket.workSummary.activeSession.startedAt.getTime(),
|
||||||
|
workType: (ticket.workSummary.activeSession as any).workType ?? "INTERNAL",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}
|
}
|
||||||
|
|
@ -294,6 +304,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
|
|
||||||
const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0
|
const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0
|
||||||
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
|
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
|
||||||
|
const internalWorkedMs = workSummary
|
||||||
|
? (((workSummary as any).internalWorkedMs ?? 0) + (((workSummary?.activeSession as any)?.workType === "INTERNAL") ? currentSessionMs : 0))
|
||||||
|
: 0
|
||||||
|
const externalWorkedMs = workSummary
|
||||||
|
? (((workSummary as any).externalWorkedMs ?? 0) + (((workSummary?.activeSession as any)?.workType === "EXTERNAL") ? currentSessionMs : 0))
|
||||||
|
: 0
|
||||||
|
|
||||||
const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs])
|
const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs])
|
||||||
const updatedRelative = useMemo(
|
const updatedRelative = useMemo(
|
||||||
|
|
@ -301,12 +317,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
[ticket.updatedAt]
|
[ticket.updatedAt]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleStartWork = async () => {
|
const handleStartWork = async (workType: "INTERNAL" | "EXTERNAL") => {
|
||||||
if (!convexUserId) return
|
if (!convexUserId) return
|
||||||
toast.dismiss("work")
|
toast.dismiss("work")
|
||||||
toast.loading("Iniciando atendimento...", { id: "work" })
|
toast.loading("Iniciando atendimento...", { id: "work" })
|
||||||
try {
|
try {
|
||||||
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
|
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users">, workType } as any)
|
||||||
if (result?.status === "already_started") {
|
if (result?.status === "already_started") {
|
||||||
toast.info("O atendimento já estava em andamento", { id: "work" })
|
toast.info("O atendimento já estava em andamento", { id: "work" })
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -347,7 +363,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
setExportingPdf(true)
|
setExportingPdf(true)
|
||||||
toast.dismiss("ticket-export")
|
toast.dismiss("ticket-export")
|
||||||
toast.loading("Gerando PDF...", { id: "ticket-export" })
|
toast.loading("Gerando PDF...", { id: "ticket-export" })
|
||||||
const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`)
|
const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`, { credentials: "include" })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`failed: ${response.status}`)
|
throw new Error(`failed: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
@ -373,9 +389,17 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
<div className={cardClass}>
|
<div className={cardClass}>
|
||||||
<div className="absolute right-6 top-6 flex items-center gap-3">
|
<div className="absolute right-6 top-6 flex items-center gap-3">
|
||||||
{workSummary ? (
|
{workSummary ? (
|
||||||
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
|
<>
|
||||||
<IconClock className="size-4 text-neutral-700" /> Tempo total: {formattedTotalWorked}
|
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
|
||||||
</Badge>
|
<IconClock className="size-4 text-neutral-700" /> Interno: {formatDuration(internalWorkedMs)}
|
||||||
|
</Badge>
|
||||||
|
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
|
||||||
|
<IconClock className="size-4 text-neutral-700" /> Externo: {formatDuration(externalWorkedMs)}
|
||||||
|
</Badge>
|
||||||
|
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
|
||||||
|
<IconClock className="size-4 text-neutral-700" /> Total: {formattedTotalWorked}
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{!editing ? (
|
{!editing ? (
|
||||||
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
|
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
|
||||||
|
|
@ -383,45 +407,53 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-800 hover:bg-slate-50"
|
aria-label="Exportar PDF"
|
||||||
|
className="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white text-neutral-800 hover:bg-slate-50"
|
||||||
onClick={handleExportPdf}
|
onClick={handleExportPdf}
|
||||||
disabled={exportingPdf}
|
disabled={exportingPdf}
|
||||||
|
title="Exportar PDF"
|
||||||
>
|
>
|
||||||
{exportingPdf ? <Spinner className="size-3 text-neutral-700" /> : <IconFileDownload className="size-4" />}
|
{exportingPdf ? <Spinner className="size-4 text-neutral-700" /> : <IconFileTypePdf className="size-5" />}
|
||||||
Exportar PDF
|
|
||||||
</Button>
|
</Button>
|
||||||
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
|
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Badge className={referenceBadgeClass}>#{ticket.reference}</Badge>
|
<Badge className={referenceBadgeClass}>#{ticket.reference}</Badge>
|
||||||
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
{isAvulso ? (
|
||||||
<StatusSelect ticketId={ticket.id} value={status} />
|
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 text-sm font-semibold text-rose-700">
|
||||||
<Button
|
Cliente avulso
|
||||||
size="sm"
|
</Badge>
|
||||||
className={isPlaying ? pauseButtonClass : startButtonClass}
|
) : null}
|
||||||
onClick={() => {
|
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
||||||
if (!convexUserId) return
|
<StatusSelect ticketId={ticket.id} value={status} />
|
||||||
if (isPlaying) {
|
{isPlaying ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className={pauseButtonClass}
|
||||||
|
onClick={() => {
|
||||||
|
if (!convexUserId) return
|
||||||
setPauseDialogOpen(true)
|
setPauseDialogOpen(true)
|
||||||
} else {
|
}}
|
||||||
void handleStartWork()
|
>
|
||||||
}
|
<IconPlayerPause className="size-4 text-white" /> Pausar
|
||||||
}}
|
</Button>
|
||||||
>
|
) : (
|
||||||
{isPlaying ? (
|
<DropdownMenu>
|
||||||
<>
|
<DropdownMenuTrigger asChild>
|
||||||
<IconPlayerPause className="size-4 text-white" /> Pausar
|
<Button size="sm" className={startButtonClass}>
|
||||||
</>
|
<IconPlayerPlay className="size-4 text-white" /> Iniciar
|
||||||
) : (
|
</Button>
|
||||||
<>
|
</DropdownMenuTrigger>
|
||||||
<IconPlayerPlay className="size-4 text-white" /> Iniciar
|
<DropdownMenuContent align="start" className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||||
</>
|
<DropdownMenuItem onSelect={() => void handleStartWork("INTERNAL")}>Iniciar (interno)</DropdownMenuItem>
|
||||||
)}
|
<DropdownMenuItem onSelect={() => void handleStartWork("EXTERNAL")}>Iniciar (externo)</DropdownMenuItem>
|
||||||
</Button>
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ const statusOptions: Array<{ value: TicketStatus; label: string }> = [
|
||||||
{ value: "AWAITING_ATTENDANCE", label: "Aguardando atendimento" },
|
{ value: "AWAITING_ATTENDANCE", label: "Aguardando atendimento" },
|
||||||
{ value: "PAUSED", label: "Pausado" },
|
{ value: "PAUSED", label: "Pausado" },
|
||||||
{ value: "RESOLVED", label: "Resolvido" },
|
{ value: "RESOLVED", label: "Resolvido" },
|
||||||
{ value: "CLOSED", label: "Fechado" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const statusLabelMap = statusOptions.reduce<Record<TicketStatus, string>>((acc, option) => {
|
const statusLabelMap = statusOptions.reduce<Record<TicketStatus, string>>((acc, option) => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { format, formatDistanceToNow, formatDistanceToNowStrict } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { type LucideIcon, Code, FileText, Mail, MessageCircle, MessageSquare, Phone } from "lucide-react"
|
import { type LucideIcon, Code, FileText, Mail, MessageCircle, MessageSquare, Phone } from "lucide-react"
|
||||||
|
|
||||||
|
|
@ -42,7 +42,7 @@ const channelIcon: Record<TicketChannel, LucideIcon> = {
|
||||||
MANUAL: FileText,
|
MANUAL: FileText,
|
||||||
}
|
}
|
||||||
|
|
||||||
const cellClass = "px-6 py-5 align-top text-sm text-neutral-700 first:pl-8 last:pr-8"
|
const cellClass = "px-4 py-4 align-middle text-sm text-neutral-700 whitespace-normal first:pl-5 last:pr-6"
|
||||||
const channelIconBadgeClass = "inline-flex size-8 items-center justify-center rounded-full border border-slate-200 bg-slate-50 text-neutral-700"
|
const channelIconBadgeClass = "inline-flex size-8 items-center justify-center rounded-full border border-slate-200 bg-slate-50 text-neutral-700"
|
||||||
const categoryChipClass = "inline-flex items-center gap-1 rounded-full bg-slate-200/60 px-2.5 py-1 text-[11px] font-medium text-neutral-700"
|
const categoryChipClass = "inline-flex items-center gap-1 rounded-full bg-slate-200/60 px-2.5 py-1 text-[11px] font-medium text-neutral-700"
|
||||||
const tableRowClass =
|
const tableRowClass =
|
||||||
|
|
@ -53,7 +53,6 @@ const statusLabel: Record<TicketStatus, string> = {
|
||||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||||
PAUSED: "Pausado",
|
PAUSED: "Pausado",
|
||||||
RESOLVED: "Resolvido",
|
RESOLVED: "Resolvido",
|
||||||
CLOSED: "Fechado",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusTone: Record<TicketStatus, string> = {
|
const statusTone: Record<TicketStatus, string> = {
|
||||||
|
|
@ -61,7 +60,6 @@ const statusTone: Record<TicketStatus, string> = {
|
||||||
AWAITING_ATTENDANCE: "text-sky-700",
|
AWAITING_ATTENDANCE: "text-sky-700",
|
||||||
PAUSED: "text-violet-700",
|
PAUSED: "text-violet-700",
|
||||||
RESOLVED: "text-emerald-700",
|
RESOLVED: "text-emerald-700",
|
||||||
CLOSED: "text-slate-600",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(ms?: number) {
|
function formatDuration(ms?: number) {
|
||||||
|
|
@ -135,34 +133,34 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="gap-0 rounded-3xl border border-slate-200 bg-white py-0 shadow-sm">
|
<Card className="gap-0 rounded-3xl border border-slate-200 bg-white py-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table className="min-w-full overflow-hidden rounded-3xl">
|
<Table className="min-w-full overflow-hidden rounded-3xl table-fixed">
|
||||||
<TableHeader className="bg-slate-100/80">
|
<TableHeader className="bg-slate-100/80">
|
||||||
<TableRow className="bg-transparent text-[11px] uppercase tracking-wide text-neutral-600">
|
<TableRow className="bg-transparent text-[11px] uppercase tracking-wide text-neutral-600">
|
||||||
<TableHead className="w-[120px] px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
<TableHead className="w-[120px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
|
||||||
Ticket
|
Ticket
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
<TableHead className="w-[40%] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
|
||||||
Assunto
|
Assunto
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 lg:table-cell">
|
<TableHead className="hidden w-[120px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
|
||||||
Fila
|
Fila
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 md:table-cell">
|
<TableHead className="hidden w-[80px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell">
|
||||||
Canal
|
Canal
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 md:table-cell">
|
<TableHead className="hidden w-[100px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell">
|
||||||
Prioridade
|
Prioridade
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
<TableHead className="w-[230px] pl-14 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
|
||||||
Status
|
Status
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 lg:table-cell">
|
<TableHead className="hidden w-[110px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
|
||||||
Tempo
|
Tempo
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 xl:table-cell">
|
<TableHead className="hidden w-[200px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 xl:table-cell">
|
||||||
Responsável
|
Responsável
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
<TableHead className="w-[140px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
|
||||||
Atualizado
|
Atualizado
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -196,11 +194,11 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={cellClass}>
|
<TableCell className={cellClass}>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
<span className="line-clamp-1 text-[15px] font-semibold text-neutral-900">
|
<span className="text-[15px] font-semibold text-neutral-900 line-clamp-2 md:line-clamp-1 break-words">
|
||||||
{ticket.subject}
|
{ticket.subject}
|
||||||
</span>
|
</span>
|
||||||
<span className="line-clamp-1 text-sm text-neutral-600">
|
<span className="text-sm text-neutral-600 line-clamp-1 break-words max-w-[52ch]">
|
||||||
{ticket.summary ?? "Sem resumo"}
|
{ticket.summary ?? "Sem resumo"}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-1 text-xs text-neutral-500">
|
<div className="flex flex-col gap-1 text-xs text-neutral-500">
|
||||||
|
|
@ -216,12 +214,12 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={`${cellClass} hidden lg:table-cell`}>
|
<TableCell className={`${cellClass} hidden lg:table-cell pl-0`}>
|
||||||
<span className="text-sm font-semibold text-neutral-800">
|
<span className="text-sm font-semibold text-neutral-800">
|
||||||
{ticket.queue ?? "Sem fila"}
|
{ticket.queue ?? "Sem fila"}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
<TableCell className={`${cellClass} hidden md:table-cell pl-1`}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="sr-only">Canal {channelLabel[ticket.channel]}</span>
|
<span className="sr-only">Canal {channelLabel[ticket.channel]}</span>
|
||||||
<span
|
<span
|
||||||
|
|
@ -233,7 +231,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
<TableCell className={`${cellClass} hidden md:table-cell pl-1 pr-8`}>
|
||||||
<div
|
<div
|
||||||
className="inline-flex"
|
className="inline-flex"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
|
@ -242,9 +240,9 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={cellClass}>
|
<TableCell className={`${cellClass} pl-14`}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className={cn("text-sm font-semibold", statusTone[ticket.status])}>
|
<span className={cn("text-sm font-semibold break-words leading-tight max-w-[140px] sm:max-w-[180px]", statusTone[ticket.status])}>
|
||||||
{statusLabel[ticket.status]}
|
{statusLabel[ticket.status]}
|
||||||
</span>
|
</span>
|
||||||
{ticket.metrics?.timeWaitingMinutes ? (
|
{ticket.metrics?.timeWaitingMinutes ? (
|
||||||
|
|
@ -266,9 +264,14 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
<AssigneeCell ticket={ticket} />
|
<AssigneeCell ticket={ticket} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={cellClass}>
|
<TableCell className={cellClass}>
|
||||||
<span className="text-sm text-neutral-600">
|
<div className="flex flex-col leading-tight">
|
||||||
{formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })}
|
<span className="text-sm text-neutral-700">
|
||||||
</span>
|
{`há cerca de ${formatDistanceToNowStrict(ticket.updatedAt, { locale: ptBR })}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
{format(ticket.updatedAt, "dd/MM/yyyy HH:mm")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export function TicketsView() {
|
||||||
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
|
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
|
||||||
|
|
||||||
const filteredTickets = useMemo(() => {
|
const filteredTickets = useMemo(() => {
|
||||||
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED", "CLOSED"])
|
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED"])
|
||||||
let working = tickets
|
let working = tickets
|
||||||
|
|
||||||
if (!filters.status) {
|
if (!filters.status) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useMutation } from "convex/react"
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { isAdmin, isCustomer, isStaff } from "@/lib/authz"
|
import { isAdmin, isStaff } from "@/lib/authz"
|
||||||
|
|
||||||
export type AppSession = {
|
export type AppSession = {
|
||||||
user: {
|
user: {
|
||||||
|
|
@ -109,7 +109,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
role: normalizedRole,
|
role: normalizedRole,
|
||||||
isAdmin: isAdmin(normalizedRole),
|
isAdmin: isAdmin(normalizedRole),
|
||||||
isStaff: isStaff(normalizedRole),
|
isStaff: isStaff(normalizedRole),
|
||||||
isCustomer: isCustomer(normalizedRole),
|
isCustomer: false,
|
||||||
}),
|
}),
|
||||||
[session, isPending, convexUserId, normalizedRole]
|
[session, isPending, convexUserId, normalizedRole]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
export const ROLE_OPTIONS = ["admin", "manager", "agent", "collaborator", "customer"] as const
|
export const ROLE_OPTIONS = ["admin", "manager", "agent", "collaborator"] as const
|
||||||
|
|
||||||
const ADMIN_ROLE = "admin"
|
const ADMIN_ROLE = "admin"
|
||||||
const CUSTOMER_ROLE = "customer"
|
|
||||||
const STAFF_ROLES = new Set(["admin", "manager", "agent", "collaborator"])
|
const STAFF_ROLES = new Set(["admin", "manager", "agent", "collaborator"])
|
||||||
|
|
||||||
export type RoleOption = (typeof ROLE_OPTIONS)[number]
|
export type RoleOption = (typeof ROLE_OPTIONS)[number]
|
||||||
|
|
@ -14,10 +13,6 @@ export function isAdmin(role?: string | null) {
|
||||||
return normalizeRole(role) === ADMIN_ROLE
|
return normalizeRole(role) === ADMIN_ROLE
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCustomer(role?: string | null) {
|
|
||||||
return normalizeRole(role) === CUSTOMER_ROLE
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isStaff(role?: string | null) {
|
export function isStaff(role?: string | null) {
|
||||||
return STAFF_ROLES.has(normalizeRole(role) ?? "")
|
return STAFF_ROLES.has(normalizeRole(role) ?? "")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const STATUS_MAP: Record<string, NormalizedTicketStatus> = {
|
||||||
ON_HOLD: "PAUSED",
|
ON_HOLD: "PAUSED",
|
||||||
PAUSED: "PAUSED",
|
PAUSED: "PAUSED",
|
||||||
RESOLVED: "RESOLVED",
|
RESOLVED: "RESOLVED",
|
||||||
CLOSED: "CLOSED",
|
CLOSED: "RESOLVED",
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeTicketStatus(status: unknown): NormalizedTicketStatus {
|
function normalizeTicketStatus(status: unknown): NormalizedTicketStatus {
|
||||||
|
|
@ -135,6 +135,8 @@ export function mapTicketFromServer(input: unknown) {
|
||||||
workSummary: s.workSummary
|
workSummary: s.workSummary
|
||||||
? {
|
? {
|
||||||
totalWorkedMs: s.workSummary.totalWorkedMs,
|
totalWorkedMs: s.workSummary.totalWorkedMs,
|
||||||
|
internalWorkedMs: (s.workSummary as any).internalWorkedMs ?? 0,
|
||||||
|
externalWorkedMs: (s.workSummary as any).externalWorkedMs ?? 0,
|
||||||
activeSession: s.workSummary.activeSession
|
activeSession: s.workSummary.activeSession
|
||||||
? {
|
? {
|
||||||
...s.workSummary.activeSession,
|
...s.workSummary.activeSession,
|
||||||
|
|
@ -183,6 +185,9 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
|
||||||
dueAt: s.dueAt ? new Date(s.dueAt) : null,
|
dueAt: s.dueAt ? new Date(s.dueAt) : null,
|
||||||
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
|
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
|
||||||
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
|
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
|
||||||
|
company: (s as any).company
|
||||||
|
? ({ id: (s as any).company.id, name: (s as any).company.name, isAvulso: (s as any).company.isAvulso } as any)
|
||||||
|
: undefined,
|
||||||
timeline: s.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })),
|
timeline: s.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })),
|
||||||
comments: s.comments.map((c) => ({
|
comments: s.comments.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
|
|
|
||||||
|
|
@ -297,8 +297,7 @@ export const playContext = {
|
||||||
nextTicket:
|
nextTicket:
|
||||||
tickets.find(
|
tickets.find(
|
||||||
(ticket) =>
|
(ticket) =>
|
||||||
ticket.status !== ticketStatusSchema.enum.RESOLVED &&
|
ticket.status !== ticketStatusSchema.enum.RESOLVED
|
||||||
ticket.status !== ticketStatusSchema.enum.CLOSED
|
|
||||||
) ?? null,
|
) ?? null,
|
||||||
} as z.infer<typeof ticketPlayContextSchema>
|
} as z.infer<typeof ticketPlayContextSchema>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ export const ticketStatusSchema = z.enum([
|
||||||
"AWAITING_ATTENDANCE",
|
"AWAITING_ATTENDANCE",
|
||||||
"PAUSED",
|
"PAUSED",
|
||||||
"RESOLVED",
|
"RESOLVED",
|
||||||
"CLOSED",
|
|
||||||
])
|
])
|
||||||
|
|
||||||
export type TicketStatus = z.infer<typeof ticketStatusSchema>
|
export type TicketStatus = z.infer<typeof ticketStatusSchema>
|
||||||
|
|
@ -35,6 +34,13 @@ export const userSummarySchema = z.object({
|
||||||
})
|
})
|
||||||
export type UserSummary = z.infer<typeof userSummarySchema>
|
export type UserSummary = z.infer<typeof userSummarySchema>
|
||||||
|
|
||||||
|
export const ticketCompanySummarySchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
isAvulso: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
export type TicketCompanySummary = z.infer<typeof ticketCompanySummarySchema>
|
||||||
|
|
||||||
export const ticketCategorySummarySchema = z.object({
|
export const ticketCategorySummarySchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
|
@ -100,6 +106,7 @@ export const ticketSchema = z.object({
|
||||||
queue: z.string().nullable(),
|
queue: z.string().nullable(),
|
||||||
requester: userSummarySchema,
|
requester: userSummarySchema,
|
||||||
assignee: userSummarySchema.nullable(),
|
assignee: userSummarySchema.nullable(),
|
||||||
|
company: ticketCompanySummarySchema.optional().nullable(),
|
||||||
slaPolicy: z
|
slaPolicy: z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
|
@ -126,11 +133,14 @@ export const ticketSchema = z.object({
|
||||||
workSummary: z
|
workSummary: z
|
||||||
.object({
|
.object({
|
||||||
totalWorkedMs: z.number(),
|
totalWorkedMs: z.number(),
|
||||||
|
internalWorkedMs: z.number().optional().default(0),
|
||||||
|
externalWorkedMs: z.number().optional().default(0),
|
||||||
activeSession: z
|
activeSession: z
|
||||||
.object({
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
startedAt: z.coerce.date(),
|
startedAt: z.coerce.date(),
|
||||||
|
workType: z.string().optional(),
|
||||||
})
|
})
|
||||||
.nullable(),
|
.nullable(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
5
types/pdfkit-standalone.d.ts
vendored
Normal file
5
types/pdfkit-standalone.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
declare module "pdfkit/js/pdfkit.standalone.js" {
|
||||||
|
const PDFDocument: any
|
||||||
|
export default PDFDocument
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue