From 66559eafbf48b148d0eb8c5f68f651ef08d9120b Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Wed, 26 Nov 2025 14:21:31 -0300 Subject: [PATCH] feat(visits): concluir/reabrir visita sem poluir agenda --- convex/schema.ts | 2 + convex/tickets.ts | 71 ++++++++++++++ src/app/api/admin/users/[id]/route.ts | 22 +++-- .../tickets/ticket-queue-summary.tsx | 2 +- .../tickets/ticket-summary-header.tsx | 97 ++++++++++++++++++- src/lib/agenda-utils.ts | 90 +++++++++++++---- src/lib/mappers/ticket.ts | 6 ++ src/lib/schemas/ticket.ts | 4 + src/lib/ticket-timeline-labels.ts | 1 + 9 files changed, 264 insertions(+), 31 deletions(-) diff --git a/convex/schema.ts b/convex/schema.ts index 619a6d4..d1e8ba8 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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()), diff --git a/convex/tickets.ts b/convex/tickets.ts index 88e3ca4..c0e27cd 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -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 }) => { diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 88bc087..ceff6eb 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -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 = ["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 = { 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 }, diff --git a/src/components/tickets/ticket-queue-summary.tsx b/src/components/tickets/ticket-queue-summary.tsx index 00a7858..a4e0ed9 100644 --- a/src/components/tickets/ticket-queue-summary.tsx +++ b/src/components/tickets/ticket-queue-summary.tsx @@ -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" } diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx index 008c39b..df949fe 100644 --- a/src/components/tickets/ticket-summary-header.tsx +++ b/src/components/tickets/ticket-summary-header.tsx @@ -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 = { + scheduled: "Agendada", + en_route: "Em deslocamento", + in_service: "Em atendimento", + done: "Concluída", + no_show: "No-show", + canceled: "Cancelada", +} +const VISIT_STATUS_TONES: Record = { + 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(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} ) : ( - - {ticket.dueAt ? format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR }) : "Sem data definida"} - +
+ + {ticket.dueAt ? format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR }) : "Sem data definida"} + +
+ {visitStatusValue ? ( + + {VISIT_STATUS_LABELS[visitStatusValue] ?? visitStatusValue} + + ) : null} + {!isManager && visitStatusValue !== "done" ? ( + + ) : null} + {!isManager && visitStatusValue && visitStatusValue !== "scheduled" && visitStatusValue !== "in_service" ? ( + + ) : null} +
+ {visitStatusValue === "done" && visitPerformedAt ? ( + + Concluída em {format(visitPerformedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} + + ) : null} +
)} ) : slaDueDate ? ( diff --git a/src/lib/agenda-utils.ts b/src/lib/agenda-utils.ts index 9e0838e..5da3928 100644 --- a/src/lib/agenda-utils.ts +++ b/src/lib/agenda-utils.ts @@ -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 +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}`, } } diff --git a/src/lib/mappers/ticket.ts b/src/lib/mappers/ticket.ts index 30ce62a..90a6ae3 100644 --- a/src/lib/mappers/ticket.ts +++ b/src/lib/mappers/ticket.ts @@ -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, diff --git a/src/lib/schemas/ticket.ts b/src/lib/schemas/ticket.ts index 3400bee..e34b4aa 100644 --- a/src/lib/schemas/ticket.ts +++ b/src/lib/schemas/ticket.ts @@ -11,6 +11,8 @@ export type TicketStatus = z.infer 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 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(), diff --git a/src/lib/ticket-timeline-labels.ts b/src/lib/ticket-timeline-labels.ts index 7d0dc6e..024d846 100644 --- a/src/lib/ticket-timeline-labels.ts +++ b/src/lib/ticket-timeline-labels.ts @@ -16,6 +16,7 @@ export const TICKET_TIMELINE_LABELS: Record = { 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",