feat(visits): concluir/reabrir visita sem poluir agenda

This commit is contained in:
Esdras Renan 2025-11-26 14:21:31 -03:00
parent 8f2c00a75a
commit 66559eafbf
9 changed files with 264 additions and 31 deletions

View file

@ -273,6 +273,8 @@ export default defineSchema({
slaPausedBy: v.optional(v.string()),
slaPausedMs: v.optional(v.number()),
dueAt: v.optional(v.number()), // ms since epoch
visitStatus: v.optional(v.string()),
visitPerformedAt: v.optional(v.number()),
firstResponseAt: v.optional(v.number()),
resolvedAt: v.optional(v.number()),
closedAt: v.optional(v.number()),

View file

@ -76,6 +76,8 @@ const MAX_COMMENT_CHARS = 20000;
const DEFAULT_REOPEN_DAYS = 7;
const MAX_REOPEN_DAYS = 14;
const VISIT_QUEUE_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"];
const VISIT_STATUSES = new Set(["scheduled", "en_route", "in_service", "done", "no_show", "canceled"]);
const VISIT_COMPLETED_STATUSES = new Set(["done", "no_show", "canceled"]);
type AnyCtx = QueryCtx | MutationCtx;
@ -2003,6 +2005,8 @@ export const getById = query({
resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null,
reopenDeadline: t.reopenDeadline ?? null,
reopenedAt: t.reopenedAt ?? null,
visitStatus: t.visitStatus ?? null,
visitPerformedAt: t.visitPerformedAt ?? null,
description: undefined,
customFields: customFieldsRecord,
timeline: timelineRecords.map((ev) => {
@ -2247,6 +2251,8 @@ export const create = mutation({
slaPolicyId: undefined,
dueAt: visitDueAt && isVisitQueue ? visitDueAt : undefined,
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
visitStatus: isVisitQueue ? "scheduled" : undefined,
visitPerformedAt: undefined,
...slaFields,
});
await ctx.db.insert("ticketEvents", {
@ -3546,6 +3552,8 @@ export const updateVisitSchedule = mutation({
const actor = viewer.user
await ctx.db.patch(ticketId, {
dueAt: visitDate,
visitStatus: "scheduled",
visitPerformedAt: undefined,
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
@ -3564,6 +3572,69 @@ export const updateVisitSchedule = mutation({
},
})
export const updateVisitStatus = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
status: v.string(),
performedAt: v.optional(v.number()),
},
handler: async (ctx, { ticketId, actorId, status, performedAt }) => {
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)
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem alterar o status da visita")
}
const normalizedStatus = (status ?? "").toLowerCase()
if (!VISIT_STATUSES.has(normalizedStatus)) {
throw new ConvexError("Status da visita inválido")
}
if (!ticketDoc.queueId) {
throw new ConvexError("Este ticket não possui fila configurada")
}
const queue = (await ctx.db.get(ticketDoc.queueId)) as Doc<"queues"> | null
if (!queue) {
throw new ConvexError("Fila não encontrada para este ticket")
}
const queueLabel = (normalizeQueueName(queue) ?? queue.name ?? "").toLowerCase()
const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword))
if (!isVisitQueue) {
throw new ConvexError("Somente tickets da fila de visitas possuem status de visita")
}
const now = Date.now()
const completed = VISIT_COMPLETED_STATUSES.has(normalizedStatus)
const resolvedPerformedAt =
completed && typeof performedAt === "number" && Number.isFinite(performedAt) ? performedAt : completed ? now : undefined
await ctx.db.patch(ticketId, {
visitStatus: normalizedStatus,
visitPerformedAt: completed ? resolvedPerformedAt : undefined,
// Mantemos dueAt para não perder o agendamento original.
dueAt: ticketDoc.dueAt,
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "VISIT_STATUS_CHANGED",
payload: {
status: normalizedStatus,
performedAt: resolvedPerformedAt,
actorId,
actorName: viewer.user.name,
actorAvatar: viewer.user.avatarUrl ?? undefined,
},
createdAt: now,
})
return { status: normalizedStatus }
},
})
export const changeQueue = mutation({
args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
handler: async (ctx, { ticketId, queueId, actorId }) => {

View file

@ -1,6 +1,5 @@
import { NextResponse } from "next/server"
import type { Id } from "@/convex/_generated/dataModel"
import type { Prisma, UserRole } from "@/lib/prisma"
import { api } from "@/convex/_generated/api"
import { ConvexHttpClient } from "convex/browser"
import { prisma } from "@/lib/prisma"
@ -14,7 +13,8 @@ function normalizeRole(input: string | null | undefined): RoleOption {
return ((ROLE_OPTIONS as readonly string[]).includes(candidate) ? candidate : "agent") as RoleOption
}
const USER_ROLE_OPTIONS: ReadonlyArray<UserRole> = ["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]
const USER_ROLE_OPTIONS = ["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"] as const
type UserRole = (typeof USER_ROLE_OPTIONS)[number]
function mapToUserRole(role: RoleOption): UserRole {
const candidate = role.toUpperCase() as UserRole
@ -207,7 +207,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
}
if (domainUser) {
const updateData: Prisma.UserUncheckedUpdateInput = {
const updateData: Record<string, unknown> = {
email: nextEmail,
name: nextName || domainUser.name,
role: mapToUserRole(nextRole),
@ -225,30 +225,34 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
data: updateData,
})
} else {
const upsertUpdate: Prisma.UserUncheckedUpdateInput = {
const upsertUpdate = {
name: nextName || nextEmail,
role: mapToUserRole(nextRole),
tenantId: nextTenant,
companyId: companyId ?? null,
jobTitle: hasJobTitleField ? jobTitle ?? null : undefined,
managerId: hasManagerField ? managerRecord?.id ?? null : undefined,
}
if (hasJobTitleField) {
upsertUpdate.jobTitle = jobTitle ?? null
// noop: já definido acima
}
if (hasManagerField) {
upsertUpdate.managerId = managerRecord?.id ?? null
// noop: já definido acima
}
const upsertCreate: Prisma.UserUncheckedCreateInput = {
const upsertCreate = {
email: nextEmail,
name: nextName || nextEmail,
role: mapToUserRole(nextRole),
tenantId: nextTenant,
companyId: companyId ?? null,
jobTitle: hasJobTitleField ? jobTitle ?? null : undefined,
managerId: hasManagerField ? managerRecord?.id ?? null : undefined,
}
if (hasJobTitleField) {
upsertCreate.jobTitle = jobTitle ?? null
// noop: já definido acima
}
if (hasManagerField) {
upsertCreate.managerId = managerRecord?.id ?? null
// noop: já definido acima
}
await prisma.user.upsert({
where: { email: nextEmail },

View file

@ -16,7 +16,7 @@ interface TicketQueueSummaryProps {
function resolveSlaTone(percent: number) {
if (percent < 25) {
return { indicatorClass: "bg-[#00e8ff]", textClass: "text-[#00e8ff]" }
return { indicatorClass: "bg-[#00e8ff]", textClass: "text-neutral-500" }
}
if (percent < 50) {
return { indicatorClass: "bg-emerald-400", textClass: "text-emerald-400" }

View file

@ -1,6 +1,7 @@
"use client"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { format, formatDistanceToNow, parseISO } from "date-fns"
import { ptBR } from "date-fns/locale"
@ -108,6 +109,22 @@ const PAUSE_REASONS = [
{ value: "IN_PROCEDURE", label: "Em procedimento" },
{ value: "LUNCH_BREAK", label: "Intervalo de almoço" },
]
const VISIT_STATUS_LABELS: Record<string, string> = {
scheduled: "Agendada",
en_route: "Em deslocamento",
in_service: "Em atendimento",
done: "Concluída",
no_show: "No-show",
canceled: "Cancelada",
}
const VISIT_STATUS_TONES: Record<string, string> = {
scheduled: "bg-slate-100 text-neutral-700 border-slate-200",
en_route: "bg-sky-100 text-sky-800 border-sky-200",
in_service: "bg-amber-100 text-amber-800 border-amber-200",
done: "bg-emerald-100 text-emerald-800 border-emerald-200",
no_show: "bg-rose-100 text-rose-800 border-rose-200",
canceled: "bg-slate-100 text-neutral-600 border-slate-200",
}
type CustomerOption = {
id: string
@ -140,6 +157,7 @@ function formatDuration(durationMs: number) {
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { convexUserId, role, isStaff, session, machineContext } = useAuth()
const router = useRouter()
const normalizedRole = (role ?? "").toLowerCase()
const isManager = normalizedRole === "manager"
const isAdmin = normalizedRole === "admin"
@ -157,6 +175,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const viewerEmailRaw = session?.user?.email ?? machineContext?.assignedUserEmail ?? null
const viewerEmail = (viewerEmailRaw ?? "").trim().toLowerCase()
const [status, setStatus] = useState<TicketStatus>(ticket.status)
const [visitStatusLoading, setVisitStatusLoading] = useState(false)
const rawReopenDeadline = ticket.reopenDeadline ?? null
const fallbackClosedMs = ticket.closedAt?.getTime() ?? ticket.resolvedAt?.getTime() ?? null
const DEFAULT_REOPEN_DAYS = 7
@ -215,6 +234,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const updateCategories = useMutation(api.tickets.updateCategories)
const reopenTicket = useMutation(api.tickets.reopenTicket)
const updateVisitSchedule = useMutation(api.tickets.updateVisitSchedule)
const updateVisitStatus = useMutation(api.tickets.updateVisitStatus)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const normalizedTicketQueue = useMemo(() => (ticket.queue ?? "").toLowerCase(), [ticket.queue])
const isVisitQueueTicket = useMemo(
@ -379,6 +399,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
[normalizedQueueSelection],
)
const visitSectionEnabled = editing ? isVisitQueueSelected : isVisitQueueTicket
const visitStatusValue = (ticket.visitStatus as string | null) ?? (ticket.dueAt ? "scheduled" : null)
const visitPerformedAt = ticket.visitPerformedAt ?? null
const visitDirty = useMemo(() => {
if (!visitSectionEnabled) {
return false
@ -469,6 +491,35 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const normalizedAssigneeReason = assigneeChangeReason.trim()
const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5
const saveDisabled = !formDirty || saving || !assigneeReasonValid || visitHasInvalid
const handleVisitStatusChange = useCallback(
async (nextStatus: string) => {
if (!visitSectionEnabled) return
if (!viewerId) {
toast.error("É necessário estar autenticado para atualizar a visita.")
return
}
const normalizedStatus = nextStatus.toLowerCase()
setVisitStatusLoading(true)
const toastId = "visit-status"
toast.loading("Atualizando status da visita...", { id: toastId })
try {
await updateVisitStatus({
ticketId: ticket.id as Id<"tickets">,
actorId: viewerId as Id<"users">,
status: normalizedStatus,
performedAt: Date.now(),
})
toast.success("Visita atualizada com sucesso.", { id: toastId })
router.refresh()
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o status da visita.", { id: toastId })
} finally {
setVisitStatusLoading(false)
}
},
[router, ticket.id, updateVisitStatus, viewerId, visitSectionEnabled],
)
const companyLabel = useMemo(() => {
if (ticket.company?.name) return ticket.company.name
if (isAvulso) return "Cliente avulso"
@ -1810,9 +1861,49 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
) : null}
</div>
) : (
<span className={sectionValueClass}>
{ticket.dueAt ? format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR }) : "Sem data definida"}
</span>
<div className="flex flex-col gap-1">
<span className={sectionValueClass}>
{ticket.dueAt ? format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR }) : "Sem data definida"}
</span>
<div className="flex flex-wrap items-center gap-2">
{visitStatusValue ? (
<Badge
variant="outline"
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
VISIT_STATUS_TONES[visitStatusValue] ?? "bg-slate-100 text-neutral-700 border-slate-200",
)}
>
{VISIT_STATUS_LABELS[visitStatusValue] ?? visitStatusValue}
</Badge>
) : null}
{!isManager && visitStatusValue !== "done" ? (
<Button
size="sm"
variant="outline"
onClick={() => handleVisitStatusChange("done")}
disabled={visitStatusLoading}
>
Marcar visita realizada
</Button>
) : null}
{!isManager && visitStatusValue && visitStatusValue !== "scheduled" && visitStatusValue !== "in_service" ? (
<Button
size="sm"
variant="ghost"
onClick={() => handleVisitStatusChange("scheduled")}
disabled={visitStatusLoading}
>
Reabrir visita
</Button>
) : null}
</div>
{visitStatusValue === "done" && visitPerformedAt ? (
<span className="text-xs text-muted-foreground">
Concluída em {format(visitPerformedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
) : null}
</div>
)}
</div>
) : slaDueDate ? (

View file

@ -14,10 +14,17 @@ import {
import type { Ticket, TicketPriority } from "@/lib/schemas/ticket"
import type { AgendaFilterState, AgendaPeriod } from "@/components/agenda/agenda-filters"
import { getSlaDisplayStatus, getSlaDueDate } from "@/lib/sla-utils"
import { getSlaDueDate } from "@/lib/sla-utils"
import { isVisitTicket } from "@/lib/ticket-matchers"
export type AgendaSlaStatus = "on_track" | "at_risk" | "breached" | "met"
type VisitStatus = NonNullable<Ticket["visitStatus"]>
type VisitState = {
status: VisitStatus | null
scheduledAt: Date | null
performedAt: Date | null
completed: boolean
}
export type AgendaTicketSummary = {
id: string
@ -30,6 +37,7 @@ export type AgendaTicketSummary = {
startAt: Date | null
endAt: Date | null
slaStatus: AgendaSlaStatus
visitStatus?: VisitStatus | null
completedAt?: Date | null
href: string
}
@ -85,8 +93,9 @@ export function buildAgendaDataset(tickets: Ticket[], filters: AgendaFilterState
const enriched = filteredTickets.map((ticket) => {
const schedule = deriveScheduleWindow(ticket)
const slaStatus = computeSlaStatus(ticket, now)
return { ticket, schedule, slaStatus }
const visitState = deriveVisitState(ticket)
const slaStatus = computeVisitSlaStatus(visitState, now)
return { ticket, schedule, visitState, slaStatus }
})
const summarySections = {
@ -99,12 +108,21 @@ export function buildAgendaDataset(tickets: Ticket[], filters: AgendaFilterState
const upcomingWindowEnd = addMonths(now, 12)
for (const entry of enriched) {
const summary = buildSummary(entry.ticket, entry.schedule, entry.slaStatus)
const summary = buildSummary(entry.ticket, entry.schedule, entry.visitState, entry.slaStatus)
const dueDate = entry.schedule.startAt
const createdAt = entry.ticket.createdAt
const resolvedAt = entry.ticket.resolvedAt
if (dueDate && !entry.ticket.resolvedAt) {
const isCompleted = entry.visitState.completed
const visitCompletedAt = entry.visitState.completed ? entry.visitState.performedAt ?? entry.visitState.scheduledAt : null
if (isCompleted) {
if (visitCompletedAt && isWithinRange(visitCompletedAt, range)) {
summarySections.completed.push(summary)
}
continue
}
if (dueDate) {
const isFuture = isAfter(dueDate, now)
if (isFuture && isBefore(dueDate, upcomingWindowEnd)) {
summarySections.upcoming.push(summary)
@ -117,10 +135,6 @@ export function buildAgendaDataset(tickets: Ticket[], filters: AgendaFilterState
if (!dueDate && entry.ticket.status !== "RESOLVED" && isWithinRange(createdAt, range)) {
summarySections.unscheduled.push(summary)
}
if (resolvedAt && isWithinRange(resolvedAt, range)) {
summarySections.completed.push(summary)
}
}
summarySections.upcoming.sort((a, b) => compareNullableDate(a.startAt, b.startAt, 1))
@ -129,7 +143,10 @@ export function buildAgendaDataset(tickets: Ticket[], filters: AgendaFilterState
summarySections.completed.sort((a, b) => compareNullableDate(a.completedAt ?? null, b.completedAt ?? null, -1))
const calendarEvents = enriched
.filter((entry): entry is typeof entry & { schedule: { startAt: Date; endAt: Date } } => Boolean(entry.schedule.startAt && entry.schedule.endAt))
.filter(
(entry): entry is typeof entry & { schedule: { startAt: Date; endAt: Date } } =>
Boolean(entry.schedule.startAt && entry.schedule.endAt),
)
.map((entry) => ({
id: `${entry.ticket.id}-event`,
ticketId: entry.ticket.id,
@ -217,15 +234,51 @@ function deriveScheduleWindow(ticket: Ticket) {
return { startAt, endAt }
}
function computeSlaStatus(ticket: Ticket, now: Date): AgendaSlaStatus {
const status = getSlaDisplayStatus(ticket, "solution", now)
if (status === "n/a") {
return "on_track"
function deriveVisitState(ticket: Ticket): VisitState {
const scheduledAt = ticket.dueAt ?? null
const rawStatus = (ticket.visitStatus ?? null) as VisitStatus | null
const performedAt = ticket.visitPerformedAt ?? null
// Se já existe registro de execução, considere concluída mesmo que o status não tenha sido persistido.
if (!rawStatus && performedAt) {
return { status: "done", scheduledAt, performedAt, completed: true }
}
if (!rawStatus) {
return { status: null, scheduledAt, performedAt: null, completed: false }
}
const normalized = rawStatus.toLowerCase() as VisitStatus
const completed = normalized === "done" || normalized === "no_show" || normalized === "canceled"
// Se reabrir (status != concluído), zera performedAt.
const effectivePerformed = completed ? performedAt : null
return {
status: normalized,
scheduledAt,
performedAt: effectivePerformed,
completed,
}
return status
}
function buildSummary(ticket: Ticket, schedule: { startAt: Date | null; endAt: Date | null }, slaStatus: AgendaSlaStatus): AgendaTicketSummary {
function computeVisitSlaStatus(visitState: VisitState, now: Date): AgendaSlaStatus {
if (visitState.completed) {
if (visitState.status === "done") return "met"
if (visitState.status === "canceled") return "on_track"
return "breached"
}
if (visitState.scheduledAt) {
if (isBefore(visitState.scheduledAt, now)) {
return "breached"
}
return "on_track"
}
return "on_track"
}
function buildSummary(
ticket: Ticket,
schedule: { startAt: Date | null; endAt: Date | null },
visitState: VisitState,
slaStatus: AgendaSlaStatus,
): AgendaTicketSummary {
const completedAt = visitState.completed ? visitState.performedAt ?? visitState.scheduledAt : ticket.resolvedAt ?? null
return {
id: ticket.id,
reference: ticket.reference,
@ -237,7 +290,8 @@ function buildSummary(ticket: Ticket, schedule: { startAt: Date | null; endAt: D
startAt: schedule.startAt,
endAt: ticket.resolvedAt ?? schedule.endAt,
slaStatus,
completedAt: ticket.resolvedAt ?? null,
visitStatus: visitState.status ?? null,
completedAt,
href: `/tickets/${ticket.id}`,
}
}

View file

@ -111,6 +111,8 @@ const serverTicketSchema = z.object({
slaPausedBy: z.string().nullable().optional(),
slaPausedMs: z.number().nullable().optional(),
dueAt: z.number().nullable().optional(),
visitStatus: z.string().nullable().optional(),
visitPerformedAt: z.number().nullable().optional(),
firstResponseAt: z.number().nullable().optional(),
resolvedAt: z.number().nullable().optional(),
closedAt: z.number().nullable().optional(),
@ -280,6 +282,8 @@ export function mapTicketFromServer(input: unknown) {
slaPausedAt: s.slaPausedAt ? new Date(s.slaPausedAt) : null,
slaPausedBy: s.slaPausedBy ?? null,
slaPausedMs: typeof s.slaPausedMs === "number" ? s.slaPausedMs : null,
visitStatus: typeof s.visitStatus === "string" ? s.visitStatus : null,
visitPerformedAt: s.visitPerformedAt ? new Date(s.visitPerformedAt) : null,
workSummary: s.workSummary
? {
totalWorkedMs: s.workSummary.totalWorkedMs,
@ -362,6 +366,8 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
updatedAt: new Date(base.updatedAt),
createdAt: new Date(base.createdAt),
dueAt: base.dueAt ? new Date(base.dueAt) : null,
visitStatus: typeof base.visitStatus === "string" ? base.visitStatus : null,
visitPerformedAt: base.visitPerformedAt ? new Date(base.visitPerformedAt) : null,
firstResponseAt: base.firstResponseAt ? new Date(base.firstResponseAt) : null,
resolvedAt: base.resolvedAt ? new Date(base.resolvedAt) : null,
closedAt: base.closedAt ? new Date(base.closedAt) : null,

View file

@ -11,6 +11,8 @@ export type TicketStatus = z.infer<typeof ticketStatusSchema>
const slaStatusSchema = z.enum(["pending", "met", "breached", "n/a"])
const slaTimeModeSchema = z.enum(["business", "calendar"])
export const visitStatusSchema = z.enum(["scheduled", "en_route", "in_service", "done", "no_show", "canceled"])
export type VisitStatus = z.infer<typeof visitStatusSchema>
export const ticketSlaSnapshotSchema = z.object({
categoryId: z.string().optional(),
@ -163,6 +165,8 @@ export const ticketSchema = z.object({
slaPausedBy: z.string().nullable().optional(),
slaPausedMs: z.number().nullable().optional(),
dueAt: z.coerce.date().nullable(),
visitStatus: visitStatusSchema.nullable().optional(),
visitPerformedAt: z.coerce.date().nullable().optional(),
firstResponseAt: z.coerce.date().nullable(),
resolvedAt: z.coerce.date().nullable(),
closedAt: z.coerce.date().nullable().optional(),

View file

@ -16,6 +16,7 @@ export const TICKET_TIMELINE_LABELS: Record<string, string> = {
MANAGER_NOTIFIED: "Gestor notificado",
VISIT_SCHEDULED: "Visita agendada",
VISIT_SCHEDULE_CHANGED: "Data da visita alterada",
VISIT_STATUS_CHANGED: "Status da visita atualizado",
CSAT_RECEIVED: "CSAT recebido",
CSAT_RATED: "CSAT avaliado",
TICKET_LINKED: "Chamado vinculado",