feat: cadastro manual de acesso remoto e ajustes de horas

This commit is contained in:
Esdras Renan 2025-10-24 23:52:58 -03:00
parent 8e3cbc7a9a
commit f3a7045691
16 changed files with 1549 additions and 207 deletions

View file

@ -182,6 +182,29 @@ function normalizeStatus(status: string | null | undefined): TicketStatusNormali
return normalized ?? "PENDING";
}
function formatWorkDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) {
return "0m";
}
const totalMinutes = Math.round(ms / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = Math.abs(totalMinutes % 60);
const parts: string[] = [];
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (parts.length === 0) {
return "0m";
}
return parts.join(" ");
}
function formatWorkDelta(deltaMs: number): string {
if (deltaMs === 0) return "0m";
const sign = deltaMs > 0 ? "+" : "-";
const absolute = formatWorkDuration(Math.abs(deltaMs));
return `${sign}${absolute}`;
}
type AgentWorkTotals = {
agentId: Id<"users">;
agentName: string | null;
@ -2048,6 +2071,126 @@ export const pauseWork = mutation({
},
})
export const adjustWorkSummary = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
internalWorkedMs: v.number(),
externalWorkedMs: v.number(),
reason: v.string(),
},
handler: async (ctx, { ticketId, actorId, internalWorkedMs, externalWorkedMs, reason }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const normalizedRole = (viewer.role ?? "").toUpperCase()
if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
throw new ConvexError("Somente administradores e agentes podem ajustar as horas de um chamado.")
}
if (ticketDoc.activeSessionId) {
throw new ConvexError("Pause o atendimento antes de ajustar as horas do chamado.")
}
const trimmedReason = reason.trim()
if (trimmedReason.length < 5) {
throw new ConvexError("Informe um motivo com pelo menos 5 caracteres.")
}
if (trimmedReason.length > 1000) {
throw new ConvexError("Motivo muito longo (máx. 1000 caracteres).")
}
const previousInternal = Math.max(0, ticketDoc.internalWorkedMs ?? 0)
const previousExternal = Math.max(0, ticketDoc.externalWorkedMs ?? 0)
const previousTotal = Math.max(0, ticketDoc.totalWorkedMs ?? previousInternal + previousExternal)
const nextInternal = Math.max(0, Math.round(internalWorkedMs))
const nextExternal = Math.max(0, Math.round(externalWorkedMs))
const nextTotal = nextInternal + nextExternal
const deltaInternal = nextInternal - previousInternal
const deltaExternal = nextExternal - previousExternal
const deltaTotal = nextTotal - previousTotal
const now = Date.now()
await ctx.db.patch(ticketId, {
internalWorkedMs: nextInternal,
externalWorkedMs: nextExternal,
totalWorkedMs: nextTotal,
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "WORK_ADJUSTED",
payload: {
actorId,
actorName: viewer.user.name,
actorAvatar: viewer.user.avatarUrl,
previousInternalMs: previousInternal,
previousExternalMs: previousExternal,
previousTotalMs: previousTotal,
nextInternalMs: nextInternal,
nextExternalMs: nextExternal,
nextTotalMs: nextTotal,
deltaInternalMs: deltaInternal,
deltaExternalMs: deltaExternal,
deltaTotalMs: deltaTotal,
},
createdAt: now,
})
const bodyHtml = [
"<p><strong>Ajuste manual de horas</strong></p>",
"<ul>",
`<li>Horas internas: ${escapeHtml(formatWorkDuration(previousInternal))} &rarr; ${escapeHtml(formatWorkDuration(nextInternal))} (${escapeHtml(formatWorkDelta(deltaInternal))})</li>`,
`<li>Horas externas: ${escapeHtml(formatWorkDuration(previousExternal))} &rarr; ${escapeHtml(formatWorkDuration(nextExternal))} (${escapeHtml(formatWorkDelta(deltaExternal))})</li>`,
`<li>Total: ${escapeHtml(formatWorkDuration(previousTotal))} &rarr; ${escapeHtml(formatWorkDuration(nextTotal))} (${escapeHtml(formatWorkDelta(deltaTotal))})</li>`,
"</ul>",
`<p><strong>Motivo:</strong> ${escapeHtml(trimmedReason)}</p>`,
].join("")
const authorSnapshot: CommentAuthorSnapshot = {
name: viewer.user.name,
email: viewer.user.email,
avatarUrl: viewer.user.avatarUrl ?? undefined,
teams: viewer.user.teams ?? undefined,
}
await ctx.db.insert("ticketComments", {
ticketId,
authorId: actorId,
visibility: "INTERNAL",
body: bodyHtml,
authorSnapshot,
attachments: [],
createdAt: now,
updatedAt: now,
})
const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, now)
return {
ticketId,
totalWorkedMs: nextTotal,
internalWorkedMs: nextInternal,
externalWorkedMs: nextExternal,
serverNow: now,
perAgentTotals: perAgentTotals.map((item) => ({
agentId: item.agentId,
agentName: item.agentName,
agentEmail: item.agentEmail,
avatarUrl: item.avatarUrl,
totalWorkedMs: item.totalWorkedMs,
internalWorkedMs: item.internalWorkedMs,
externalWorkedMs: item.externalWorkedMs,
})),
}
},
})
export const updateSubject = mutation({
args: { ticketId: v.id("tickets"), subject: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, subject, actorId }) => {