feat: status + queue updates, filters e UI
- Status renomeados e cores (Em andamento azul, Pausado amarelo) - Transições automáticas: iniciar=Em andamento, pausar=Pausado - Fila padrão: Chamados ao criar ticket - Admin/Empresas: renomeia ‘Slug’ → ‘Apelido’ + mensagens - Dashboard: últimos tickets priorizam sem responsável (mais antigos) - Tickets: filtro por responsável + salvar filtro por usuário - Encerrar ticket: adiciona botão ‘Cancelar’ - Strings atualizadas (PDF, relatórios, badges)
This commit is contained in:
parent
e91192a1f6
commit
5535ba81e6
19 changed files with 399 additions and 86 deletions
|
|
@ -17,7 +17,7 @@ type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RE
|
|||
|
||||
const STATUS_LABELS: Record<TicketStatusNormalized, string> = {
|
||||
PENDING: "Pendente",
|
||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
AWAITING_ATTENDANCE: "Em andamento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
};
|
||||
|
|
@ -36,6 +36,22 @@ const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
|
|||
const missingRequesterLogCache = new Set<string>();
|
||||
const missingCommentAuthorLogCache = new Set<string>();
|
||||
|
||||
// Character limits (generous but bounded)
|
||||
const MAX_SUMMARY_CHARS = 600;
|
||||
const MAX_COMMENT_CHARS = 20000;
|
||||
|
||||
function plainTextLength(html: string): number {
|
||||
try {
|
||||
const text = String(html)
|
||||
.replace(/<[^>]*>/g, "") // strip tags
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
return text.length;
|
||||
} catch {
|
||||
return String(html ?? "").length;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStatus(status: string | null | undefined): TicketStatusNormalized {
|
||||
if (!status) return "PENDING";
|
||||
const normalized = LEGACY_STATUS_MAP[status.toUpperCase()];
|
||||
|
|
@ -419,6 +435,8 @@ export const list = query({
|
|||
priority: v.optional(v.string()),
|
||||
channel: v.optional(v.string()),
|
||||
queueId: v.optional(v.id("queues")),
|
||||
assigneeId: v.optional(v.id("users")),
|
||||
requesterId: v.optional(v.id("users")),
|
||||
search: v.optional(v.string()),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
|
|
@ -434,10 +452,21 @@ export const list = query({
|
|||
if (!user.companyId) {
|
||||
throw new ConvexError("Gestor não possui empresa vinculada")
|
||||
}
|
||||
// Managers are scoped to company; allow secondary narrowing by requester/assignee
|
||||
base = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
|
||||
.collect();
|
||||
} else if (args.assigneeId) {
|
||||
base = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!))
|
||||
.collect();
|
||||
} else if (args.requesterId) {
|
||||
base = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!))
|
||||
.collect();
|
||||
} else if (args.queueId) {
|
||||
base = await ctx.db
|
||||
.query("tickets")
|
||||
|
|
@ -445,11 +474,18 @@ export const list = query({
|
|||
.collect();
|
||||
} else if (role === "COLLABORATOR") {
|
||||
// Colaborador: exibir apenas tickets onde ele é o solicitante
|
||||
// Compatibilidade por e-mail: inclui tickets com requesterSnapshot.email == e-mail do viewer
|
||||
const all = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
|
||||
.collect()
|
||||
base = all.filter((t) => t.requesterId === args.viewerId)
|
||||
const viewerEmail = user.email.trim().toLowerCase()
|
||||
base = all.filter((t) => {
|
||||
if (t.requesterId === args.viewerId) return true
|
||||
const rs = t.requesterSnapshot as { email?: string } | undefined
|
||||
const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null
|
||||
return Boolean(email && email === viewerEmail)
|
||||
})
|
||||
} else {
|
||||
base = await ctx.db
|
||||
.query("tickets")
|
||||
|
|
@ -468,6 +504,8 @@ export const list = query({
|
|||
|
||||
if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority);
|
||||
if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel);
|
||||
if (args.assigneeId) filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId));
|
||||
if (args.requesterId) filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId));
|
||||
if (normalizedStatusFilter) {
|
||||
filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter);
|
||||
}
|
||||
|
|
@ -585,6 +623,15 @@ export const getById = query({
|
|||
const { user, role } = await requireUser(ctx, viewerId, tenantId)
|
||||
const t = await ctx.db.get(id);
|
||||
if (!t || t.tenantId !== tenantId) return null;
|
||||
if (role === "COLLABORATOR") {
|
||||
const isOwnerById = String(t.requesterId) === String(viewerId)
|
||||
const snapshotEmail = (t.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase?.() ?? null
|
||||
const viewerEmail = user.email.trim().toLowerCase()
|
||||
const isOwnerByEmail = Boolean(snapshotEmail && snapshotEmail === viewerEmail)
|
||||
if (!isOwnerById && !isOwnerByEmail) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// no customer role; managers are constrained to company via ensureManagerTicketAccess
|
||||
let requester: Doc<"users"> | null = null
|
||||
if (role === "MANAGER") {
|
||||
|
|
@ -841,6 +888,9 @@ export const create = mutation({
|
|||
if (subject.length < 3) {
|
||||
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
|
||||
}
|
||||
if (args.summary && args.summary.trim().length > MAX_SUMMARY_CHARS) {
|
||||
throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`);
|
||||
}
|
||||
const category = await ctx.db.get(args.categoryId);
|
||||
if (!category || category.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Categoria inválida");
|
||||
|
|
@ -893,6 +943,19 @@ export const create = mutation({
|
|||
}
|
||||
: undefined
|
||||
|
||||
// default queue: if none provided, prefer "Chamados"
|
||||
let resolvedQueueId = args.queueId as Id<"queues"> | undefined
|
||||
if (!resolvedQueueId) {
|
||||
const queues = await ctx.db
|
||||
.query("queues")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
|
||||
.collect()
|
||||
const preferred = queues.find((q) => q.slug === "chamados") || queues.find((q) => q.name === "Chamados") || null
|
||||
if (preferred) {
|
||||
resolvedQueueId = preferred._id as Id<"queues">
|
||||
}
|
||||
}
|
||||
|
||||
const id = await ctx.db.insert("tickets", {
|
||||
tenantId: args.tenantId,
|
||||
reference: nextRef,
|
||||
|
|
@ -901,7 +964,7 @@ export const create = mutation({
|
|||
status: initialStatus,
|
||||
priority: args.priority,
|
||||
channel: args.channel,
|
||||
queueId: args.queueId,
|
||||
queueId: resolvedQueueId,
|
||||
categoryId: args.categoryId,
|
||||
subcategoryId: args.subcategoryId,
|
||||
requesterId: args.requesterId,
|
||||
|
|
@ -1019,6 +1082,11 @@ export const addComment = mutation({
|
|||
}
|
||||
}
|
||||
|
||||
const bodyPlainLen = plainTextLength(args.body)
|
||||
if (bodyPlainLen > MAX_COMMENT_CHARS) {
|
||||
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
||||
}
|
||||
|
||||
const authorSnapshot: CommentAuthorSnapshot = {
|
||||
name: author.name,
|
||||
email: author.email,
|
||||
|
|
@ -1084,6 +1152,11 @@ export const updateComment = mutation({
|
|||
await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||
}
|
||||
|
||||
const bodyPlainLen = plainTextLength(body)
|
||||
if (bodyPlainLen > MAX_COMMENT_CHARS) {
|
||||
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(commentId, {
|
||||
body,
|
||||
|
|
@ -1453,6 +1526,7 @@ export const startWork = mutation({
|
|||
await ctx.db.patch(ticketId, {
|
||||
working: true,
|
||||
activeSessionId: sessionId,
|
||||
status: "AWAITING_ATTENDANCE",
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
|
|
@ -1532,6 +1606,7 @@ export const pauseWork = mutation({
|
|||
await ctx.db.patch(ticketId, {
|
||||
working: false,
|
||||
activeSessionId: undefined,
|
||||
status: "PAUSED",
|
||||
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
|
||||
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
|
||||
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
|
||||
|
|
@ -1599,6 +1674,9 @@ export const updateSummary = mutation({
|
|||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
await requireStaff(ctx, actorId, t.tenantId)
|
||||
if (summary && summary.trim().length > MAX_SUMMARY_CHARS) {
|
||||
throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`)
|
||||
}
|
||||
await ctx.db.patch(ticketId, { summary, updatedAt: now });
|
||||
const actor = await ctx.db.get(actorId);
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
|
|
@ -1743,3 +1821,109 @@ export const remove = mutation({
|
|||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const reassignTicketsByEmail = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
fromEmail: v.string(),
|
||||
toUserId: v.id("users"),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
limit: v.optional(v.number()),
|
||||
updateSnapshot: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, fromEmail, toUserId, dryRun, limit, updateSnapshot }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId)
|
||||
|
||||
const normalizedFrom = fromEmail.trim().toLowerCase()
|
||||
if (!normalizedFrom || !normalizedFrom.includes("@")) {
|
||||
throw new ConvexError("E-mail de origem inválido")
|
||||
}
|
||||
|
||||
const toUser = await ctx.db.get(toUserId)
|
||||
if (!toUser || toUser.tenantId !== tenantId) {
|
||||
throw new ConvexError("Usuário de destino inválido para o tenant")
|
||||
}
|
||||
|
||||
// Coletar tickets por requesterId (quando possível via usuário antigo)
|
||||
const fromUser = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalizedFrom))
|
||||
.first()
|
||||
|
||||
const byRequesterId: Doc<"tickets">[] = fromUser
|
||||
? await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", fromUser._id))
|
||||
.collect()
|
||||
: []
|
||||
|
||||
// Coletar tickets por e-mail no snapshot para cobrir casos sem user antigo
|
||||
const allTenant = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
|
||||
const bySnapshotEmail = allTenant.filter((t) => {
|
||||
const rs = t.requesterSnapshot as { email?: string } | undefined
|
||||
const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null
|
||||
if (!email || email !== normalizedFrom) return false
|
||||
// Evita duplicar os já coletados por requesterId
|
||||
if (fromUser && t.requesterId === fromUser._id) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const candidatesMap = new Map<string, Doc<"tickets">>()
|
||||
for (const t of byRequesterId) candidatesMap.set(String(t._id), t)
|
||||
for (const t of bySnapshotEmail) candidatesMap.set(String(t._id), t)
|
||||
const candidates = Array.from(candidatesMap.values())
|
||||
|
||||
const maxToProcess = Math.max(0, Math.min(limit && limit > 0 ? limit : candidates.length, candidates.length))
|
||||
const toProcess = candidates.slice(0, maxToProcess)
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
dryRun: true as const,
|
||||
fromEmail: normalizedFrom,
|
||||
toUserId,
|
||||
candidates: candidates.length,
|
||||
willUpdate: toProcess.length,
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
let updated = 0
|
||||
for (const t of toProcess) {
|
||||
const patch: Record<string, unknown> = { requesterId: toUserId, updatedAt: now }
|
||||
if (updateSnapshot) {
|
||||
patch.requesterSnapshot = {
|
||||
name: toUser.name,
|
||||
email: toUser.email,
|
||||
avatarUrl: toUser.avatarUrl ?? undefined,
|
||||
teams: toUser.teams ?? undefined,
|
||||
}
|
||||
}
|
||||
await ctx.db.patch(t._id, patch)
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: t._id,
|
||||
type: "REQUESTER_CHANGED",
|
||||
payload: {
|
||||
fromUserId: fromUser?._id ?? null,
|
||||
fromEmail: normalizedFrom,
|
||||
toUserId,
|
||||
toUserName: toUser.name,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
updated += 1
|
||||
}
|
||||
|
||||
return {
|
||||
dryRun: false as const,
|
||||
fromEmail: normalizedFrom,
|
||||
toUserId,
|
||||
candidates: candidates.length,
|
||||
updated,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue