diff --git a/convex/tickets.ts b/convex/tickets.ts index ce8212a..ab6c4e4 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -3501,6 +3501,8 @@ export const updateVisitSchedule = mutation({ throw new ConvexError("Somente tickets da fila de visitas possuem data de visita") } const now = Date.now() + const previousVisitDate = typeof ticketDoc.dueAt === "number" ? ticketDoc.dueAt : null + const actor = viewer.user await ctx.db.patch(ticketId, { dueAt: visitDate, updatedAt: now, @@ -3510,7 +3512,10 @@ export const updateVisitSchedule = mutation({ type: "VISIT_SCHEDULE_CHANGED", payload: { visitDate, + previousVisitDate, actorId, + actorName: actor.name, + actorAvatar: actor.avatarUrl ?? undefined, }, createdAt: now, }) @@ -3534,15 +3539,21 @@ export const changeQueue = mutation({ if (!queue || queue.tenantId !== ticketDoc.tenantId) { throw new ConvexError("Fila inválida") } - const now = Date.now(); - await ctx.db.patch(ticketId, { queueId, updatedAt: now }); - const queueName = normalizeQueueName(queue); + const now = Date.now() + const queueName = normalizeQueueName(queue) + const normalizedQueueLabel = (queueName ?? queue.name ?? "").toLowerCase() + const isVisitQueueTarget = VISIT_QUEUE_KEYWORDS.some((keyword) => normalizedQueueLabel.includes(keyword)) + const patch: Partial> = { queueId, updatedAt: now } + if (!isVisitQueueTarget) { + patch.dueAt = ticketDoc.slaSolutionDueAt ?? undefined + } + await ctx.db.patch(ticketId, patch) await ctx.db.insert("ticketEvents", { ticketId, type: "QUEUE_CHANGED", payload: { queueId, queueName, actorId }, createdAt: now, - }); + }) }, }); diff --git a/src/components/tickets/ticket-queue-summary.tsx b/src/components/tickets/ticket-queue-summary.tsx index e1fb3e6..a97a5b1 100644 --- a/src/components/tickets/ticket-queue-summary.tsx +++ b/src/components/tickets/ticket-queue-summary.tsx @@ -49,12 +49,12 @@ export function TicketQueueSummaryCards({ queues, layout = "default" }: TicketQu
{/* Grade responsiva: compacta no modo widget, ampla nos demais contextos */}
- {data.map((queue) => { + {data.map((queue, index) => { const totalOpen = queue.pending + queue.inProgress + queue.paused const breachPercent = totalOpen === 0 ? 0 : Math.round((queue.breached / totalOpen) * 100) return ( diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx index b6c0501..33e14df 100644 --- a/src/components/tickets/ticket-summary-header.tsx +++ b/src/components/tickets/ticket-summary-header.tsx @@ -220,13 +220,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { const [visitTimeInput, setVisitTimeInput] = useState(initialVisitTimeValue) const [visitDatePickerOpen, setVisitDatePickerOpen] = useState(false) const [visitError, setVisitError] = useState(null) - const visitDirtyRef = useMemo( - () => - isVisitQueueTicket && - (visitDateInput !== initialVisitDateValue || visitTimeInput !== initialVisitTimeValue), - [isVisitQueueTicket, visitDateInput, initialVisitDateValue, visitTimeInput, initialVisitTimeValue], - ) - const visitDirty = visitDirtyRef const queuesEnabled = Boolean(isStaff && convexUserId) const companiesRemote = useQuery( api.companies.list, @@ -331,6 +324,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { }, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId]) const currentQueueName = ticket.queue ?? "" const isAvulso = Boolean(((ticket.company ?? null) as { isAvulso?: boolean } | null)?.isAvulso ?? false) + const hasSlaMetadata = Boolean(ticket.slaSnapshot || ticket.slaSolutionDueAt) const resolvedWithSummary = useMemo(() => { if (!ticket.resolvedWithTicketId) return null const linkedId = String(ticket.resolvedWithTicketId) @@ -366,6 +360,30 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { }, [ticket.resolvedWithTicketId, ticket.timeline]) const [queueSelection, setQueueSelection] = useState(currentQueueName) const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName]) + const normalizedQueueSelection = useMemo(() => (queueSelection ?? "").toLowerCase(), [queueSelection]) + const isVisitQueueSelected = useMemo( + () => VISIT_KEYWORDS.some((keyword) => normalizedQueueSelection.includes(keyword)), + [normalizedQueueSelection], + ) + const visitSectionEnabled = editing ? isVisitQueueSelected : isVisitQueueTicket + const visitDirty = useMemo(() => { + if (!visitSectionEnabled) { + return false + } + if (!isVisitQueueTicket && isVisitQueueSelected) { + return true + } + return visitDateInput !== initialVisitDateValue || visitTimeInput !== initialVisitTimeValue + }, [ + visitSectionEnabled, + isVisitQueueTicket, + isVisitQueueSelected, + visitDateInput, + initialVisitDateValue, + visitTimeInput, + initialVisitTimeValue, + ]) + const previousVisitSelectionRef = useRef(isVisitQueueSelected) const currentRequesterRecord = useMemo( () => customers.find((customer) => customer.id === ticket.requester.id) ?? null, [customers, ticket.requester.id] @@ -424,7 +442,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId]) const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id]) const visitHasInvalid = - isVisitQueueTicket && visitDirty && (!visitDateInput || !visitTimeInput || Boolean(visitError)) + visitSectionEnabled && (!visitDateInput || !visitTimeInput || Boolean(visitError)) + const visitInlineError = + visitError ?? + (editing && visitSectionEnabled && (!visitDateInput || !visitTimeInput) + ? "Informe a data e o horário da visita para prosseguir." + : null) + const shouldHighlightVisitFields = Boolean(visitInlineError && editing && visitSectionEnabled) + const slaDueDate = !visitSectionEnabled && hasSlaMetadata ? ticket.dueAt : null + const visitDateMissing = !visitDateInput + const visitTimeMissing = !visitTimeInput const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty || visitDirty const normalizedAssigneeReason = assigneeChangeReason.trim() const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5 @@ -559,7 +586,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { setQueueSelection(currentQueueName) } - if (isVisitQueueTicket && visitDirty && !isManager) { + if (visitSectionEnabled && visitDirty && !isManager) { if (!visitDateInput || !visitTimeInput) { setVisitError("Informe a data e o horário da visita.") throw new Error("invalid-visit-schedule") @@ -587,7 +614,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { } throw visitScheduleError } - } else if (isVisitQueueTicket && visitDirty && isManager) { + } else if (visitSectionEnabled && visitDirty && isManager) { setVisitDateInput(initialVisitDateValue) setVisitTimeInput(initialVisitTimeValue) } @@ -733,12 +760,31 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { setVisitError(null) }, [editing, initialVisitDateValue, initialVisitTimeValue]) + useEffect(() => { + if (!editing) { + previousVisitSelectionRef.current = isVisitQueueSelected + return + } + if (isVisitQueueSelected && !previousVisitSelectionRef.current && !isVisitQueueTicket) { + setVisitDateInput(null) + setVisitTimeInput(null) + setVisitError(null) + } + previousVisitSelectionRef.current = isVisitQueueSelected + }, [editing, isVisitQueueSelected, isVisitQueueTicket]) + useEffect(() => { if (!editing) { setVisitDatePickerOpen(false) } }, [editing]) + useEffect(() => { + if (!visitSectionEnabled) { + setVisitDatePickerOpen(false) + } + }, [visitSectionEnabled]) + useEffect(() => { if (visitError && visitDateInput && visitTimeInput) { setVisitError(null) @@ -1642,9 +1688,15 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { {updatedRelative}
- {isVisitQueueTicket ? ( -
- Data da visita + {visitSectionEnabled ? ( +
+ + Data da visita + {editing && !isManager ? (
@@ -1652,12 +1704,18 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { @@ -1688,10 +1746,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { } }} stepMinutes={5} - className="h-9 rounded-lg border border-slate-300 bg-white text-sm font-medium text-neutral-800 shadow-sm" + className={`h-9 rounded-lg border bg-white text-sm font-medium shadow-sm ${ + visitTimeMissing ? "border-rose-400 text-rose-700" : "border-slate-300 text-neutral-800" + }`} />
- {visitError ?

{visitError}

: null} + {visitInlineError ? ( +

{visitInlineError}

+ ) : null}
) : ( @@ -1699,10 +1761,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { )}
- ) : ticket.dueAt ? ( + ) : slaDueDate ? (
SLA até - {format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} + {format(slaDueDate, "dd/MM/yyyy HH:mm", { locale: ptBR })}
) : null} {ticket.slaSnapshot ? ( diff --git a/src/components/tickets/ticket-timeline.tsx b/src/components/tickets/ticket-timeline.tsx index 737e866..e5c2950 100644 --- a/src/components/tickets/ticket-timeline.tsx +++ b/src/components/tickets/ticket-timeline.tsx @@ -47,6 +47,7 @@ const timelineIcons: Record> = { REQUESTER_CHANGED: IconUserCircle, MANAGER_NOTIFIED: IconUserCircle, VISIT_SCHEDULED: IconCalendar, + VISIT_SCHEDULE_CHANGED: IconCalendar, CSAT_RECEIVED: IconStar, CSAT_RATED: IconStar, TICKET_LINKED: IconLink, @@ -214,7 +215,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { ) : null} - {format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })} + {format(entry.createdAt, "dd/MM/yyyy, HH:mm", { locale: ptBR })}
{(() => { @@ -515,16 +516,35 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { payload.managerUserId message = manager ? `Gestor notificado: ${manager}` : "Gestor notificado" } - if (entry.type === "VISIT_SCHEDULED") { - const scheduledRaw = payload.scheduledFor ?? payload.scheduledAt - let formatted: string | null = null - if (typeof scheduledRaw === "number" || typeof scheduledRaw === "string") { - const date = new Date(scheduledRaw) - if (!Number.isNaN(date.getTime())) { - formatted = format(date, "dd MMM yyyy HH:mm", { locale: ptBR }) + if (entry.type === "VISIT_SCHEDULED" || entry.type === "VISIT_SCHEDULE_CHANGED") { + const formatVisitDate = (raw: unknown): string | null => { + if (typeof raw === "number" || typeof raw === "string") { + const date = new Date(raw) + if (!Number.isNaN(date.getTime())) { + return format(date, "dd/MM/yyyy, HH:mm", { locale: ptBR }) + } } + return null + } + const nextRaw = + (payload as { visitDate?: unknown }).visitDate ?? + (payload as { scheduledFor?: unknown }).scheduledFor ?? + (payload as { scheduledAt?: unknown }).scheduledAt + const previousRaw = + (payload as { previousVisitDate?: unknown }).previousVisitDate ?? + (payload as { previousScheduledFor?: unknown }).previousScheduledFor ?? + (payload as { previousScheduledAt?: unknown }).previousScheduledAt ?? + (payload as { from?: unknown }).from + const formattedNext = formatVisitDate(nextRaw) + const formattedPrevious = formatVisitDate(previousRaw) + if (entry.type === "VISIT_SCHEDULE_CHANGED") { + const previousSuffix = formattedPrevious ? ` (antes: ${formattedPrevious})` : "" + message = formattedNext + ? `Data da visita alterada para ${formattedNext}${previousSuffix}` + : "Data da visita alterada" + } else { + message = formattedNext ? `Visita agendada para ${formattedNext}` : "Visita agendada" } - message = formatted ? `Visita agendada para ${formatted}` : "Visita agendada" } if (entry.type === "CSAT_RECEIVED") { message = "CSAT recebido" diff --git a/src/lib/ticket-timeline-labels.ts b/src/lib/ticket-timeline-labels.ts index ebd3fb5..7d0dc6e 100644 --- a/src/lib/ticket-timeline-labels.ts +++ b/src/lib/ticket-timeline-labels.ts @@ -15,6 +15,7 @@ export const TICKET_TIMELINE_LABELS: Record = { REQUESTER_CHANGED: "Solicitante alterado", MANAGER_NOTIFIED: "Gestor notificado", VISIT_SCHEDULED: "Visita agendada", + VISIT_SCHEDULE_CHANGED: "Data da visita alterada", CSAT_RECEIVED: "CSAT recebido", CSAT_RATED: "CSAT avaliado", TICKET_LINKED: "Chamado vinculado", diff --git a/src/server/pdf/ticket-pdf-template.tsx b/src/server/pdf/ticket-pdf-template.tsx index 65c4e34..2943099 100644 --- a/src/server/pdf/ticket-pdf-template.tsx +++ b/src/server/pdf/ticket-pdf-template.tsx @@ -267,6 +267,15 @@ function buildTimelineMessage(type: string, payload: Record | n const sessionText = sessionDuration ? formatDurationMs(sessionDuration) : null const categoryName = p.categoryName as string | undefined const subcategoryName = p.subcategoryName as string | undefined + const formatVisitDate = (value: unknown): string | null => { + if (typeof value === "number" || typeof value === "string") { + const date = new Date(value) + if (!Number.isNaN(date.getTime())) { + return format(date, "dd MMM yyyy HH:mm", { locale: ptBR }) + } + } + return null + } switch (type) { case "STATUS_CHANGED": @@ -323,8 +332,29 @@ function buildTimelineMessage(type: string, payload: Record | n } case "MANAGER_NOTIFIED": return "Gestor notificado" - case "VISIT_SCHEDULED": - return "Visita agendada" + case "VISIT_SCHEDULED": { + const visitDate = + (p.visitDate as unknown) ?? p.scheduledFor ?? p.scheduledAt ?? p.to ?? null + const formatted = formatVisitDate(visitDate) + return formatted ? `Visita agendada para ${formatted}` : "Visita agendada" + } + case "VISIT_SCHEDULE_CHANGED": { + const nextVisit = + (p.visitDate as unknown) ?? p.scheduledFor ?? p.scheduledAt ?? p.to ?? null + const previousVisit = + p.previousVisitDate ?? + p.previousScheduledFor ?? + p.previousScheduledAt ?? + p.from ?? + null + const formattedNext = formatVisitDate(nextVisit) + const formattedPrevious = formatVisitDate(previousVisit) + if (formattedNext) { + const suffix = formattedPrevious ? ` (antes: ${formattedPrevious})` : "" + return `Data da visita alterada para ${formattedNext}${suffix}` + } + return "Data da visita alterada" + } case "CSAT_RECEIVED": return "CSAT recebido" case "CSAT_RATED":