feat: enhance tickets portal and admin flows
This commit is contained in:
parent
9cdd8763b4
commit
c15f0a5b09
67 changed files with 1101 additions and 338 deletions
|
|
@ -14,15 +14,15 @@ Lista priorizada de evoluções propostas para o Sistema de Chamados. Confira `a
|
||||||
|
|
||||||
# 🧾 Tickets e atendimentos
|
# 🧾 Tickets e atendimentos
|
||||||
|
|
||||||
- [ ] Adicionar opção **Exportar histórico completo em PDF** (conversa, logs, movimentações)
|
- [x] Adicionar opção **Exportar histórico completo em PDF** (conversa, logs, movimentações)
|
||||||
- [ ] Implementar **justificativa obrigatória ao pausar** o chamado
|
- [x] Implementar **justificativa obrigatória ao pausar** o chamado
|
||||||
- [ ] Categorias: Falta de contato / Aguardando terceiro / Em procedimento
|
- [x] Categorias: Falta de contato / Aguardando terceiro / Em procedimento
|
||||||
- [ ] Ajustar **status padrão dos tickets**
|
- [x] Ajustar **status padrão dos tickets**
|
||||||
- [ ] Pendentes
|
- [x] Pendentes
|
||||||
- [ ] Aguardando atendimento
|
- [x] Aguardando atendimento
|
||||||
- [ ] Pausados
|
- [x] Pausados
|
||||||
- [ ] (Remover “Aguardando resposta” e “Violados”)
|
- [x] (Remover “Aguardando resposta” e “Violados”)
|
||||||
- [ ] Remover automaticamente da listagem ao finalizar o chamado
|
- [x] Remover automaticamente da listagem ao finalizar o chamado
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,25 @@ import type { Id } from "./_generated/dataModel";
|
||||||
|
|
||||||
import { requireAdmin, requireStaff } from "./rbac";
|
import { requireAdmin, requireStaff } from "./rbac";
|
||||||
|
|
||||||
|
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED";
|
||||||
|
|
||||||
|
const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
|
||||||
|
NEW: "PENDING",
|
||||||
|
PENDING: "PENDING",
|
||||||
|
OPEN: "AWAITING_ATTENDANCE",
|
||||||
|
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
|
||||||
|
ON_HOLD: "PAUSED",
|
||||||
|
PAUSED: "PAUSED",
|
||||||
|
RESOLVED: "RESOLVED",
|
||||||
|
CLOSED: "CLOSED",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
||||||
|
if (!status) return "PENDING";
|
||||||
|
const normalized = STATUS_NORMALIZE_MAP[status.toUpperCase()];
|
||||||
|
return normalized ?? "PENDING";
|
||||||
|
}
|
||||||
|
|
||||||
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
||||||
"Suporte N1": "Chamados",
|
"Suporte N1": "Chamados",
|
||||||
"suporte-n1": "Chamados",
|
"suporte-n1": "Chamados",
|
||||||
|
|
@ -98,8 +117,14 @@ export const summary = query({
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", qItem._id))
|
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", qItem._id))
|
||||||
.collect();
|
.collect();
|
||||||
const waiting = pending.filter((t) => t.status === "PENDING" || t.status === "ON_HOLD").length;
|
const waiting = pending.filter((t) => {
|
||||||
const open = pending.filter((t) => t.status !== "RESOLVED" && t.status !== "CLOSED").length;
|
const status = normalizeStatus(t.status);
|
||||||
|
return status === "PENDING" || status === "PAUSED";
|
||||||
|
}).length;
|
||||||
|
const open = pending.filter((t) => {
|
||||||
|
const status = normalizeStatus(t.status);
|
||||||
|
return status !== "RESOLVED" && status !== "CLOSED";
|
||||||
|
}).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 };
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,31 @@ import type { Doc, Id } from "./_generated/dataModel";
|
||||||
|
|
||||||
import { requireStaff } from "./rbac";
|
import { requireStaff } from "./rbac";
|
||||||
|
|
||||||
|
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED";
|
||||||
|
|
||||||
|
const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
|
||||||
|
NEW: "PENDING",
|
||||||
|
PENDING: "PENDING",
|
||||||
|
OPEN: "AWAITING_ATTENDANCE",
|
||||||
|
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
|
||||||
|
ON_HOLD: "PAUSED",
|
||||||
|
PAUSED: "PAUSED",
|
||||||
|
RESOLVED: "RESOLVED",
|
||||||
|
CLOSED: "CLOSED",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
||||||
|
if (!status) return "PENDING";
|
||||||
|
const normalized = STATUS_NORMALIZE_MAP[status.toUpperCase()];
|
||||||
|
return normalized ?? "PENDING";
|
||||||
|
}
|
||||||
|
|
||||||
function average(values: number[]) {
|
function average(values: number[]) {
|
||||||
if (values.length === 0) return null;
|
if (values.length === 0) return null;
|
||||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OPEN_STATUSES = new Set(["NEW", "OPEN", "PENDING", "ON_HOLD"]);
|
const OPEN_STATUSES = new Set<TicketStatusNormalized>(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]);
|
||||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function percentageChange(current: number, previous: number) {
|
function percentageChange(current: number, previous: number) {
|
||||||
|
|
@ -116,8 +135,11 @@ export const slaOverview = query({
|
||||||
const queues = await fetchQueues(ctx, tenantId);
|
const queues = await fetchQueues(ctx, tenantId);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status));
|
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
|
||||||
const resolvedTickets = tickets.filter((ticket) => ticket.status === "RESOLVED" || ticket.status === "CLOSED");
|
const resolvedTickets = tickets.filter((ticket) => {
|
||||||
|
const status = normalizeStatus(ticket.status);
|
||||||
|
return status === "RESOLVED" || status === "CLOSED";
|
||||||
|
});
|
||||||
const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
|
const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
|
||||||
|
|
||||||
const firstResponseTimes = tickets
|
const firstResponseTimes = tickets
|
||||||
|
|
@ -193,17 +215,18 @@ export const backlogOverview = query({
|
||||||
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<string, number>>((acc, ticket) => {
|
const statusCounts = tickets.reduce<Record<TicketStatusNormalized, number>>((acc, ticket) => {
|
||||||
acc[ticket.status] = (acc[ticket.status] ?? 0) + 1;
|
const status = normalizeStatus(ticket.status);
|
||||||
|
acc[status] = (acc[status] ?? 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {} as Record<TicketStatusNormalized, number>);
|
||||||
|
|
||||||
const priorityCounts = tickets.reduce<Record<string, number>>((acc, ticket) => {
|
const priorityCounts = tickets.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(ticket.status));
|
const openTickets = tickets.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) {
|
||||||
|
|
@ -276,7 +299,7 @@ export const dashboardOverview = query({
|
||||||
const deltaMinutes =
|
const deltaMinutes =
|
||||||
averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null;
|
averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null;
|
||||||
|
|
||||||
const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status));
|
const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
|
||||||
const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
|
const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
|
||||||
|
|
||||||
const surveys = await collectCsatSurveys(ctx, tickets);
|
const surveys = await collectCsatSurveys(ctx, tickets);
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,8 @@ export default defineSchema({
|
||||||
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()),
|
||||||
|
pauseReason: v.optional(v.string()),
|
||||||
|
pauseNote: v.optional(v.string()),
|
||||||
})
|
})
|
||||||
.index("by_ticket", ["ticketId"])
|
.index("by_ticket", ["ticketId"])
|
||||||
.index("by_ticket_agent", ["ticketId", "agentId"]),
|
.index("by_ticket_agent", ["ticketId", "agentId"]),
|
||||||
|
|
|
||||||
|
|
@ -304,7 +304,7 @@ export const seedDemo = mutation({
|
||||||
reference: ++ref,
|
reference: ++ref,
|
||||||
subject: "Erro 500 ao acessar portal do cliente",
|
subject: "Erro 500 ao acessar portal do cliente",
|
||||||
summary: "Clientes relatam erro intermitente no portal web",
|
summary: "Clientes relatam erro intermitente no portal web",
|
||||||
status: "OPEN",
|
status: "AWAITING_ATTENDANCE",
|
||||||
priority: "URGENT",
|
priority: "URGENT",
|
||||||
channel: "EMAIL",
|
channel: "EMAIL",
|
||||||
queueId: queue1,
|
queueId: queue1,
|
||||||
|
|
@ -362,7 +362,7 @@ export const seedDemo = mutation({
|
||||||
reference: ++ref,
|
reference: ++ref,
|
||||||
subject: "Visita técnica para instalação de roteadores",
|
subject: "Visita técnica para instalação de roteadores",
|
||||||
summary: "Equipe Omni solicita agenda para instalação de novos pontos de rede",
|
summary: "Equipe Omni solicita agenda para instalação de novos pontos de rede",
|
||||||
status: "OPEN",
|
status: "AWAITING_ATTENDANCE",
|
||||||
priority: "MEDIUM",
|
priority: "MEDIUM",
|
||||||
channel: "PHONE",
|
channel: "PHONE",
|
||||||
queueId: queueVisitas._id,
|
queueId: queueVisitas._id,
|
||||||
|
|
@ -387,4 +387,3 @@ export const seedDemo = mutation({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,38 @@ import { requireCustomer, 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"]);
|
||||||
|
const PAUSE_REASON_LABELS: Record<string, string> = {
|
||||||
|
NO_CONTACT: "Falta de contato",
|
||||||
|
WAITING_THIRD_PARTY: "Aguardando terceiro",
|
||||||
|
IN_PROCEDURE: "Em procedimento",
|
||||||
|
};
|
||||||
|
|
||||||
|
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED";
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
|
||||||
|
PENDING: "Pendente",
|
||||||
|
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||||
|
PAUSED: "Pausado",
|
||||||
|
RESOLVED: "Resolvido",
|
||||||
|
CLOSED: "Fechado",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
|
||||||
|
NEW: "PENDING",
|
||||||
|
PENDING: "PENDING",
|
||||||
|
OPEN: "AWAITING_ATTENDANCE",
|
||||||
|
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
|
||||||
|
ON_HOLD: "PAUSED",
|
||||||
|
PAUSED: "PAUSED",
|
||||||
|
RESOLVED: "RESOLVED",
|
||||||
|
CLOSED: "CLOSED",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
||||||
|
if (!status) return "PENDING";
|
||||||
|
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
|
||||||
|
return normalized ?? "PENDING";
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureManagerTicketAccess(
|
async function ensureManagerTicketAccess(
|
||||||
ctx: MutationCtx | QueryCtx,
|
ctx: MutationCtx | QueryCtx,
|
||||||
|
|
@ -232,11 +264,6 @@ export const list = query({
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
|
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
|
||||||
.collect();
|
.collect();
|
||||||
} else if (args.status) {
|
|
||||||
base = await ctx.db
|
|
||||||
.query("tickets")
|
|
||||||
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", args.status!))
|
|
||||||
.collect();
|
|
||||||
} else if (args.queueId) {
|
} else if (args.queueId) {
|
||||||
base = await ctx.db
|
base = await ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
|
|
@ -258,9 +285,13 @@ export const list = query({
|
||||||
}
|
}
|
||||||
filtered = filtered.filter((t) => t.companyId === user.companyId)
|
filtered = filtered.filter((t) => t.companyId === user.companyId)
|
||||||
}
|
}
|
||||||
|
const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
|
||||||
|
|
||||||
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
|
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
|
||||||
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
|
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
|
||||||
|
if (normalizedStatusFilter) {
|
||||||
|
filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter);
|
||||||
|
}
|
||||||
if (args.search) {
|
if (args.search) {
|
||||||
const term = args.search.toLowerCase();
|
const term = args.search.toLowerCase();
|
||||||
filtered = filtered.filter(
|
filtered = filtered.filter(
|
||||||
|
|
@ -307,7 +338,7 @@ export const list = query({
|
||||||
tenantId: t.tenantId,
|
tenantId: t.tenantId,
|
||||||
subject: t.subject,
|
subject: t.subject,
|
||||||
summary: t.summary,
|
summary: t.summary,
|
||||||
status: t.status,
|
status: normalizeStatus(t.status),
|
||||||
priority: t.priority,
|
priority: t.priority,
|
||||||
channel: t.channel,
|
channel: t.channel,
|
||||||
queue: queueName,
|
queue: queueName,
|
||||||
|
|
@ -428,7 +459,7 @@ export const getById = query({
|
||||||
tenantId: t.tenantId,
|
tenantId: t.tenantId,
|
||||||
subject: t.subject,
|
subject: t.subject,
|
||||||
summary: t.summary,
|
summary: t.summary,
|
||||||
status: t.status,
|
status: normalizeStatus(t.status),
|
||||||
priority: t.priority,
|
priority: t.priority,
|
||||||
channel: t.channel,
|
channel: t.channel,
|
||||||
queue: queueName,
|
queue: queueName,
|
||||||
|
|
@ -588,12 +619,13 @@ export const create = mutation({
|
||||||
.take(1);
|
.take(1);
|
||||||
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
|
const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const initialStatus: TicketStatusNormalized = initialAssigneeId ? "AWAITING_ATTENDANCE" : "PENDING";
|
||||||
const id = await ctx.db.insert("tickets", {
|
const id = await ctx.db.insert("tickets", {
|
||||||
tenantId: args.tenantId,
|
tenantId: args.tenantId,
|
||||||
reference: nextRef,
|
reference: nextRef,
|
||||||
subject,
|
subject,
|
||||||
summary: args.summary?.trim() || undefined,
|
summary: args.summary?.trim() || undefined,
|
||||||
status: "NEW",
|
status: initialStatus,
|
||||||
priority: args.priority,
|
priority: args.priority,
|
||||||
channel: args.channel,
|
channel: args.channel,
|
||||||
queueId: args.queueId,
|
queueId: args.queueId,
|
||||||
|
|
@ -847,20 +879,13 @@ export const updateStatus = mutation({
|
||||||
}
|
}
|
||||||
const ticketDoc = ticket as Doc<"tickets">
|
const ticketDoc = ticket as Doc<"tickets">
|
||||||
await requireTicketStaff(ctx, actorId, ticketDoc)
|
await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
|
const normalizedStatus = normalizeStatus(status)
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await ctx.db.patch(ticketId, { status, updatedAt: now });
|
await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now });
|
||||||
const statusPt: Record<string, string> = {
|
|
||||||
NEW: "Novo",
|
|
||||||
OPEN: "Aberto",
|
|
||||||
PENDING: "Pendente",
|
|
||||||
ON_HOLD: "Em espera",
|
|
||||||
RESOLVED: "Resolvido",
|
|
||||||
CLOSED: "Fechado",
|
|
||||||
} as const;
|
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId,
|
ticketId,
|
||||||
type: "STATUS_CHANGED",
|
type: "STATUS_CHANGED",
|
||||||
payload: { to: status, toLabel: statusPt[status] ?? status, actorId },
|
payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -1095,8 +1120,13 @@ export const startWork = mutation({
|
||||||
})
|
})
|
||||||
|
|
||||||
export const pauseWork = mutation({
|
export const pauseWork = mutation({
|
||||||
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
|
args: {
|
||||||
handler: async (ctx, { ticketId, actorId }) => {
|
ticketId: v.id("tickets"),
|
||||||
|
actorId: v.id("users"),
|
||||||
|
reason: v.string(),
|
||||||
|
note: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { ticketId, actorId, reason, note }) => {
|
||||||
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")
|
||||||
|
|
@ -1106,6 +1136,10 @@ export const pauseWork = mutation({
|
||||||
return { status: "already_paused" }
|
return { status: "already_paused" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!PAUSE_REASON_LABELS[reason]) {
|
||||||
|
throw new ConvexError("Motivo de pausa inválido")
|
||||||
|
}
|
||||||
|
|
||||||
const session = await ctx.db.get(ticket.activeSessionId)
|
const session = await ctx.db.get(ticket.activeSessionId)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false })
|
await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false })
|
||||||
|
|
@ -1118,6 +1152,8 @@ export const pauseWork = mutation({
|
||||||
await ctx.db.patch(ticket.activeSessionId, {
|
await ctx.db.patch(ticket.activeSessionId, {
|
||||||
stoppedAt: now,
|
stoppedAt: now,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
pauseReason: reason,
|
||||||
|
pauseNote: note ?? "",
|
||||||
})
|
})
|
||||||
|
|
||||||
await ctx.db.patch(ticketId, {
|
await ctx.db.patch(ticketId, {
|
||||||
|
|
@ -1137,11 +1173,19 @@ export const pauseWork = mutation({
|
||||||
actorAvatar: actor?.avatarUrl,
|
actorAvatar: actor?.avatarUrl,
|
||||||
sessionId: session._id,
|
sessionId: session._id,
|
||||||
sessionDurationMs: durationMs,
|
sessionDurationMs: durationMs,
|
||||||
|
pauseReason: reason,
|
||||||
|
pauseReasonLabel: PAUSE_REASON_LABELS[reason],
|
||||||
|
pauseNote: note ?? "",
|
||||||
},
|
},
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { status: "paused", durationMs }
|
return {
|
||||||
|
status: "paused",
|
||||||
|
durationMs,
|
||||||
|
pauseReason: reason,
|
||||||
|
pauseNote: note ?? "",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1228,7 +1272,10 @@ export const playNext = mutation({
|
||||||
|
|
||||||
const chosen = candidates[0];
|
const chosen = candidates[0];
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
|
const currentStatus = normalizeStatus(chosen.status);
|
||||||
|
const nextStatus: TicketStatusNormalized =
|
||||||
|
currentStatus === "PENDING" ? "AWAITING_ATTENDANCE" : currentStatus;
|
||||||
|
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: nextStatus, updatedAt: now });
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId: chosen._id,
|
ticketId: chosen._id,
|
||||||
type: "ASSIGNEE_CHANGED",
|
type: "ASSIGNEE_CHANGED",
|
||||||
|
|
@ -1247,7 +1294,7 @@ export const playNext = mutation({
|
||||||
tenantId: chosen.tenantId,
|
tenantId: chosen.tenantId,
|
||||||
subject: chosen.subject,
|
subject: chosen.subject,
|
||||||
summary: chosen.summary,
|
summary: chosen.summary,
|
||||||
status: chosen.status,
|
status: nextStatus,
|
||||||
priority: chosen.priority,
|
priority: chosen.priority,
|
||||||
channel: chosen.channel,
|
channel: chosen.channel,
|
||||||
queue: queueName,
|
queue: queueName,
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,13 @@
|
||||||
"better-auth": "^1.3.26",
|
"better-auth": "^1.3.26",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^16.4.5",
|
|
||||||
"convex": "^1.27.3",
|
"convex": "^1.27.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"pdfkit": "^0.17.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
|
@ -67,9 +68,11 @@
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pdfkit": "^0.17.3",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
|
"@types/three": "^0.180.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.4",
|
"eslint-config-next": "15.5.4",
|
||||||
"prisma": "^6.16.2",
|
"prisma": "^6.16.2",
|
||||||
|
|
|
||||||
163
pnpm-lock.yaml
generated
163
pnpm-lock.yaml
generated
|
|
@ -116,6 +116,9 @@ importers:
|
||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
pdfkit:
|
||||||
|
specifier: ^0.17.2
|
||||||
|
version: 0.17.2
|
||||||
postcss:
|
postcss:
|
||||||
specifier: ^8.5.6
|
specifier: ^8.5.6
|
||||||
version: 8.5.6
|
version: 8.5.6
|
||||||
|
|
@ -162,6 +165,9 @@ importers:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20
|
specifier: ^20
|
||||||
version: 20.19.19
|
version: 20.19.19
|
||||||
|
'@types/pdfkit':
|
||||||
|
specifier: ^0.17.3
|
||||||
|
version: 0.17.3
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19
|
specifier: ^19
|
||||||
version: 19.2.0
|
version: 19.2.0
|
||||||
|
|
@ -171,6 +177,9 @@ importers:
|
||||||
'@types/sanitize-html':
|
'@types/sanitize-html':
|
||||||
specifier: ^2.16.0
|
specifier: ^2.16.0
|
||||||
version: 2.16.0
|
version: 2.16.0
|
||||||
|
'@types/three':
|
||||||
|
specifier: ^0.180.0
|
||||||
|
version: 0.180.0
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9
|
specifier: ^9
|
||||||
version: 9.37.0(jiti@2.6.1)
|
version: 9.37.0(jiti@2.6.1)
|
||||||
|
|
@ -212,6 +221,9 @@ packages:
|
||||||
'@better-fetch/fetch@1.1.18':
|
'@better-fetch/fetch@1.1.18':
|
||||||
resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==}
|
resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==}
|
||||||
|
|
||||||
|
'@dimforge/rapier3d-compat@0.12.0':
|
||||||
|
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||||
|
|
||||||
'@dnd-kit/accessibility@3.1.1':
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -1783,6 +1795,9 @@ packages:
|
||||||
'@tiptap/starter-kit@3.6.5':
|
'@tiptap/starter-kit@3.6.5':
|
||||||
resolution: {integrity: sha512-LNAJQstB/VazmMlRbUyu3rCNVQ9af25Ywkn3Uyuwt3Ks9ZlliIm/x/zertdXTY2adoig+b36zT5Xcx1O4IdJ3A==}
|
resolution: {integrity: sha512-LNAJQstB/VazmMlRbUyu3rCNVQ9af25Ywkn3Uyuwt3Ks9ZlliIm/x/zertdXTY2adoig+b36zT5Xcx1O4IdJ3A==}
|
||||||
|
|
||||||
|
'@tweenjs/tween.js@23.1.3':
|
||||||
|
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
|
|
@ -1834,6 +1849,9 @@ packages:
|
||||||
'@types/node@20.19.19':
|
'@types/node@20.19.19':
|
||||||
resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==}
|
resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==}
|
||||||
|
|
||||||
|
'@types/pdfkit@0.17.3':
|
||||||
|
resolution: {integrity: sha512-E4tp2qFaghqfS4K5TR4Gn1uTIkg0UAkhUgvVIszr5cS6ZmbioPWEkvhNDy3GtR9qdKC8DLQAnaaMlTcf346VsA==}
|
||||||
|
|
||||||
'@types/react-dom@19.2.0':
|
'@types/react-dom@19.2.0':
|
||||||
resolution: {integrity: sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==}
|
resolution: {integrity: sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -1855,6 +1873,12 @@ packages:
|
||||||
'@types/sanitize-html@2.16.0':
|
'@types/sanitize-html@2.16.0':
|
||||||
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
|
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
|
||||||
|
|
||||||
|
'@types/stats.js@0.17.4':
|
||||||
|
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
|
||||||
|
|
||||||
|
'@types/three@0.180.0':
|
||||||
|
resolution: {integrity: sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==}
|
||||||
|
|
||||||
'@types/use-sync-external-store@0.0.6':
|
'@types/use-sync-external-store@0.0.6':
|
||||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||||
|
|
||||||
|
|
@ -2044,6 +2068,9 @@ packages:
|
||||||
'@vitest/utils@2.1.9':
|
'@vitest/utils@2.1.9':
|
||||||
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
|
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
|
||||||
|
|
||||||
|
'@webgpu/types@0.1.65':
|
||||||
|
resolution: {integrity: sha512-cYrHab4d6wuVvDW5tdsfI6/o6vcLMDe6w2Citd1oS51Xxu2ycLCnVo4fqwujfKWijrZMInTJIKcXxteoy21nVA==}
|
||||||
|
|
||||||
acorn-jsx@5.3.2:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -2134,6 +2161,10 @@ packages:
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
|
base64-js@0.0.8:
|
||||||
|
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
base64-js@1.5.1:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
|
|
@ -2179,6 +2210,9 @@ packages:
|
||||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
brotli@1.3.3:
|
||||||
|
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
|
||||||
|
|
||||||
buffer@6.0.3:
|
buffer@6.0.3:
|
||||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||||
|
|
||||||
|
|
@ -2242,6 +2276,10 @@ packages:
|
||||||
client-only@0.0.1:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
|
clone@2.1.2:
|
||||||
|
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
clsx@2.1.1:
|
clsx@2.1.1:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -2286,6 +2324,9 @@ packages:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
crypto-js@4.2.0:
|
||||||
|
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||||
|
|
||||||
csstype@3.1.3:
|
csstype@3.1.3:
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||||
|
|
||||||
|
|
@ -2407,6 +2448,9 @@ packages:
|
||||||
detect-node-es@1.1.0:
|
detect-node-es@1.1.0:
|
||||||
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
||||||
|
|
||||||
|
dfa@1.2.0:
|
||||||
|
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
|
||||||
|
|
||||||
doctrine@2.1.0:
|
doctrine@2.1.0:
|
||||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -2668,6 +2712,9 @@ packages:
|
||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fflate@0.8.2:
|
||||||
|
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
@ -2687,6 +2734,9 @@ packages:
|
||||||
flatted@3.3.3:
|
flatted@3.3.3:
|
||||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||||
|
|
||||||
|
fontkit@2.0.4:
|
||||||
|
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
|
||||||
|
|
||||||
for-each@0.3.5:
|
for-each@0.3.5:
|
||||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -2945,6 +2995,9 @@ packages:
|
||||||
jose@6.1.0:
|
jose@6.1.0:
|
||||||
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
|
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
|
||||||
|
|
||||||
|
jpeg-exif@1.1.4:
|
||||||
|
resolution: {integrity: sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
|
|
@ -3055,6 +3108,9 @@ packages:
|
||||||
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
|
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
linebreak@1.1.0:
|
||||||
|
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
|
||||||
|
|
||||||
linkify-it@5.0.0:
|
linkify-it@5.0.0:
|
||||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||||
|
|
||||||
|
|
@ -3101,6 +3157,9 @@ packages:
|
||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
meshoptimizer@0.22.0:
|
||||||
|
resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==}
|
||||||
|
|
||||||
micromatch@4.0.8:
|
micromatch@4.0.8:
|
||||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
@ -3232,6 +3291,9 @@ packages:
|
||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
pako@0.2.9:
|
||||||
|
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -3260,6 +3322,9 @@ packages:
|
||||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||||
engines: {node: '>= 14.16'}
|
engines: {node: '>= 14.16'}
|
||||||
|
|
||||||
|
pdfkit@0.17.2:
|
||||||
|
resolution: {integrity: sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==}
|
||||||
|
|
||||||
perfect-debounce@1.0.0:
|
perfect-debounce@1.0.0:
|
||||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||||
|
|
||||||
|
|
@ -3277,6 +3342,9 @@ packages:
|
||||||
pkg-types@2.3.0:
|
pkg-types@2.3.0:
|
||||||
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||||
|
|
||||||
|
png-js@1.0.0:
|
||||||
|
resolution: {integrity: sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==}
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0:
|
possible-typed-array-names@1.1.0:
|
||||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -3512,6 +3580,9 @@ packages:
|
||||||
resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
|
resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
restructure@3.0.2:
|
||||||
|
resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
|
||||||
|
|
||||||
reusify@1.1.0:
|
reusify@1.1.0:
|
||||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
@ -3703,6 +3774,9 @@ packages:
|
||||||
three@0.180.0:
|
three@0.180.0:
|
||||||
resolution: {integrity: sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==}
|
resolution: {integrity: sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==}
|
||||||
|
|
||||||
|
tiny-inflate@1.0.3:
|
||||||
|
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
||||||
|
|
||||||
tiny-invariant@1.3.3:
|
tiny-invariant@1.3.3:
|
||||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
|
|
@ -3795,6 +3869,12 @@ packages:
|
||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
|
unicode-properties@1.4.1:
|
||||||
|
resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
|
||||||
|
|
||||||
|
unicode-trie@2.0.0:
|
||||||
|
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
|
||||||
|
|
||||||
unicornstudio-react@1.4.31:
|
unicornstudio-react@1.4.31:
|
||||||
resolution: {integrity: sha512-EYPeBPyOXiL6ltLMQRJFbBktnai+RQee4UZk5OcFWbVXii//E8pRF9p4++5ByEiBvDIX4jyj5Mgtxi76Kr12kQ==}
|
resolution: {integrity: sha512-EYPeBPyOXiL6ltLMQRJFbBktnai+RQee4UZk5OcFWbVXii//E8pRF9p4++5ByEiBvDIX4jyj5Mgtxi76Kr12kQ==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
@ -3984,6 +4064,8 @@ snapshots:
|
||||||
|
|
||||||
'@better-fetch/fetch@1.1.18': {}
|
'@better-fetch/fetch@1.1.18': {}
|
||||||
|
|
||||||
|
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||||
|
|
||||||
'@dnd-kit/accessibility@3.1.1(react@19.2.0)':
|
'@dnd-kit/accessibility@3.1.1(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|
@ -5393,6 +5475,8 @@ snapshots:
|
||||||
'@tiptap/extensions': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
|
'@tiptap/extensions': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)
|
||||||
'@tiptap/pm': 3.6.5
|
'@tiptap/pm': 3.6.5
|
||||||
|
|
||||||
|
'@tweenjs/tween.js@23.1.3': {}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
@ -5441,6 +5525,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/pdfkit@0.17.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 20.19.19
|
||||||
|
|
||||||
'@types/react-dom@19.2.0(@types/react@19.2.0)':
|
'@types/react-dom@19.2.0(@types/react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.0
|
'@types/react': 19.2.0
|
||||||
|
|
@ -5461,6 +5549,18 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
htmlparser2: 8.0.2
|
htmlparser2: 8.0.2
|
||||||
|
|
||||||
|
'@types/stats.js@0.17.4': {}
|
||||||
|
|
||||||
|
'@types/three@0.180.0':
|
||||||
|
dependencies:
|
||||||
|
'@dimforge/rapier3d-compat': 0.12.0
|
||||||
|
'@tweenjs/tween.js': 23.1.3
|
||||||
|
'@types/stats.js': 0.17.4
|
||||||
|
'@types/webxr': 0.5.24
|
||||||
|
'@webgpu/types': 0.1.65
|
||||||
|
fflate: 0.8.2
|
||||||
|
meshoptimizer: 0.22.0
|
||||||
|
|
||||||
'@types/use-sync-external-store@0.0.6': {}
|
'@types/use-sync-external-store@0.0.6': {}
|
||||||
|
|
||||||
'@types/webxr@0.5.24': {}
|
'@types/webxr@0.5.24': {}
|
||||||
|
|
@ -5657,6 +5757,8 @@ snapshots:
|
||||||
loupe: 3.2.1
|
loupe: 3.2.1
|
||||||
tinyrainbow: 1.2.0
|
tinyrainbow: 1.2.0
|
||||||
|
|
||||||
|
'@webgpu/types@0.1.65': {}
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.15.0):
|
acorn-jsx@5.3.2(acorn@8.15.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
|
|
@ -5771,6 +5873,8 @@ snapshots:
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
base64-js@0.0.8: {}
|
||||||
|
|
||||||
base64-js@1.5.1: {}
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
better-auth@1.3.26(next@15.5.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
better-auth@1.3.26(next@15.5.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||||
|
|
@ -5814,6 +5918,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
fill-range: 7.1.1
|
fill-range: 7.1.1
|
||||||
|
|
||||||
|
brotli@1.3.3:
|
||||||
|
dependencies:
|
||||||
|
base64-js: 1.5.1
|
||||||
|
|
||||||
buffer@6.0.3:
|
buffer@6.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
|
|
@ -5888,6 +5996,8 @@ snapshots:
|
||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
|
clone@2.1.2: {}
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
|
|
@ -5918,6 +6028,8 @@ snapshots:
|
||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
crypto-js@4.2.0: {}
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
|
|
||||||
d3-array@3.2.4:
|
d3-array@3.2.4:
|
||||||
|
|
@ -6018,6 +6130,8 @@ snapshots:
|
||||||
|
|
||||||
detect-node-es@1.1.0: {}
|
detect-node-es@1.1.0: {}
|
||||||
|
|
||||||
|
dfa@1.2.0: {}
|
||||||
|
|
||||||
doctrine@2.1.0:
|
doctrine@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
|
|
@ -6471,6 +6585,8 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
fflate@0.8.2: {}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 4.0.1
|
flat-cache: 4.0.1
|
||||||
|
|
@ -6491,6 +6607,18 @@ snapshots:
|
||||||
|
|
||||||
flatted@3.3.3: {}
|
flatted@3.3.3: {}
|
||||||
|
|
||||||
|
fontkit@2.0.4:
|
||||||
|
dependencies:
|
||||||
|
'@swc/helpers': 0.5.15
|
||||||
|
brotli: 1.3.3
|
||||||
|
clone: 2.1.2
|
||||||
|
dfa: 1.2.0
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
restructure: 3.0.2
|
||||||
|
tiny-inflate: 1.0.3
|
||||||
|
unicode-properties: 1.4.1
|
||||||
|
unicode-trie: 2.0.0
|
||||||
|
|
||||||
for-each@0.3.5:
|
for-each@0.3.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
is-callable: 1.2.7
|
||||||
|
|
@ -6761,6 +6889,8 @@ snapshots:
|
||||||
|
|
||||||
jose@6.1.0: {}
|
jose@6.1.0: {}
|
||||||
|
|
||||||
|
jpeg-exif@1.1.4: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.0:
|
js-yaml@4.1.0:
|
||||||
|
|
@ -6848,6 +6978,11 @@ snapshots:
|
||||||
lightningcss-win32-arm64-msvc: 1.30.1
|
lightningcss-win32-arm64-msvc: 1.30.1
|
||||||
lightningcss-win32-x64-msvc: 1.30.1
|
lightningcss-win32-x64-msvc: 1.30.1
|
||||||
|
|
||||||
|
linebreak@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
base64-js: 0.0.8
|
||||||
|
unicode-trie: 2.0.0
|
||||||
|
|
||||||
linkify-it@5.0.0:
|
linkify-it@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
uc.micro: 2.1.0
|
uc.micro: 2.1.0
|
||||||
|
|
@ -6891,6 +7026,8 @@ snapshots:
|
||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
|
|
||||||
|
meshoptimizer@0.22.0: {}
|
||||||
|
|
||||||
micromatch@4.0.8:
|
micromatch@4.0.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
|
|
@ -7029,6 +7166,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
|
|
||||||
|
pako@0.2.9: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
|
|
@ -7047,6 +7186,14 @@ snapshots:
|
||||||
|
|
||||||
pathval@2.0.1: {}
|
pathval@2.0.1: {}
|
||||||
|
|
||||||
|
pdfkit@0.17.2:
|
||||||
|
dependencies:
|
||||||
|
crypto-js: 4.2.0
|
||||||
|
fontkit: 2.0.4
|
||||||
|
jpeg-exif: 1.1.4
|
||||||
|
linebreak: 1.1.0
|
||||||
|
png-js: 1.0.0
|
||||||
|
|
||||||
perfect-debounce@1.0.0: {}
|
perfect-debounce@1.0.0: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
@ -7061,6 +7208,8 @@ snapshots:
|
||||||
exsolve: 1.0.7
|
exsolve: 1.0.7
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
png-js@1.0.0: {}
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0: {}
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss@8.4.31:
|
postcss@8.4.31:
|
||||||
|
|
@ -7343,6 +7492,8 @@ snapshots:
|
||||||
path-parse: 1.0.7
|
path-parse: 1.0.7
|
||||||
supports-preserve-symlinks-flag: 1.0.0
|
supports-preserve-symlinks-flag: 1.0.0
|
||||||
|
|
||||||
|
restructure@3.0.2: {}
|
||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
rollup@4.52.4:
|
rollup@4.52.4:
|
||||||
|
|
@ -7610,6 +7761,8 @@ snapshots:
|
||||||
|
|
||||||
three@0.180.0: {}
|
three@0.180.0: {}
|
||||||
|
|
||||||
|
tiny-inflate@1.0.3: {}
|
||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
@ -7706,6 +7859,16 @@ snapshots:
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
|
unicode-properties@1.4.1:
|
||||||
|
dependencies:
|
||||||
|
base64-js: 1.5.1
|
||||||
|
unicode-trie: 2.0.0
|
||||||
|
|
||||||
|
unicode-trie@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
pako: 0.2.9
|
||||||
|
tiny-inflate: 1.0.3
|
||||||
|
|
||||||
unicornstudio-react@1.4.31(next@15.5.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
unicornstudio-react@1.4.31(next@15.5.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,13 @@ enum UserRole {
|
||||||
CUSTOMER
|
CUSTOMER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TicketStatus {
|
enum TicketStatus {
|
||||||
NEW
|
PENDING
|
||||||
OPEN
|
AWAITING_ATTENDANCE
|
||||||
PENDING
|
PAUSED
|
||||||
ON_HOLD
|
RESOLVED
|
||||||
RESOLVED
|
CLOSED
|
||||||
CLOSED
|
}
|
||||||
}
|
|
||||||
|
|
||||||
enum TicketPriority {
|
enum TicketPriority {
|
||||||
LOW
|
LOW
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,23 @@ function slugify(value) {
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_MAP = {
|
||||||
|
NEW: "PENDING",
|
||||||
|
PENDING: "PENDING",
|
||||||
|
OPEN: "AWAITING_ATTENDANCE",
|
||||||
|
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
|
||||||
|
ON_HOLD: "PAUSED",
|
||||||
|
PAUSED: "PAUSED",
|
||||||
|
RESOLVED: "RESOLVED",
|
||||||
|
CLOSED: "CLOSED",
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(status) {
|
||||||
|
if (!status) return "PENDING"
|
||||||
|
const key = String(status).toUpperCase()
|
||||||
|
return STATUS_MAP[key] ?? "PENDING"
|
||||||
|
}
|
||||||
|
|
||||||
async function upsertCompanies(snapshotCompanies) {
|
async function upsertCompanies(snapshotCompanies) {
|
||||||
const map = new Map()
|
const map = new Map()
|
||||||
|
|
||||||
|
|
@ -250,7 +267,7 @@ async function upsertTickets(snapshotTickets, userMap, queueMap, companyMap) {
|
||||||
const data = {
|
const data = {
|
||||||
subject: ticket.subject,
|
subject: ticket.subject,
|
||||||
summary: ticket.summary ?? null,
|
summary: ticket.summary ?? null,
|
||||||
status: (ticket.status ?? "NEW").toUpperCase(),
|
status: normalizeStatus(ticket.status),
|
||||||
priority: (ticket.priority ?? "MEDIUM").toUpperCase(),
|
priority: (ticket.priority ?? "MEDIUM").toUpperCase(),
|
||||||
channel: (ticket.channel ?? "MANUAL").toUpperCase(),
|
channel: (ticket.channel ?? "MANUAL").toUpperCase(),
|
||||||
queueId,
|
queueId,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { AdminUsersManager } from "@/components/admin/admin-users-manager"
|
import { AdminUsersManager } from "@/components/admin/admin-users-manager"
|
||||||
import { AppShell } from "@/components/app-shell"
|
import { AppShell } from "@/components/app-shell"
|
||||||
import { SiteHeader } from "@/components/site-header"
|
import { SiteHeader } from "@/components/site-header"
|
||||||
import { ROLE_OPTIONS, normalizeRole } from "@/lib/authz"
|
import { ROLE_OPTIONS, normalizeRole, type RoleOption } from "@/lib/authz"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
|
import { normalizeInvite, type NormalizedInvite } from "@/server/invite-utils"
|
||||||
|
|
@ -27,7 +27,7 @@ async function loadUsers() {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name ?? "",
|
name: user.name ?? "",
|
||||||
role: normalizeRole(user.role) ?? "agent",
|
role: (normalizeRole(user.role) ?? "agent") as RoleOption,
|
||||||
tenantId: user.tenantId ?? DEFAULT_TENANT_ID,
|
tenantId: user.tenantId ?? DEFAULT_TENANT_ID,
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
updatedAt: user.updatedAt?.toISOString() ?? null,
|
updatedAt: user.updatedAt?.toISOString() ?? null,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { Prisma } from "@prisma/client"
|
||||||
import { ConvexHttpClient } from "convex/browser"
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { assertAdminSession } from "@/lib/auth-server"
|
import { assertAdminSession } from "@/lib/auth-server"
|
||||||
import { env } from "@/lib/env"
|
import { env } from "@/lib/env"
|
||||||
|
|
@ -36,7 +36,8 @@ async function syncInvite(invite: NormalizedInvite) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
|
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await context.params
|
||||||
const session = await assertAdminSession()
|
const session = await assertAdminSession()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
|
|
@ -46,7 +47,7 @@ export async function PATCH(request: Request, { params }: { params: { id: string
|
||||||
const reason = typeof body?.reason === "string" && body.reason.trim() ? body.reason.trim() : null
|
const reason = typeof body?.reason === "string" && body.reason.trim() ? body.reason.trim() : null
|
||||||
|
|
||||||
const invite = await prisma.authInvite.findUnique({
|
const invite = await prisma.authInvite.findUnique({
|
||||||
where: { id: params.id },
|
where: { id },
|
||||||
include: { events: { orderBy: { createdAt: "asc" } } },
|
include: { events: { orderBy: { createdAt: "asc" } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -81,7 +82,7 @@ export async function PATCH(request: Request, { params }: { params: { id: string
|
||||||
data: {
|
data: {
|
||||||
inviteId: invite.id,
|
inviteId: invite.id,
|
||||||
type: "revoked",
|
type: "revoked",
|
||||||
payload: reason ? { reason } : null,
|
payload: reason ? { reason } : Prisma.JsonNull,
|
||||||
actorId: session.user.id ?? null,
|
actorId: session.user.id ?? null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { randomBytes } from "crypto"
|
import { randomBytes } from "crypto"
|
||||||
|
|
||||||
|
import { Prisma } from "@prisma/client"
|
||||||
import { ConvexHttpClient } from "convex/browser"
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { assertAdminSession } from "@/lib/auth-server"
|
import { assertAdminSession } from "@/lib/auth-server"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
|
@ -14,6 +14,13 @@ import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type Norma
|
||||||
|
|
||||||
const DEFAULT_EXPIRATION_DAYS = 7
|
const DEFAULT_EXPIRATION_DAYS = 7
|
||||||
|
|
||||||
|
function toJsonPayload(payload: unknown): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput {
|
||||||
|
if (payload === null || payload === undefined) {
|
||||||
|
return Prisma.JsonNull
|
||||||
|
}
|
||||||
|
return payload as Prisma.InputJsonValue
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRole(input: string | null | undefined): RoleOption {
|
function normalizeRole(input: string | null | undefined): RoleOption {
|
||||||
const role = (input ?? "agent").toLowerCase() as RoleOption
|
const role = (input ?? "agent").toLowerCase() as RoleOption
|
||||||
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
|
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
|
||||||
|
|
@ -52,7 +59,7 @@ async function appendEvent(inviteId: string, type: string, actorId: string | nul
|
||||||
data: {
|
data: {
|
||||||
inviteId,
|
inviteId,
|
||||||
type,
|
type,
|
||||||
payload,
|
payload: toJsonPayload(payload),
|
||||||
actorId,
|
actorId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { randomBytes } from "crypto"
|
||||||
import { hashPassword } from "better-auth/crypto"
|
import { hashPassword } from "better-auth/crypto"
|
||||||
import { ConvexHttpClient } from "convex/browser"
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
// @ts-expect-error Convex generated API lacks type declarations in Next API routes
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { Prisma } from "@prisma/client"
|
||||||
import { hashPassword } from "better-auth/crypto"
|
import { hashPassword } from "better-auth/crypto"
|
||||||
import { ConvexHttpClient } from "convex/browser"
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
// @ts-expect-error Convex generated API lacks types in Next routes
|
|
||||||
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 { env } from "@/lib/env"
|
import { env } from "@/lib/env"
|
||||||
|
|
@ -47,9 +47,10 @@ async function syncInvite(invite: NormalizedInvite) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(_request: Request, { params }: { params: { token: string } }) {
|
export async function GET(_request: Request, context: { params: Promise<{ token: string }> }) {
|
||||||
|
const { token } = await context.params
|
||||||
const invite = await prisma.authInvite.findUnique({
|
const invite = await prisma.authInvite.findUnique({
|
||||||
where: { token: params.token },
|
where: { token },
|
||||||
include: { events: { orderBy: { createdAt: "asc" } } },
|
include: { events: { orderBy: { createdAt: "asc" } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -66,7 +67,7 @@ export async function GET(_request: Request, { params }: { params: { token: stri
|
||||||
data: {
|
data: {
|
||||||
inviteId: invite.id,
|
inviteId: invite.id,
|
||||||
type: status,
|
type: status,
|
||||||
payload: null,
|
payload: Prisma.JsonNull,
|
||||||
actorId: null,
|
actorId: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -80,7 +81,8 @@ export async function GET(_request: Request, { params }: { params: { token: stri
|
||||||
return NextResponse.json({ invite: normalized })
|
return NextResponse.json({ invite: normalized })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request, { params }: { params: { token: string } }) {
|
export async function POST(request: Request, context: { params: Promise<{ token: string }> }) {
|
||||||
|
const { token } = await context.params
|
||||||
const payload = (await request.json().catch(() => null)) as Partial<AcceptInvitePayload> | null
|
const payload = (await request.json().catch(() => null)) as Partial<AcceptInvitePayload> | null
|
||||||
if (!payload || typeof payload.password !== "string") {
|
if (!payload || typeof payload.password !== "string") {
|
||||||
return NextResponse.json({ error: "Senha inválida" }, { status: 400 })
|
return NextResponse.json({ error: "Senha inválida" }, { status: 400 })
|
||||||
|
|
@ -91,7 +93,7 @@ export async function POST(request: Request, { params }: { params: { token: stri
|
||||||
}
|
}
|
||||||
|
|
||||||
const invite = await prisma.authInvite.findUnique({
|
const invite = await prisma.authInvite.findUnique({
|
||||||
where: { token: params.token },
|
where: { token },
|
||||||
include: { events: { orderBy: { createdAt: "asc" } } },
|
include: { events: { orderBy: { createdAt: "asc" } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -108,7 +110,7 @@ export async function POST(request: Request, { params }: { params: { token: stri
|
||||||
data: {
|
data: {
|
||||||
inviteId: invite.id,
|
inviteId: invite.id,
|
||||||
type: "expired",
|
type: "expired",
|
||||||
payload: null,
|
payload: Prisma.JsonNull,
|
||||||
actorId: null,
|
actorId: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
261
src/app/api/tickets/[id]/export/pdf/route.ts
Normal file
261
src/app/api/tickets/[id]/export/pdf/route.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import PDFDocument from "pdfkit"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
import { ptBR } from "date-fns/locale"
|
||||||
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
import { env } from "@/lib/env"
|
||||||
|
import { assertStaffSession } from "@/lib/auth-server"
|
||||||
|
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
PENDING: "Pendente",
|
||||||
|
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||||
|
PAUSED: "Pausado",
|
||||||
|
RESOLVED: "Resolvido",
|
||||||
|
CLOSED: "Fechado",
|
||||||
|
}
|
||||||
|
|
||||||
|
const timelineLabel: Record<string, string> = {
|
||||||
|
CREATED: "Chamado criado",
|
||||||
|
STATUS_CHANGED: "Status atualizado",
|
||||||
|
ASSIGNEE_CHANGED: "Responsável alterado",
|
||||||
|
COMMENT_ADDED: "Novo comentário",
|
||||||
|
COMMENT_EDITED: "Comentário editado",
|
||||||
|
ATTACHMENT_REMOVED: "Anexo removido",
|
||||||
|
QUEUE_CHANGED: "Fila alterada",
|
||||||
|
PRIORITY_CHANGED: "Prioridade alterada",
|
||||||
|
WORK_STARTED: "Atendimento iniciado",
|
||||||
|
WORK_PAUSED: "Atendimento pausado",
|
||||||
|
CATEGORY_CHANGED: "Categoria alterada",
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(date: Date | null | undefined) {
|
||||||
|
if (!date) return "—"
|
||||||
|
return format(date, "dd/MM/yyyy HH:mm", { locale: ptBR })
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlToPlainText(html?: string | null) {
|
||||||
|
if (!html) return ""
|
||||||
|
const withBreaks = html
|
||||||
|
.replace(/<\s*br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/p>/gi, "\n\n")
|
||||||
|
const stripped = withBreaks.replace(/<[^>]+>/g, "")
|
||||||
|
return decodeHtmlEntities(stripped).replace(/\u00A0/g, " ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(input: string) {
|
||||||
|
return input
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id: ticketId } = await context.params
|
||||||
|
const session = await assertStaffSession()
|
||||||
|
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 PDF export", 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let ticketRaw: unknown
|
||||||
|
try {
|
||||||
|
ticketRaw = await client.query(api.tickets.getById, {
|
||||||
|
tenantId,
|
||||||
|
id: ticketId as unknown as Id<"tickets">,
|
||||||
|
viewerId: viewerId as unknown as Id<"users">,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load ticket from Convex for PDF export", error, {
|
||||||
|
tenantId,
|
||||||
|
ticketId,
|
||||||
|
viewerId,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ error: "Falha ao carregar ticket no Convex" }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ticketRaw) {
|
||||||
|
return NextResponse.json({ error: "Ticket não encontrado" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = mapTicketWithDetailsFromServer(ticketRaw)
|
||||||
|
const doc = new PDFDocument({ size: "A4", margin: 48 })
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
|
||||||
|
doc.on("data", (chunk) => {
|
||||||
|
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pdfBufferPromise = new Promise<Buffer>((resolve, reject) => {
|
||||||
|
doc.on("end", () => resolve(Buffer.concat(chunks)))
|
||||||
|
doc.on("error", reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.font("Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference} — ${ticket.subject}`)
|
||||||
|
doc.moveDown(0.5)
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(11)
|
||||||
|
.text(`Status: ${statusLabel[ticket.status] ?? ticket.status}`)
|
||||||
|
.moveDown(0.15)
|
||||||
|
.text(`Prioridade: ${ticket.priority}`)
|
||||||
|
.moveDown(0.15)
|
||||||
|
.text(`Canal: ${ticket.channel}`)
|
||||||
|
.moveDown(0.15)
|
||||||
|
.text(`Fila: ${ticket.queue ?? "—"}`)
|
||||||
|
|
||||||
|
doc.moveDown(0.75)
|
||||||
|
doc
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(12)
|
||||||
|
.text("Solicitante")
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(11)
|
||||||
|
.text(`${ticket.requester.name} (${ticket.requester.email})`)
|
||||||
|
|
||||||
|
doc.moveDown(0.5)
|
||||||
|
doc.font("Helvetica-Bold").fontSize(12).text("Responsável")
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(11)
|
||||||
|
.text(ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído")
|
||||||
|
|
||||||
|
doc.moveDown(0.75)
|
||||||
|
doc.font("Helvetica-Bold").fontSize(12).text("Datas")
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(11)
|
||||||
|
.text(`Criado em: ${formatDateTime(ticket.createdAt)}`)
|
||||||
|
.moveDown(0.15)
|
||||||
|
.text(`Atualizado em: ${formatDateTime(ticket.updatedAt)}`)
|
||||||
|
.moveDown(0.15)
|
||||||
|
.text(`Resolvido em: ${formatDateTime(ticket.resolvedAt ?? null)}`)
|
||||||
|
|
||||||
|
if (ticket.summary) {
|
||||||
|
doc.moveDown(0.75)
|
||||||
|
doc.font("Helvetica-Bold").fontSize(12).text("Resumo")
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(11)
|
||||||
|
.text(ticket.summary, { align: "justify" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.description) {
|
||||||
|
doc.moveDown(0.75)
|
||||||
|
doc.font("Helvetica-Bold").fontSize(12).text("Descrição")
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(11)
|
||||||
|
.text(htmlToPlainText(ticket.description), { align: "justify" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.comments.length > 0) {
|
||||||
|
doc.addPage()
|
||||||
|
doc.font("Helvetica-Bold").fontSize(14).text("Comentários")
|
||||||
|
doc.moveDown(0.5)
|
||||||
|
const commentsSorted = [...ticket.comments].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||||
|
commentsSorted.forEach((comment, index) => {
|
||||||
|
const visibility =
|
||||||
|
comment.visibility === "PUBLIC" ? "Público" : "Interno"
|
||||||
|
doc
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(11)
|
||||||
|
.text(
|
||||||
|
`${comment.author.name} • ${visibility} • ${formatDateTime(comment.createdAt)}`
|
||||||
|
)
|
||||||
|
const body = htmlToPlainText(comment.body)
|
||||||
|
if (body) {
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(11)
|
||||||
|
.text(body, { align: "justify" })
|
||||||
|
}
|
||||||
|
if (comment.attachments.length > 0) {
|
||||||
|
doc.moveDown(0.25)
|
||||||
|
doc.font("Helvetica").fontSize(10).text("Anexos:")
|
||||||
|
comment.attachments.forEach((attachment) => {
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(10)
|
||||||
|
.text(`• ${attachment.name ?? attachment.id}`, { indent: 12 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (index < commentsSorted.length - 1) {
|
||||||
|
doc.moveDown(0.75)
|
||||||
|
doc
|
||||||
|
.strokeColor("#E2E8F0")
|
||||||
|
.moveTo(doc.x, doc.y)
|
||||||
|
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||||
|
.stroke()
|
||||||
|
doc.moveDown(0.75)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.timeline.length > 0) {
|
||||||
|
doc.addPage()
|
||||||
|
doc.font("Helvetica-Bold").fontSize(14).text("Linha do tempo")
|
||||||
|
doc.moveDown(0.5)
|
||||||
|
const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||||
|
timelineSorted.forEach((event) => {
|
||||||
|
const label = timelineLabel[event.type] ?? event.type
|
||||||
|
doc
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(11)
|
||||||
|
.text(`${label} • ${formatDateTime(event.createdAt)}`)
|
||||||
|
if (event.payload) {
|
||||||
|
const payloadText = JSON.stringify(event.payload, null, 2)
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(10)
|
||||||
|
.text(payloadText, { indent: 12 })
|
||||||
|
}
|
||||||
|
doc.moveDown(0.5)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end()
|
||||||
|
const pdfBuffer = await pdfBufferPromise
|
||||||
|
const pdfBytes = new Uint8Array(pdfBuffer)
|
||||||
|
|
||||||
|
return new NextResponse(pdfBytes, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `attachment; filename="ticket-${ticket.reference}.pdf"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { AppShell } from "@/components/app-shell"
|
import { AppShell } from "@/components/app-shell"
|
||||||
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
|
|
||||||
import { SectionCards } from "@/components/section-cards"
|
import { SectionCards } from "@/components/section-cards"
|
||||||
import { SiteHeader } from "@/components/site-header"
|
import { SiteHeader } from "@/components/site-header"
|
||||||
import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel"
|
import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel"
|
||||||
|
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation } from "convex/react";
|
import { useMutation } from "convex/react";
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
|
|
||||||
export default function SeedPage() {
|
export default function SeedPage() {
|
||||||
|
|
|
||||||
73
src/app/login/login-page-client.tsx
Normal file
73
src/app/login/login-page-client.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import dynamic from "next/dynamic"
|
||||||
|
import { GalleryVerticalEnd } from "lucide-react"
|
||||||
|
|
||||||
|
import { LoginForm } from "@/components/login-form"
|
||||||
|
import { useSession } from "@/lib/auth-client"
|
||||||
|
|
||||||
|
const ShaderBackground = dynamic(
|
||||||
|
() => import("@/components/background-paper-shaders-wrapper"),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
export function LoginPageClient() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const { data: session, isPending } = useSession()
|
||||||
|
const callbackUrl = searchParams?.get("callbackUrl") ?? undefined
|
||||||
|
const [isHydrated, setIsHydrated] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPending) return
|
||||||
|
if (!session?.user) return
|
||||||
|
const destination = callbackUrl ?? "/dashboard"
|
||||||
|
router.replace(destination)
|
||||||
|
}, [callbackUrl, isPending, router, session?.user])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsHydrated(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const shouldDisable = !isHydrated || isPending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-svh lg:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-6 p-6 md:p-10">
|
||||||
|
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||||
|
<Link href="/" className="flex items-center gap-2 text-xl font-semibold text-neutral-900">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
|
||||||
|
<GalleryVerticalEnd className="size-4" />
|
||||||
|
</div>
|
||||||
|
Sistema de chamados
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<LoginForm callbackUrl={callbackUrl} disabled={shouldDisable} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Image
|
||||||
|
src="/rever-8.png"
|
||||||
|
alt="Logotipo Rever Tecnologia"
|
||||||
|
width={110}
|
||||||
|
height={110}
|
||||||
|
className="h-[3.45rem] w-auto"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<footer className="flex justify-center text-sm text-neutral-500">
|
||||||
|
Desenvolvido por Esdras Renan
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<div className="relative hidden overflow-hidden lg:flex">
|
||||||
|
<ShaderBackground className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,74 +1,11 @@
|
||||||
"use client"
|
import { Suspense } from "react"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { LoginPageClient } from "./login-page-client"
|
||||||
import Image from "next/image"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
|
||||||
import { GalleryVerticalEnd } from "lucide-react"
|
|
||||||
|
|
||||||
import { LoginForm } from "@/components/login-form"
|
|
||||||
import { useSession } from "@/lib/auth-client"
|
|
||||||
import dynamic from "next/dynamic"
|
|
||||||
|
|
||||||
const ShaderBackground = dynamic(
|
|
||||||
() => import("@/components/background-paper-shaders-wrapper"),
|
|
||||||
{ ssr: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const { data: session, isPending } = useSession()
|
|
||||||
const callbackUrl = searchParams?.get("callbackUrl") ?? undefined
|
|
||||||
const [isHydrated, setIsHydrated] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPending) return
|
|
||||||
if (!session?.user) return
|
|
||||||
const destination = callbackUrl ?? "/dashboard"
|
|
||||||
router.replace(destination)
|
|
||||||
}, [callbackUrl, isPending, router, session?.user])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsHydrated(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const shouldDisable = !isHydrated || isPending
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-svh lg:grid-cols-2">
|
<Suspense fallback={<div className="flex min-h-svh items-center justify-center">Carregando…</div>}>
|
||||||
<div className="flex flex-col gap-6 p-6 md:p-10">
|
<LoginPageClient />
|
||||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
</Suspense>
|
||||||
<Link href="/" className="flex items-center gap-2 text-xl font-semibold text-neutral-900">
|
|
||||||
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
|
|
||||||
<GalleryVerticalEnd className="size-4" />
|
|
||||||
</div>
|
|
||||||
Sistema de chamados
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 items-center justify-center">
|
|
||||||
<div className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
|
||||||
<LoginForm callbackUrl={callbackUrl} disabled={shouldDisable} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Image
|
|
||||||
src="/rever-8.png"
|
|
||||||
alt="Logotipo Rever Tecnologia"
|
|
||||||
width={110}
|
|
||||||
height={110}
|
|
||||||
className="h-[3.45rem] w-auto"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<footer className="flex justify-center text-sm text-neutral-500">
|
|
||||||
Desenvolvido por Esdras Renan
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
<div className="relative hidden overflow-hidden lg:flex">
|
|
||||||
<ShaderBackground className="h-full w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import type { Doc, Id } from "@/convex/_generated/dataModel"
|
||||||
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
|
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
// @ts-expect-error Convex runtime API lacks generated types
|
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { IconAdjustments, IconForms, IconListDetails, IconTypography } from "@tabler/icons-react"
|
import { IconAdjustments, IconForms, IconListDetails, IconTypography } from "@tabler/icons-react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { 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"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { IconInbox, IconHierarchy2, IconLink, IconPlus } from "@tabler/icons-react"
|
import { IconInbox, IconHierarchy2, IconLink, IconPlus } from "@tabler/icons-react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { 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"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { IconAlarm, IconBolt, IconTargetArrow } from "@tabler/icons-react"
|
import { IconAlarm, IconBolt, IconTargetArrow } from "@tabler/icons-react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { IconUsersGroup, IconCalendarClock, IconSettings, IconUserPlus } from "@tabler/icons-react"
|
import { IconUsersGroup, IconCalendarClock, IconSettings, IconUserPlus } from "@tabler/icons-react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ReactNode } from "react"
|
import { Suspense, type ReactNode } from "react"
|
||||||
|
|
||||||
import { AppSidebar } from "@/components/app-sidebar"
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
import { AuthGuard } from "@/components/auth/auth-guard"
|
import { AuthGuard } from "@/components/auth/auth-guard"
|
||||||
|
|
@ -14,7 +14,9 @@ export function AppShell({ header, children }: AppShellProps) {
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<AuthGuard />
|
<Suspense fallback={null}>
|
||||||
|
<AuthGuard />
|
||||||
|
</Suspense>
|
||||||
{header}
|
{header}
|
||||||
<main className="flex flex-1 flex-col gap-8 bg-gradient-to-br from-background via-background to-primary/10 pb-12 pt-6">
|
<main className="flex flex-1 flex-col gap-8 bg-gradient-to-br from-background via-background to-primary/10 pb-12 pt-6">
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,18 @@
|
||||||
|
|
||||||
import { MeshGradient } from "@paper-design/shaders-react"
|
import { MeshGradient } from "@paper-design/shaders-react"
|
||||||
|
|
||||||
export default function BackgroundPaperShadersWrapper() {
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export default function BackgroundPaperShadersWrapper({ className }: { className?: string }) {
|
||||||
const speed = 1.0
|
const speed = 1.0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full bg-black relative overflow-hidden">
|
<div className={cn("relative h-full w-full overflow-hidden bg-black", className)}>
|
||||||
<MeshGradient
|
<MeshGradient
|
||||||
className="w-full h-full absolute inset-0"
|
className="absolute inset-0 h-full w-full"
|
||||||
colors={["#000000", "#1a1a1a", "#333333", "#ffffff"]}
|
colors={["#000000", "#1a1a1a", "#333333", "#ffffff"]}
|
||||||
speed={speed * 0.5}
|
speed={speed * 0.5}
|
||||||
wireframe="true"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,16 @@ export function EnergyRing({
|
||||||
useFrame((state) => {
|
useFrame((state) => {
|
||||||
if (mesh.current) {
|
if (mesh.current) {
|
||||||
mesh.current.rotation.z = state.clock.elapsedTime
|
mesh.current.rotation.z = state.clock.elapsedTime
|
||||||
mesh.current.material.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3
|
const material = mesh.current.material
|
||||||
|
if (Array.isArray(material)) {
|
||||||
|
material.forEach((mat) => {
|
||||||
|
if ("opacity" in mat) {
|
||||||
|
mat.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (material && "opacity" in material) {
|
||||||
|
material.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import * as React from "react"
|
||||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||||
|
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
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"
|
||||||
|
|
@ -39,17 +38,22 @@ import {
|
||||||
|
|
||||||
export const description = "Distribuição semanal de tickets por canal"
|
export const description = "Distribuição semanal de tickets por canal"
|
||||||
|
|
||||||
export function ChartAreaInteractive() {
|
export function ChartAreaInteractive() {
|
||||||
const isMobile = useIsMobile()
|
const [mounted, setMounted] = React.useState(false)
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const [timeRange, setTimeRange] = React.useState("7d")
|
const [timeRange, setTimeRange] = React.useState("7d")
|
||||||
const { session, convexUserId } = useAuth()
|
const { session, convexUserId } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isMobile) {
|
setMounted(true)
|
||||||
setTimeRange("7d")
|
}, [])
|
||||||
}
|
|
||||||
}, [isMobile])
|
React.useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setTimeRange("7d")
|
||||||
|
}
|
||||||
|
}, [isMobile])
|
||||||
|
|
||||||
const report = useQuery(
|
const report = useQuery(
|
||||||
api.reports.ticketsByChannel,
|
api.reports.ticketsByChannel,
|
||||||
|
|
@ -72,7 +76,7 @@ export function ChartAreaInteractive() {
|
||||||
)
|
)
|
||||||
|
|
||||||
const chartConfig = React.useMemo(() => {
|
const chartConfig = React.useMemo(() => {
|
||||||
const entries = channels.map((channel, index) => [
|
const entries = channels.map((channel: string, index: number) => [
|
||||||
channel,
|
channel,
|
||||||
{
|
{
|
||||||
label: channel
|
label: channel
|
||||||
|
|
@ -87,7 +91,7 @@ export function ChartAreaInteractive() {
|
||||||
|
|
||||||
const chartData = React.useMemo(() => {
|
const chartData = React.useMemo(() => {
|
||||||
if (!report?.points) return []
|
if (!report?.points) return []
|
||||||
return report.points.map((point) => {
|
return report.points.map((point: { date: string; values: Record<string, number> }) => {
|
||||||
const entry: Record<string, number | string> = { date: point.date }
|
const entry: Record<string, number | string> = { date: point.date }
|
||||||
for (const channel of channels) {
|
for (const channel of channels) {
|
||||||
entry[channel] = point.values[channel] ?? 0
|
entry[channel] = point.values[channel] ?? 0
|
||||||
|
|
@ -95,6 +99,14 @@ export function ChartAreaInteractive() {
|
||||||
return entry
|
return entry
|
||||||
})
|
})
|
||||||
}, [channels, report])
|
}, [channels, report])
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[250px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
|
||||||
|
Carregando gráfico...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="@container/card">
|
<Card className="@container/card">
|
||||||
|
|
@ -156,7 +168,7 @@ export function ChartAreaInteractive() {
|
||||||
>
|
>
|
||||||
<AreaChart data={chartData}>
|
<AreaChart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
{channels.map((channel) => (
|
{channels.map((channel: string) => (
|
||||||
<linearGradient key={channel} id={`fill-${channel}`} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient key={channel} id={`fill-${channel}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop
|
<stop
|
||||||
offset="5%"
|
offset="5%"
|
||||||
|
|
@ -203,7 +215,7 @@ export function ChartAreaInteractive() {
|
||||||
{channels
|
{channels
|
||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((channel) => (
|
.map((channel: string) => (
|
||||||
<Area
|
<Area
|
||||||
key={channel}
|
key={channel}
|
||||||
dataKey={channel}
|
dataKey={channel}
|
||||||
|
|
@ -212,7 +224,11 @@ export function ChartAreaInteractive() {
|
||||||
stroke={chartConfig[channel]?.color ?? "var(--chart-1)"}
|
stroke={chartConfig[channel]?.color ?? "var(--chart-1)"}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
stackId="a"
|
stackId="a"
|
||||||
name={chartConfig[channel]?.label ?? channel}
|
name={
|
||||||
|
typeof chartConfig[channel]?.label === "string"
|
||||||
|
? (chartConfig[channel]?.label as string)
|
||||||
|
: channel
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
|
|
@ -221,4 +237,6 @@ export function ChartAreaInteractive() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ChartAreaInteractive
|
||||||
|
|
|
||||||
|
|
@ -12,19 +12,17 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const statusLabel: Record<Ticket["status"], string> = {
|
const statusLabel: Record<Ticket["status"], string> = {
|
||||||
NEW: "Novo",
|
|
||||||
OPEN: "Aberto",
|
|
||||||
PENDING: "Pendente",
|
PENDING: "Pendente",
|
||||||
ON_HOLD: "Em espera",
|
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||||
|
PAUSED: "Pausado",
|
||||||
RESOLVED: "Resolvido",
|
RESOLVED: "Resolvido",
|
||||||
CLOSED: "Fechado",
|
CLOSED: "Fechado",
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusTone: Record<Ticket["status"], string> = {
|
const statusTone: Record<Ticket["status"], string> = {
|
||||||
NEW: "bg-slate-200 text-slate-800",
|
PENDING: "bg-slate-200 text-slate-800",
|
||||||
OPEN: "bg-sky-100 text-sky-700",
|
AWAITING_ATTENDANCE: "bg-sky-100 text-sky-700",
|
||||||
PENDING: "bg-amber-100 text-amber-700",
|
PAUSED: "bg-violet-100 text-violet-700",
|
||||||
ON_HOLD: "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",
|
CLOSED: "bg-slate-100 text-slate-600",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { format, formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { MessageCircle } from "lucide-react"
|
import { MessageCircle } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
|
||||||
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 { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
|
@ -23,10 +22,9 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
||||||
|
|
||||||
const statusLabel: Record<TicketWithDetails["status"], string> = {
|
const statusLabel: Record<TicketWithDetails["status"], string> = {
|
||||||
NEW: "Novo",
|
|
||||||
OPEN: "Aberto",
|
|
||||||
PENDING: "Pendente",
|
PENDING: "Pendente",
|
||||||
ON_HOLD: "Em espera",
|
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||||
|
PAUSED: "Pausado",
|
||||||
RESOLVED: "Resolvido",
|
RESOLVED: "Resolvido",
|
||||||
CLOSED: "Fechado",
|
CLOSED: "Fechado",
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +124,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!convexUserId || !comment.trim()) return
|
if (!convexUserId || !comment.trim() || !ticket) return
|
||||||
const toastId = "portal-add-comment"
|
const toastId = "portal-add-comment"
|
||||||
toast.loading("Enviando comentário...", { id: toastId })
|
toast.loading("Enviando comentário...", { id: toastId })
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useMutation } from "convex/react"
|
import { useMutation } from "convex/react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
|
||||||
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 { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
|
||||||
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 { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { useMemo } from "react"
|
import { useMemo } 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"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
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"
|
||||||
|
|
@ -20,12 +19,11 @@ const PRIORITY_LABELS: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
NEW: "Novo",
|
PENDING: "Pendentes",
|
||||||
OPEN: "Em andamento",
|
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||||
PENDING: "Pendente",
|
PAUSED: "Pausados",
|
||||||
ON_HOLD: "Em espera",
|
RESOLVED: "Resolvidos",
|
||||||
RESOLVED: "Resolvido",
|
CLOSED: "Encerrados",
|
||||||
CLOSED: "Encerrado",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BacklogReport() {
|
export function BacklogReport() {
|
||||||
|
|
@ -38,7 +36,7 @@ export function BacklogReport() {
|
||||||
|
|
||||||
const mostCriticalPriority = useMemo(() => {
|
const mostCriticalPriority = useMemo(() => {
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
const entries = Object.entries(data.priorityCounts)
|
const entries = Object.entries(data.priorityCounts) as Array<[string, number]>
|
||||||
if (entries.length === 0) return null
|
if (entries.length === 0) return null
|
||||||
return entries.reduce((prev, current) => (current[1] > prev[1] ? current : prev))
|
return entries.reduce((prev, current) => (current[1] > prev[1] ? current : prev))
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
@ -104,7 +102,7 @@ export function BacklogReport() {
|
||||||
</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">
|
||||||
{Object.entries(data.statusCounts).map(([status, total]) => (
|
{(Object.entries(data.statusCounts) as Array<[string, number]>).map(([status, total]) => (
|
||||||
<div key={status} className="rounded-xl border border-slate-200 p-4">
|
<div key={status} className="rounded-xl border border-slate-200 p-4">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
{STATUS_LABELS[status] ?? status}
|
{STATUS_LABELS[status] ?? status}
|
||||||
|
|
@ -125,7 +123,7 @@ export function BacklogReport() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{Object.entries(data.priorityCounts).map(([priority, total]) => (
|
{(Object.entries(data.priorityCounts) as Array<[string, number]>).map(([priority, total]) => (
|
||||||
<div key={priority} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
<div key={priority} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||||
<span className="text-sm font-medium text-neutral-800">
|
<span className="text-sm font-medium text-neutral-800">
|
||||||
{PRIORITY_LABELS[priority] ?? priority}
|
{PRIORITY_LABELS[priority] ?? priority}
|
||||||
|
|
@ -153,7 +151,7 @@ export function BacklogReport() {
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{data.queueCounts.map((queue) => (
|
{data.queueCounts.map((queue: { id: string; name: string; total: number }) => (
|
||||||
<li key={queue.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
<li key={queue.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>
|
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
import { IconMoodSmile, IconStars, IconMessageCircle2 } from "@tabler/icons-react"
|
import { IconMoodSmile, IconStars, IconMessageCircle2 } from "@tabler/icons-react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { 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"
|
||||||
|
|
@ -68,7 +67,7 @@ export function CsatReport() {
|
||||||
{data.recent.length === 0 ? (
|
{data.recent.length === 0 ? (
|
||||||
<p className="text-sm text-neutral-500">Ainda não coletamos nenhuma avaliação.</p>
|
<p className="text-sm text-neutral-500">Ainda não coletamos nenhuma avaliação.</p>
|
||||||
) : (
|
) : (
|
||||||
data.recent.map((item) => (
|
data.recent.map((item: { ticketId: string; reference: number; score: number; receivedAt: number }) => (
|
||||||
<div key={`${item.ticketId}-${item.receivedAt}`} className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
<div key={`${item.ticketId}-${item.receivedAt}`} className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||||
<span>#{item.reference}</span>
|
<span>#{item.reference}</span>
|
||||||
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
||||||
|
|
@ -90,7 +89,7 @@ export function CsatReport() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{data.distribution.map((entry) => (
|
{data.distribution.map((entry: { score: number; total: number }) => (
|
||||||
<li key={entry.score} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
<li key={entry.score} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
import { IconAlertTriangle, IconGraph, IconClockHour4 } from "@tabler/icons-react"
|
import { IconAlertTriangle, IconGraph, IconClockHour4 } from "@tabler/icons-react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { 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"
|
||||||
|
|
@ -29,7 +28,10 @@ export function SlaReport() {
|
||||||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
)
|
)
|
||||||
|
|
||||||
const queueTotal = useMemo(() => data?.queueBreakdown.reduce((acc, queue) => acc + queue.open, 0) ?? 0, [data])
|
const queueTotal = useMemo(
|
||||||
|
() => data?.queueBreakdown.reduce((acc: number, queue: { open: number }) => acc + queue.open, 0) ?? 0,
|
||||||
|
[data]
|
||||||
|
)
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -104,7 +106,7 @@ export function SlaReport() {
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{data.queueBreakdown.map((queue) => (
|
{data.queueBreakdown.map((queue: { id: string; name: string; open: number }) => (
|
||||||
<li key={queue.id} className="flex items-center justify-between gap-4 rounded-xl border border-slate-200 px-4 py-3">
|
<li key={queue.id} className="flex items-center justify-between gap-4 rounded-xl border border-slate-200 px-4 py-3">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>
|
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
|
import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { 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"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useMemo, useState } from "react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { IconFileText, IconPlus, IconTrash, IconX } from "@tabler/icons-react"
|
import { IconFileText, IconPlus, IconTrash, IconX } from "@tabler/icons-react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useMutation } from "convex/react"
|
import { useMutation } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TS declarations until build
|
|
||||||
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { useEffect, useMemo, useState } from "react"
|
||||||
import type { Doc, Id } from "@/convex/_generated/dataModel"
|
import type { Doc, Id } from "@/convex/_generated/dataModel"
|
||||||
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
|
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
|
||||||
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 { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
|
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
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 { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useMutation } from "convex/react"
|
import { useMutation } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
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 type { TicketPriority } from "@/lib/schemas/ticket"
|
import type { TicketPriority } from "@/lib/schemas/ticket"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import Link from "next/link"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TS declarations
|
|
||||||
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 { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,9 @@ import { Badge } from "@/components/ui/badge"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const statusStyles: Record<TicketStatus, { label: string; className: string }> = {
|
const statusStyles: Record<TicketStatus, { label: string; className: string }> = {
|
||||||
NEW: { label: "Novo", className: "border border-slate-200 bg-slate-100 text-slate-700" },
|
PENDING: { label: "Pendente", className: "border border-slate-200 bg-slate-100 text-slate-700" },
|
||||||
OPEN: { label: "Aberto", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
||||||
PENDING: { label: "Pendente", className: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
|
PAUSED: { label: "Pausado", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
|
||||||
ON_HOLD: { label: "Em espera", 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" },
|
CLOSED: { label: "Fechado", className: "border border-slate-200 bg-slate-200 text-slate-700" },
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useMutation } from "convex/react"
|
import { useMutation } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
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 type { TicketStatus } from "@/lib/schemas/ticket"
|
import type { TicketStatus } from "@/lib/schemas/ticket"
|
||||||
|
|
@ -13,14 +12,20 @@ import { toast } from "sonner"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ChevronDown } from "lucide-react"
|
import { ChevronDown } from "lucide-react"
|
||||||
|
|
||||||
const statusStyles: Record<TicketStatus, { label: string; badgeClass: string }> = {
|
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
|
||||||
NEW: { label: "Novo", badgeClass: "bg-slate-100 text-slate-700" },
|
|
||||||
OPEN: { label: "Aberto", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
const STATUS_OPTIONS: TicketStatus[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED", "CLOSED"];
|
||||||
PENDING: { label: "Pendente", badgeClass: "bg-[#fdebd6] text-[#7b4107]" },
|
|
||||||
ON_HOLD: { label: "Em espera", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
|
const statusStyles: Record<StatusKey, { label: string; badgeClass: string }> = {
|
||||||
|
PENDING: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
||||||
|
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
||||||
|
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" },
|
CLOSED: { label: "Fechado", badgeClass: "bg-slate-200 text-slate-700" },
|
||||||
}
|
NEW: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
|
||||||
|
OPEN: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
|
||||||
|
ON_HOLD: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
|
||||||
|
};
|
||||||
|
|
||||||
const triggerClass =
|
const triggerClass =
|
||||||
"group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden"
|
"group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden"
|
||||||
|
|
@ -53,14 +58,14 @@ export function StatusSelect({ ticketId, value }: { ticketId: string; value: Tic
|
||||||
>
|
>
|
||||||
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
|
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
|
||||||
<SelectValue asChild>
|
<SelectValue asChild>
|
||||||
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass)}>
|
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass ?? statusStyles.PENDING.badgeClass)}>
|
||||||
{statusStyles[status]?.label ?? status}
|
{statusStyles[status]?.label ?? statusStyles.PENDING.label}
|
||||||
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
|
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
|
||||||
</Badge>
|
</Badge>
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||||
{(["NEW", "OPEN", "PENDING", "ON_HOLD", "RESOLVED", "CLOSED"] as const).map((option) => (
|
{STATUS_OPTIONS.map((option) => (
|
||||||
<SelectItem key={option} value={option} className={itemClass}>
|
<SelectItem key={option} value={option} className={itemClass}>
|
||||||
{statusStyles[option].label}
|
{statusStyles[option].label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { ptBR } from "date-fns/locale"
|
||||||
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
|
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
|
||||||
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
|
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
|
||||||
import { useAction, useMutation, useQuery } from "convex/react"
|
import { useAction, useMutation, useQuery } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "convex/react";
|
import { useQuery } from "convex/react";
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
|
||||||
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 { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
|
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
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 type { TicketQueueSummary } from "@/lib/schemas/ticket"
|
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { 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, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
import { IconClock, IconFileDownload, 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"
|
||||||
// @ts-expect-error Convex generates JS module without TS definitions
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
|
@ -20,6 +19,9 @@ import { StatusSelect } from "@/components/tickets/status-select"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
@ -44,6 +46,11 @@ const subtleBadgeClass =
|
||||||
|
|
||||||
const EMPTY_CATEGORY_VALUE = "__none__"
|
const EMPTY_CATEGORY_VALUE = "__none__"
|
||||||
const EMPTY_SUBCATEGORY_VALUE = "__none__"
|
const EMPTY_SUBCATEGORY_VALUE = "__none__"
|
||||||
|
const PAUSE_REASONS = [
|
||||||
|
{ value: "NO_CONTACT", label: "Falta de contato" },
|
||||||
|
{ value: "WAITING_THIRD_PARTY", label: "Aguardando terceiro" },
|
||||||
|
{ value: "IN_PROCEDURE", label: "Em procedimento" },
|
||||||
|
]
|
||||||
|
|
||||||
function formatDuration(durationMs: number) {
|
function formatDuration(durationMs: number) {
|
||||||
if (durationMs <= 0) return "0s"
|
if (durationMs <= 0) return "0s"
|
||||||
|
|
@ -104,6 +111,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [pauseDialogOpen, setPauseDialogOpen] = useState(false)
|
||||||
|
const [pauseReason, setPauseReason] = useState<string>(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
|
||||||
|
const [pauseNote, setPauseNote] = useState("")
|
||||||
|
const [pausing, setPausing] = useState(false)
|
||||||
|
const [exportingPdf, setExportingPdf] = useState(false)
|
||||||
const selectedCategoryId = categorySelection.categoryId
|
const selectedCategoryId = categorySelection.categoryId
|
||||||
const selectedSubcategoryId = categorySelection.subcategoryId
|
const selectedSubcategoryId = categorySelection.subcategoryId
|
||||||
const dirty = useMemo(
|
const dirty = useMemo(
|
||||||
|
|
@ -272,6 +284,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [workSummary?.activeSession])
|
}, [workSummary?.activeSession])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pauseDialogOpen) {
|
||||||
|
setPauseReason(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
|
||||||
|
setPauseNote("")
|
||||||
|
setPausing(false)
|
||||||
|
}
|
||||||
|
}, [pauseDialogOpen])
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -281,6 +301,74 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
[ticket.updatedAt]
|
[ticket.updatedAt]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleStartWork = async () => {
|
||||||
|
if (!convexUserId) return
|
||||||
|
toast.dismiss("work")
|
||||||
|
toast.loading("Iniciando atendimento...", { id: "work" })
|
||||||
|
try {
|
||||||
|
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
|
||||||
|
if (result?.status === "already_started") {
|
||||||
|
toast.info("O atendimento já estava em andamento", { id: "work" })
|
||||||
|
} else {
|
||||||
|
toast.success("Atendimento iniciado", { id: "work" })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Não foi possível atualizar o atendimento", { id: "work" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePauseConfirm = async () => {
|
||||||
|
if (!convexUserId) return
|
||||||
|
toast.dismiss("work")
|
||||||
|
toast.loading("Pausando atendimento...", { id: "work" })
|
||||||
|
setPausing(true)
|
||||||
|
try {
|
||||||
|
const result = await pauseWork({
|
||||||
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
|
actorId: convexUserId as Id<"users">,
|
||||||
|
reason: pauseReason,
|
||||||
|
note: pauseNote.trim() ? pauseNote.trim() : undefined,
|
||||||
|
})
|
||||||
|
if (result?.status === "already_paused") {
|
||||||
|
toast.info("O atendimento já estava pausado", { id: "work" })
|
||||||
|
} else {
|
||||||
|
toast.success("Atendimento pausado", { id: "work" })
|
||||||
|
}
|
||||||
|
setPauseDialogOpen(false)
|
||||||
|
} catch {
|
||||||
|
toast.error("Não foi possível atualizar o atendimento", { id: "work" })
|
||||||
|
} finally {
|
||||||
|
setPausing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportPdf = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setExportingPdf(true)
|
||||||
|
toast.dismiss("ticket-export")
|
||||||
|
toast.loading("Gerando PDF...", { id: "ticket-export" })
|
||||||
|
const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = url
|
||||||
|
link.download = `ticket-${ticket.reference}.pdf`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast.success("PDF exportado com sucesso!", { id: "ticket-export" })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível exportar o PDF.", { id: "ticket-export" })
|
||||||
|
} finally {
|
||||||
|
setExportingPdf(false)
|
||||||
|
}
|
||||||
|
}, [ticket.id, ticket.reference])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
||||||
|
|
@ -294,6 +382,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
Editar
|
Editar
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
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"
|
||||||
|
onClick={handleExportPdf}
|
||||||
|
disabled={exportingPdf}
|
||||||
|
>
|
||||||
|
{exportingPdf ? <Spinner className="size-3 text-neutral-700" /> : <IconFileDownload className="size-4" />}
|
||||||
|
Exportar PDF
|
||||||
|
</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">
|
||||||
|
|
@ -305,28 +403,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className={isPlaying ? pauseButtonClass : startButtonClass}
|
className={isPlaying ? pauseButtonClass : startButtonClass}
|
||||||
onClick={async () => {
|
onClick={() => {
|
||||||
if (!convexUserId) return
|
if (!convexUserId) return
|
||||||
toast.dismiss("work")
|
if (isPlaying) {
|
||||||
toast.loading(isPlaying ? "Pausando atendimento..." : "Iniciando atendimento...", { id: "work" })
|
setPauseDialogOpen(true)
|
||||||
try {
|
} else {
|
||||||
if (isPlaying) {
|
void handleStartWork()
|
||||||
const result = await pauseWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
|
|
||||||
if (result?.status === "already_paused") {
|
|
||||||
toast.info("O atendimento já estava pausado", { id: "work" })
|
|
||||||
} else {
|
|
||||||
toast.success("Atendimento pausado", { id: "work" })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
|
|
||||||
if (result?.status === "already_started") {
|
|
||||||
toast.info("O atendimento já estava em andamento", { id: "work" })
|
|
||||||
} else {
|
|
||||||
toast.success("Atendimento iniciado", { id: "work" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Não foi possível atualizar o atendimento", { id: "work" })
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -539,6 +621,53 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<Dialog open={pauseDialogOpen} onOpenChange={setPauseDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Registrar pausa</DialogTitle>
|
||||||
|
<DialogDescription>Informe o motivo da pausa para registrar no histórico do chamado.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Motivo</span>
|
||||||
|
<Select value={pauseReason} onValueChange={setPauseReason}>
|
||||||
|
<SelectTrigger className={selectTriggerClass}>
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||||
|
{PAUSE_REASONS.map((reason) => (
|
||||||
|
<SelectItem key={reason.value} value={reason.value}>
|
||||||
|
{reason.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Observações</span>
|
||||||
|
<Textarea
|
||||||
|
value={pauseNote}
|
||||||
|
onChange={(event) => setPauseNote(event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Adicione detalhes opcionais (visível apenas internamente)."
|
||||||
|
className="min-h-[96px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setPauseDialogOpen(false)} disabled={pausing}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={pauseButtonClass}
|
||||||
|
onClick={handlePauseConfirm}
|
||||||
|
disabled={pausing || !pauseReason}
|
||||||
|
>
|
||||||
|
{pausing ? <Spinner className="size-4 text-white" /> : "Registrar pausa"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import type { ComponentType } from "react"
|
import type { ComponentType, ReactNode } from "react"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import {
|
import {
|
||||||
IconClockHour4,
|
IconClockHour4,
|
||||||
|
|
@ -119,9 +119,12 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
sessionDurationMs?: number
|
sessionDurationMs?: number
|
||||||
categoryName?: string
|
categoryName?: string
|
||||||
subcategoryName?: string
|
subcategoryName?: string
|
||||||
|
pauseReason?: string
|
||||||
|
pauseReasonLabel?: string
|
||||||
|
pauseNote?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
let message: string | null = null
|
let message: ReactNode = null
|
||||||
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
|
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
|
||||||
message = "Status alterado para " + (payload.toLabel || payload.to)
|
message = "Status alterado para " + (payload.toLabel || payload.to)
|
||||||
}
|
}
|
||||||
|
|
@ -153,8 +156,22 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
if (entry.type === "ATTACHMENT_REMOVED" && payload.attachmentName) {
|
if (entry.type === "ATTACHMENT_REMOVED" && payload.attachmentName) {
|
||||||
message = `Anexo removido: ${payload.attachmentName}`
|
message = `Anexo removido: ${payload.attachmentName}`
|
||||||
}
|
}
|
||||||
if (entry.type === "WORK_PAUSED" && typeof payload.sessionDurationMs === "number") {
|
if (entry.type === "WORK_PAUSED") {
|
||||||
message = `Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`
|
const parts: string[] = []
|
||||||
|
if (payload.pauseReasonLabel || payload.pauseReason) {
|
||||||
|
parts.push(`Motivo: ${payload.pauseReasonLabel ?? payload.pauseReason}`)
|
||||||
|
}
|
||||||
|
if (typeof payload.sessionDurationMs === "number") {
|
||||||
|
parts.push(`Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`)
|
||||||
|
}
|
||||||
|
message = (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span>{parts.length > 0 ? parts.join(" • ") : "Atendimento pausado"}</span>
|
||||||
|
{payload.pauseNote ? (
|
||||||
|
<span className="block text-xs text-neutral-500">Observação: {payload.pauseNote}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (entry.type === "CATEGORY_CHANGED") {
|
if (entry.type === "CATEGORY_CHANGED") {
|
||||||
if (payload.categoryName || payload.subcategoryName) {
|
if (payload.categoryName || payload.subcategoryName) {
|
||||||
|
|
@ -168,9 +185,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
if (!message) return null
|
if (!message) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-600">
|
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-600">{message}</div>
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { IconFilter, IconRefresh } from "@tabler/icons-react"
|
||||||
import {
|
import {
|
||||||
ticketChannelSchema,
|
ticketChannelSchema,
|
||||||
ticketPrioritySchema,
|
ticketPrioritySchema,
|
||||||
ticketStatusSchema,
|
type TicketStatus,
|
||||||
} from "@/lib/schemas/ticket"
|
} from "@/lib/schemas/ticket"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
@ -24,17 +24,18 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
|
||||||
const statusOptions = ticketStatusSchema.options.map((status) => ({
|
const statusOptions: Array<{ value: TicketStatus; label: string }> = [
|
||||||
value: status,
|
{ value: "PENDING", label: "Pendente" },
|
||||||
label: {
|
{ value: "AWAITING_ATTENDANCE", label: "Aguardando atendimento" },
|
||||||
NEW: "Novo",
|
{ value: "PAUSED", label: "Pausado" },
|
||||||
OPEN: "Aberto",
|
{ value: "RESOLVED", label: "Resolvido" },
|
||||||
PENDING: "Pendente",
|
{ value: "CLOSED", label: "Fechado" },
|
||||||
ON_HOLD: "Em espera",
|
]
|
||||||
RESOLVED: "Resolvido",
|
|
||||||
CLOSED: "Fechado",
|
const statusLabelMap = statusOptions.reduce<Record<TicketStatus, string>>((acc, option) => {
|
||||||
}[status],
|
acc[option.value] = option.label
|
||||||
}))
|
return acc
|
||||||
|
}, {} as Record<TicketStatus, string>)
|
||||||
|
|
||||||
const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
|
const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
|
||||||
value: priority,
|
value: priority,
|
||||||
|
|
@ -62,10 +63,11 @@ type QueueOption = string
|
||||||
|
|
||||||
export type TicketFiltersState = {
|
export type TicketFiltersState = {
|
||||||
search: string
|
search: string
|
||||||
status: string | null
|
status: TicketStatus | null
|
||||||
priority: string | null
|
priority: string | null
|
||||||
queue: string | null
|
queue: string | null
|
||||||
channel: string | null
|
channel: string | null
|
||||||
|
view: "active" | "completed"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultTicketFilters: TicketFiltersState = {
|
export const defaultTicketFilters: TicketFiltersState = {
|
||||||
|
|
@ -74,6 +76,7 @@ export const defaultTicketFilters: TicketFiltersState = {
|
||||||
priority: null,
|
priority: null,
|
||||||
queue: null,
|
queue: null,
|
||||||
channel: null,
|
channel: null,
|
||||||
|
view: "active",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TicketsFiltersProps {
|
interface TicketsFiltersProps {
|
||||||
|
|
@ -97,10 +100,11 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
||||||
|
|
||||||
const activeFilters = useMemo(() => {
|
const activeFilters = useMemo(() => {
|
||||||
const chips: string[] = []
|
const chips: string[] = []
|
||||||
if (filters.status) chips.push(`Status: ${filters.status}`)
|
if (filters.status) chips.push(`Status: ${statusLabelMap[filters.status] ?? filters.status}`)
|
||||||
if (filters.priority) chips.push(`Prioridade: ${filters.priority}`)
|
if (filters.priority) chips.push(`Prioridade: ${filters.priority}`)
|
||||||
if (filters.queue) chips.push(`Fila: ${filters.queue}`)
|
if (filters.queue) chips.push(`Fila: ${filters.queue}`)
|
||||||
if (filters.channel) chips.push(`Canal: ${filters.channel}`)
|
if (filters.channel) chips.push(`Canal: ${filters.channel}`)
|
||||||
|
if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos")
|
||||||
return chips
|
return chips
|
||||||
}, [filters])
|
}, [filters])
|
||||||
|
|
||||||
|
|
@ -132,6 +136,18 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={filters.view}
|
||||||
|
onValueChange={(value) => setPartial({ view: value as TicketFiltersState["view"] })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Em andamento</SelectItem>
|
||||||
|
<SelectItem value="completed">Concluídos</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -150,7 +166,9 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
||||||
</p>
|
</p>
|
||||||
<Select
|
<Select
|
||||||
value={filters.status ?? ALL_VALUE}
|
value={filters.status ?? ALL_VALUE}
|
||||||
onValueChange={(value) => setPartial({ status: value === ALL_VALUE ? null : value })}
|
onValueChange={(value) =>
|
||||||
|
setPartial({ status: value === ALL_VALUE ? null : (value as TicketStatus) })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Todos" />
|
<SelectValue placeholder="Todos" />
|
||||||
|
|
|
||||||
|
|
@ -49,19 +49,17 @@ const tableRowClass =
|
||||||
"group border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-none"
|
"group border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-none"
|
||||||
|
|
||||||
const statusLabel: Record<TicketStatus, string> = {
|
const statusLabel: Record<TicketStatus, string> = {
|
||||||
NEW: "Novo",
|
|
||||||
OPEN: "Aberto",
|
|
||||||
PENDING: "Pendente",
|
PENDING: "Pendente",
|
||||||
ON_HOLD: "Em espera",
|
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||||
|
PAUSED: "Pausado",
|
||||||
RESOLVED: "Resolvido",
|
RESOLVED: "Resolvido",
|
||||||
CLOSED: "Fechado",
|
CLOSED: "Fechado",
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusTone: Record<TicketStatus, string> = {
|
const statusTone: Record<TicketStatus, string> = {
|
||||||
NEW: "text-slate-700",
|
PENDING: "text-slate-700",
|
||||||
OPEN: "text-sky-700",
|
AWAITING_ATTENDANCE: "text-sky-700",
|
||||||
PENDING: "text-amber-700",
|
PAUSED: "text-violet-700",
|
||||||
ON_HOLD: "text-violet-700",
|
|
||||||
RESOLVED: "text-emerald-700",
|
RESOLVED: "text-emerald-700",
|
||||||
CLOSED: "text-slate-600",
|
CLOSED: "text-slate-600",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
|
||||||
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 { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
|
@ -42,9 +41,23 @@ 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(() => {
|
||||||
if (!filters.queue) return tickets
|
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED", "CLOSED"])
|
||||||
return tickets.filter((t: Ticket) => t.queue === filters.queue)
|
let working = tickets
|
||||||
}, [tickets, filters.queue])
|
|
||||||
|
if (!filters.status) {
|
||||||
|
if (filters.view === "active") {
|
||||||
|
working = working.filter((t) => !completedStatuses.has(t.status))
|
||||||
|
} else if (filters.view === "completed") {
|
||||||
|
working = working.filter((t) => completedStatuses.has(t.status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.queue) {
|
||||||
|
working = working.filter((t) => t.queue === filters.queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return working
|
||||||
|
}, [tickets, filters.queue, filters.status, filters.view])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||||
|
|
|
||||||
|
|
@ -16,20 +16,17 @@ const DotOrbit = dynamic(
|
||||||
function ShaderVisual() {
|
function ShaderVisual() {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<MeshGradient
|
<MeshGradient className="absolute inset-0" colors={["#020202", "#04131f", "#062534", "#0b3947"]} speed={0.8} />
|
||||||
className="absolute inset-0"
|
|
||||||
colors={["#020202", "#04131f", "#062534", "#0b3947"]}
|
|
||||||
speed={0.8}
|
|
||||||
backgroundColor="#020202"
|
|
||||||
wireframe="true"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 opacity-70">
|
<div className="absolute inset-0 opacity-70">
|
||||||
<DotOrbit
|
<DotOrbit
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
dotColor="#0f172a"
|
colors={["#0f172a", "#155e75", "#22d3ee"]}
|
||||||
orbitColor="#155e75"
|
colorBack="#020617"
|
||||||
speed={1.4}
|
speed={1.4}
|
||||||
intensity={1.2}
|
size={0.9}
|
||||||
|
sizeRange={0.4}
|
||||||
|
spreading={1.0}
|
||||||
|
stepsPerColor={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute inset-0">
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAction } from "convex/react";
|
import { useAction } from "convex/react";
|
||||||
// @ts-expect-error Convex generates runtime API without TS metadata
|
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
|
||||||
|
|
@ -28,19 +28,10 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
style: baseStyle,
|
style: baseStyle,
|
||||||
classNames: {
|
classNames: {
|
||||||
icon: "size-[14px] text-white [&>svg]:size-[14px] [&>svg]:text-white",
|
icon: "size-[14px] text-white [&>svg]:size-[14px] [&>svg]:text-white",
|
||||||
|
actionButton: "bg-white text-black border border-black rounded-lg",
|
||||||
|
cancelButton: "bg-transparent text-white border border-white/40 rounded-lg",
|
||||||
},
|
},
|
||||||
descriptionClassName: "text-white/80",
|
descriptionClassName: "text-white/80",
|
||||||
actionButtonClassName: "bg-white text-black border border-black rounded-lg",
|
|
||||||
cancelButtonClassName: "bg-transparent text-white border border-white/40 rounded-lg",
|
|
||||||
iconTheme: {
|
|
||||||
primary: "#ffffff",
|
|
||||||
secondary: "#000000",
|
|
||||||
},
|
|
||||||
success: { className: baseClass, style: baseStyle },
|
|
||||||
error: { className: baseClass, style: baseStyle },
|
|
||||||
info: { className: baseClass, style: baseStyle },
|
|
||||||
warning: { className: baseClass, style: baseStyle },
|
|
||||||
loading: { className: baseClass, style: baseStyle },
|
|
||||||
}}
|
}}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
import { useMutation } from "convex/react"
|
import { useMutation } from "convex/react"
|
||||||
// @ts-expect-error Convex runtime API lacks TypeScript declarations
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
||||||
export function useDefaultQueues(tenantId?: string | null) {
|
export function useDefaultQueues(tenantId?: string | null) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef } from "react"
|
import { useEffect, useMemo, useRef } from "react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
// @ts-expect-error Convex generates runtime API without TS declarations
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import type { TicketCategory } from "@/lib/schemas/category"
|
import type { TicketCategory } from "@/lib/schemas/category"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
import { createContext, useContext, useEffect, useMemo, useState } from "react"
|
import { createContext, useContext, useEffect, useMemo, useState } from "react"
|
||||||
import { customSessionClient } from "better-auth/client/plugins"
|
import { customSessionClient } from "better-auth/client/plugins"
|
||||||
import { createAuthClient } from "better-auth/react"
|
import { createAuthClient } from "better-auth/react"
|
||||||
|
import type { AppAuth } from "@/lib/auth"
|
||||||
import { useMutation } from "convex/react"
|
import { useMutation } from "convex/react"
|
||||||
|
|
||||||
// @ts-expect-error Convex generates runtime API without types until build
|
|
||||||
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, isCustomer, isStaff } from "@/lib/authz"
|
||||||
|
|
@ -26,7 +26,7 @@ export type AppSession = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const authClient = createAuthClient({
|
const authClient = createAuthClient({
|
||||||
plugins: [customSessionClient<AppSession>()],
|
plugins: [customSessionClient<AppAuth>()],
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -70,3 +70,5 @@ export const auth = betterAuth({
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export type AppAuth = typeof auth
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,5 @@ export const env = {
|
||||||
BETTER_AUTH_URL: parsed.data.BETTER_AUTH_URL ?? parsed.data.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
BETTER_AUTH_URL: parsed.data.BETTER_AUTH_URL ?? parsed.data.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
||||||
NEXT_PUBLIC_CONVEX_URL: parsed.data.NEXT_PUBLIC_CONVEX_URL,
|
NEXT_PUBLIC_CONVEX_URL: parsed.data.NEXT_PUBLIC_CONVEX_URL,
|
||||||
DATABASE_URL: parsed.data.DATABASE_URL,
|
DATABASE_URL: parsed.data.DATABASE_URL,
|
||||||
|
NEXT_PUBLIC_APP_URL: parsed.data.NEXT_PUBLIC_APP_URL,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ describe("ticket mappers", () => {
|
||||||
expect(ui.createdAt).toBeInstanceOf(Date);
|
expect(ui.createdAt).toBeInstanceOf(Date);
|
||||||
expect(ui.updatedAt).toBeInstanceOf(Date);
|
expect(ui.updatedAt).toBeInstanceOf(Date);
|
||||||
expect(ui.lastTimelineEntry).toBeUndefined();
|
expect(ui.lastTimelineEntry).toBeUndefined();
|
||||||
|
expect(ui.status).toBe("AWAITING_ATTENDANCE");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converte ticket com detalhes", () => {
|
it("converte ticket com detalhes", () => {
|
||||||
|
|
@ -47,6 +48,6 @@ describe("ticket mappers", () => {
|
||||||
});
|
});
|
||||||
expect(ui.timeline[0]!.createdAt).toBeInstanceOf(Date);
|
expect(ui.timeline[0]!.createdAt).toBeInstanceOf(Date);
|
||||||
expect(ui.comments[0]!.createdAt).toBeInstanceOf(Date);
|
expect(ui.comments[0]!.createdAt).toBeInstanceOf(Date);
|
||||||
|
expect(ui.status).toBe("AWAITING_ATTENDANCE");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,24 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ticketSchema, ticketWithDetailsSchema } from "@/lib/schemas/ticket";
|
import { ticketSchema, ticketStatusSchema, ticketWithDetailsSchema } from "@/lib/schemas/ticket";
|
||||||
|
|
||||||
|
type NormalizedTicketStatus = z.infer<typeof ticketStatusSchema>;
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, NormalizedTicketStatus> = {
|
||||||
|
NEW: "PENDING",
|
||||||
|
PENDING: "PENDING",
|
||||||
|
OPEN: "AWAITING_ATTENDANCE",
|
||||||
|
AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE",
|
||||||
|
ON_HOLD: "PAUSED",
|
||||||
|
PAUSED: "PAUSED",
|
||||||
|
RESOLVED: "RESOLVED",
|
||||||
|
CLOSED: "CLOSED",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeTicketStatus(status: unknown): NormalizedTicketStatus {
|
||||||
|
if (typeof status !== "string") return "PENDING";
|
||||||
|
const normalized = STATUS_MAP[status.toUpperCase()];
|
||||||
|
return normalized ?? "PENDING";
|
||||||
|
}
|
||||||
|
|
||||||
// Server shapes: datas como number (epoch ms) e alguns nullables
|
// Server shapes: datas como number (epoch ms) e alguns nullables
|
||||||
// Relaxamos email/urls no shape do servidor para evitar que payloads parciais quebrem o app.
|
// Relaxamos email/urls no shape do servidor para evitar que payloads parciais quebrem o app.
|
||||||
|
|
@ -104,6 +123,7 @@ export function mapTicketFromServer(input: unknown) {
|
||||||
const s = serverTicketSchema.parse(input);
|
const s = serverTicketSchema.parse(input);
|
||||||
const ui = {
|
const ui = {
|
||||||
...s,
|
...s,
|
||||||
|
status: normalizeTicketStatus(s.status),
|
||||||
category: s.category ?? undefined,
|
category: s.category ?? undefined,
|
||||||
subcategory: s.subcategory ?? undefined,
|
subcategory: s.subcategory ?? undefined,
|
||||||
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
|
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
|
||||||
|
|
@ -154,6 +174,7 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
|
||||||
const ui = {
|
const ui = {
|
||||||
...s,
|
...s,
|
||||||
customFields,
|
customFields,
|
||||||
|
status: normalizeTicketStatus(s.status),
|
||||||
category: s.category ?? undefined,
|
category: s.category ?? undefined,
|
||||||
subcategory: s.subcategory ?? undefined,
|
subcategory: s.subcategory ?? undefined,
|
||||||
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
|
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ const baseTickets = [
|
||||||
reference: 41001,
|
reference: 41001,
|
||||||
subject: "Erro 500 ao acessar portal do cliente",
|
subject: "Erro 500 ao acessar portal do cliente",
|
||||||
summary: "Clientes relatam erro intermitente no portal web",
|
summary: "Clientes relatam erro intermitente no portal web",
|
||||||
status: ticketStatusSchema.enum.OPEN,
|
status: ticketStatusSchema.enum.AWAITING_ATTENDANCE,
|
||||||
priority: ticketPrioritySchema.enum.URGENT,
|
priority: ticketPrioritySchema.enum.URGENT,
|
||||||
channel: ticketChannelSchema.enum.EMAIL,
|
channel: ticketChannelSchema.enum.EMAIL,
|
||||||
queue: "Chamados",
|
queue: "Chamados",
|
||||||
|
|
@ -90,7 +90,7 @@ const baseTickets = [
|
||||||
reference: 41002,
|
reference: 41002,
|
||||||
subject: "Integração ERP parada",
|
subject: "Integração ERP parada",
|
||||||
summary: "Webhook do ERP retornando timeout",
|
summary: "Webhook do ERP retornando timeout",
|
||||||
status: ticketStatusSchema.enum.PENDING,
|
status: ticketStatusSchema.enum.PAUSED,
|
||||||
priority: ticketPrioritySchema.enum.HIGH,
|
priority: ticketPrioritySchema.enum.HIGH,
|
||||||
channel: ticketChannelSchema.enum.WHATSAPP,
|
channel: ticketChannelSchema.enum.WHATSAPP,
|
||||||
queue: "Laboratório",
|
queue: "Laboratório",
|
||||||
|
|
@ -120,7 +120,7 @@ const baseTickets = [
|
||||||
reference: 41003,
|
reference: 41003,
|
||||||
subject: "Solicitação de acesso VPN",
|
subject: "Solicitação de acesso VPN",
|
||||||
summary: "Novo colaborador precisa de acesso",
|
summary: "Novo colaborador precisa de acesso",
|
||||||
status: ticketStatusSchema.enum.NEW,
|
status: ticketStatusSchema.enum.PENDING,
|
||||||
priority: ticketPrioritySchema.enum.MEDIUM,
|
priority: ticketPrioritySchema.enum.MEDIUM,
|
||||||
channel: ticketChannelSchema.enum.MANUAL,
|
channel: ticketChannelSchema.enum.MANUAL,
|
||||||
queue: "Field Services",
|
queue: "Field Services",
|
||||||
|
|
@ -141,7 +141,7 @@ const baseTickets = [
|
||||||
reference: 41004,
|
reference: 41004,
|
||||||
subject: "Bug no app mobile - upload de foto",
|
subject: "Bug no app mobile - upload de foto",
|
||||||
summary: "Upload trava com arquivos acima de 5MB",
|
summary: "Upload trava com arquivos acima de 5MB",
|
||||||
status: ticketStatusSchema.enum.ON_HOLD,
|
status: ticketStatusSchema.enum.PAUSED,
|
||||||
priority: ticketPrioritySchema.enum.HIGH,
|
priority: ticketPrioritySchema.enum.HIGH,
|
||||||
channel: ticketChannelSchema.enum.CHAT,
|
channel: ticketChannelSchema.enum.CHAT,
|
||||||
queue: "Laboratório",
|
queue: "Laboratório",
|
||||||
|
|
@ -234,13 +234,13 @@ const commentsByTicket: Record<string, z.infer<typeof ticketWithDetailsSchema>["
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineByTicket: Record<string, z.infer<typeof ticketWithDetailsSchema>["timeline"]> = {
|
const timelineByTicket: Record<string, z.infer<typeof ticketWithDetailsSchema>["timeline"]> = {
|
||||||
"ticket-1001": [
|
"ticket-1001": [
|
||||||
{
|
{
|
||||||
id: "timeline-1",
|
id: "timeline-1",
|
||||||
type: "STATUS_CHANGED",
|
type: "STATUS_CHANGED",
|
||||||
payload: { from: "NEW", to: "OPEN" },
|
payload: { from: "PENDING", to: "AWAITING_ATTENDANCE" },
|
||||||
createdAt: subHours(new Date(), 5),
|
createdAt: subHours(new Date(), 5),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "timeline-2",
|
id: "timeline-2",
|
||||||
type: "ASSIGNEE_CHANGED",
|
type: "ASSIGNEE_CHANGED",
|
||||||
|
|
@ -254,14 +254,14 @@ const timelineByTicket: Record<string, z.infer<typeof ticketWithDetailsSchema>["
|
||||||
createdAt: subHours(new Date(), 1),
|
createdAt: subHours(new Date(), 1),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"ticket-1002": [
|
"ticket-1002": [
|
||||||
{
|
{
|
||||||
id: "timeline-4",
|
id: "timeline-4",
|
||||||
type: "STATUS_CHANGED",
|
type: "STATUS_CHANGED",
|
||||||
payload: { from: "OPEN", to: "PENDING" },
|
payload: { from: "AWAITING_ATTENDANCE", to: "PAUSED" },
|
||||||
createdAt: subHours(new Date(), 3),
|
createdAt: subHours(new Date(), 3),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ticketDetails = tickets.map((ticket) => ({
|
export const ticketDetails = tickets.map((ticket) => ({
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
export const ticketStatusSchema = z.enum([
|
export const ticketStatusSchema = z.enum([
|
||||||
"NEW",
|
"PENDING",
|
||||||
"OPEN",
|
"AWAITING_ATTENDANCE",
|
||||||
"PENDING",
|
"PAUSED",
|
||||||
"ON_HOLD",
|
"RESOLVED",
|
||||||
"RESOLVED",
|
"CLOSED",
|
||||||
"CLOSED",
|
])
|
||||||
])
|
|
||||||
|
|
||||||
export type TicketStatus = z.infer<typeof ticketStatusSchema>
|
export type TicketStatus = z.infer<typeof ticketStatusSchema>
|
||||||
|
|
||||||
|
|
|
||||||
14
types/convex-react.d.ts
vendored
Normal file
14
types/convex-react.d.ts
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import "convex/react";
|
||||||
|
|
||||||
|
declare module "convex/react" {
|
||||||
|
import type { FunctionReference } from "convex/server";
|
||||||
|
|
||||||
|
type OriginalUseQuery = typeof import("convex/react").useQuery;
|
||||||
|
type OriginalArgs = Parameters<OriginalUseQuery>[1];
|
||||||
|
type OriginalReturn = ReturnType<OriginalUseQuery>;
|
||||||
|
|
||||||
|
export function useQuery(
|
||||||
|
query: FunctionReference<"query"> | "skip",
|
||||||
|
args?: OriginalArgs
|
||||||
|
): OriginalReturn;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue