feat: enforce visit scheduling ux

This commit is contained in:
Esdras Renan 2025-11-18 19:59:27 -03:00
parent 6473e8d40f
commit 72a4748a81
6 changed files with 160 additions and 36 deletions

View file

@ -3501,6 +3501,8 @@ export const updateVisitSchedule = mutation({
throw new ConvexError("Somente tickets da fila de visitas possuem data de visita") throw new ConvexError("Somente tickets da fila de visitas possuem data de visita")
} }
const now = Date.now() const now = Date.now()
const previousVisitDate = typeof ticketDoc.dueAt === "number" ? ticketDoc.dueAt : null
const actor = viewer.user
await ctx.db.patch(ticketId, { await ctx.db.patch(ticketId, {
dueAt: visitDate, dueAt: visitDate,
updatedAt: now, updatedAt: now,
@ -3510,7 +3512,10 @@ export const updateVisitSchedule = mutation({
type: "VISIT_SCHEDULE_CHANGED", type: "VISIT_SCHEDULE_CHANGED",
payload: { payload: {
visitDate, visitDate,
previousVisitDate,
actorId, actorId,
actorName: actor.name,
actorAvatar: actor.avatarUrl ?? undefined,
}, },
createdAt: now, createdAt: now,
}) })
@ -3534,15 +3539,21 @@ export const changeQueue = mutation({
if (!queue || queue.tenantId !== ticketDoc.tenantId) { if (!queue || queue.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Fila inválida") throw new ConvexError("Fila inválida")
} }
const now = Date.now(); const now = Date.now()
await ctx.db.patch(ticketId, { queueId, updatedAt: now }); const queueName = normalizeQueueName(queue)
const queueName = normalizeQueueName(queue); const normalizedQueueLabel = (queueName ?? queue.name ?? "").toLowerCase()
const isVisitQueueTarget = VISIT_QUEUE_KEYWORDS.some((keyword) => normalizedQueueLabel.includes(keyword))
const patch: Partial<Doc<"tickets">> = { queueId, updatedAt: now }
if (!isVisitQueueTarget) {
patch.dueAt = ticketDoc.slaSolutionDueAt ?? undefined
}
await ctx.db.patch(ticketId, patch)
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId, ticketId,
type: "QUEUE_CHANGED", type: "QUEUE_CHANGED",
payload: { queueId, queueName, actorId }, payload: { queueId, queueName, actorId },
createdAt: now, createdAt: now,
}); })
}, },
}); });

View file

@ -49,12 +49,12 @@ export function TicketQueueSummaryCards({ queues, layout = "default" }: TicketQu
<div className="h-full min-h-0 overflow-auto"> <div className="h-full min-h-0 overflow-auto">
{/* Grade responsiva: compacta no modo widget, ampla nos demais contextos */} {/* Grade responsiva: compacta no modo widget, ampla nos demais contextos */}
<div className={gridLayoutClass}> <div className={gridLayoutClass}>
{data.map((queue) => { {data.map((queue, index) => {
const totalOpen = queue.pending + queue.inProgress + queue.paused const totalOpen = queue.pending + queue.inProgress + queue.paused
const breachPercent = totalOpen === 0 ? 0 : Math.round((queue.breached / totalOpen) * 100) const breachPercent = totalOpen === 0 ? 0 : Math.round((queue.breached / totalOpen) * 100)
return ( return (
<Card <Card
key={queue.id} key={queue.id ?? `${queue.name ?? "queue"}-${index}`}
className="min-w-0 rounded-2xl border border-slate-200 bg-white p-3.5 shadow-sm sm:p-4" className="min-w-0 rounded-2xl border border-slate-200 bg-white p-3.5 shadow-sm sm:p-4"
> >
<CardHeader className="min-w-0 pb-1.5 sm:pb-2"> <CardHeader className="min-w-0 pb-1.5 sm:pb-2">

View file

@ -220,13 +220,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [visitTimeInput, setVisitTimeInput] = useState<string | null>(initialVisitTimeValue) const [visitTimeInput, setVisitTimeInput] = useState<string | null>(initialVisitTimeValue)
const [visitDatePickerOpen, setVisitDatePickerOpen] = useState(false) const [visitDatePickerOpen, setVisitDatePickerOpen] = useState(false)
const [visitError, setVisitError] = useState<string | null>(null) const [visitError, setVisitError] = useState<string | null>(null)
const visitDirtyRef = useMemo(
() =>
isVisitQueueTicket &&
(visitDateInput !== initialVisitDateValue || visitTimeInput !== initialVisitTimeValue),
[isVisitQueueTicket, visitDateInput, initialVisitDateValue, visitTimeInput, initialVisitTimeValue],
)
const visitDirty = visitDirtyRef
const queuesEnabled = Boolean(isStaff && convexUserId) const queuesEnabled = Boolean(isStaff && convexUserId)
const companiesRemote = useQuery( const companiesRemote = useQuery(
api.companies.list, api.companies.list,
@ -331,6 +324,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId]) }, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId])
const currentQueueName = ticket.queue ?? "" const currentQueueName = ticket.queue ?? ""
const isAvulso = Boolean(((ticket.company ?? null) as { isAvulso?: boolean } | null)?.isAvulso ?? false) const isAvulso = Boolean(((ticket.company ?? null) as { isAvulso?: boolean } | null)?.isAvulso ?? false)
const hasSlaMetadata = Boolean(ticket.slaSnapshot || ticket.slaSolutionDueAt)
const resolvedWithSummary = useMemo(() => { const resolvedWithSummary = useMemo(() => {
if (!ticket.resolvedWithTicketId) return null if (!ticket.resolvedWithTicketId) return null
const linkedId = String(ticket.resolvedWithTicketId) const linkedId = String(ticket.resolvedWithTicketId)
@ -366,6 +360,30 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}, [ticket.resolvedWithTicketId, ticket.timeline]) }, [ticket.resolvedWithTicketId, ticket.timeline])
const [queueSelection, setQueueSelection] = useState(currentQueueName) const [queueSelection, setQueueSelection] = useState(currentQueueName)
const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, 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( const currentRequesterRecord = useMemo(
() => customers.find((customer) => customer.id === ticket.requester.id) ?? null, () => customers.find((customer) => customer.id === ticket.requester.id) ?? null,
[customers, ticket.requester.id] [customers, ticket.requester.id]
@ -424,7 +442,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId]) const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id]) const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id])
const visitHasInvalid = 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 formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty || visitDirty
const normalizedAssigneeReason = assigneeChangeReason.trim() const normalizedAssigneeReason = assigneeChangeReason.trim()
const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5 const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5
@ -559,7 +586,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setQueueSelection(currentQueueName) setQueueSelection(currentQueueName)
} }
if (isVisitQueueTicket && visitDirty && !isManager) { if (visitSectionEnabled && visitDirty && !isManager) {
if (!visitDateInput || !visitTimeInput) { if (!visitDateInput || !visitTimeInput) {
setVisitError("Informe a data e o horário da visita.") setVisitError("Informe a data e o horário da visita.")
throw new Error("invalid-visit-schedule") throw new Error("invalid-visit-schedule")
@ -587,7 +614,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
} }
throw visitScheduleError throw visitScheduleError
} }
} else if (isVisitQueueTicket && visitDirty && isManager) { } else if (visitSectionEnabled && visitDirty && isManager) {
setVisitDateInput(initialVisitDateValue) setVisitDateInput(initialVisitDateValue)
setVisitTimeInput(initialVisitTimeValue) setVisitTimeInput(initialVisitTimeValue)
} }
@ -733,12 +760,31 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setVisitError(null) setVisitError(null)
}, [editing, initialVisitDateValue, initialVisitTimeValue]) }, [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(() => { useEffect(() => {
if (!editing) { if (!editing) {
setVisitDatePickerOpen(false) setVisitDatePickerOpen(false)
} }
}, [editing]) }, [editing])
useEffect(() => {
if (!visitSectionEnabled) {
setVisitDatePickerOpen(false)
}
}, [visitSectionEnabled])
useEffect(() => { useEffect(() => {
if (visitError && visitDateInput && visitTimeInput) { if (visitError && visitDateInput && visitTimeInput) {
setVisitError(null) setVisitError(null)
@ -1642,9 +1688,15 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={subtleBadgeClass}>{updatedRelative}</span> <span className={subtleBadgeClass}>{updatedRelative}</span>
</div> </div>
</div> </div>
{isVisitQueueTicket ? ( {visitSectionEnabled ? (
<div className="flex flex-col gap-1"> <div
<span className={sectionLabelClass}>Data da visita</span> className={`flex flex-col gap-1 ${
shouldHighlightVisitFields ? "rounded-2xl border border-rose-200 bg-rose-50/70 p-3" : ""
}`}
>
<span className={`${sectionLabelClass} ${shouldHighlightVisitFields ? "text-rose-700" : ""}`}>
Data da visita
</span>
{editing && !isManager ? ( {editing && !isManager ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_160px]"> <div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_160px]">
@ -1652,12 +1704,18 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
className={`flex h-9 w-full items-center justify-between rounded-lg border border-slate-300 bg-white px-3 text-left text-sm shadow-sm hover:bg-slate-50 ${visitDateInput ? "text-neutral-800" : "text-muted-foreground"}`} className={`flex h-9 w-full items-center justify-between rounded-lg border bg-white px-3 text-left text-sm shadow-sm hover:bg-slate-50 ${
visitDateMissing
? "border-rose-400 text-rose-700"
: visitDateInput
? "border-slate-300 text-neutral-800"
: "border-slate-300 text-muted-foreground"
}`}
> >
{visitDatePickerValue {visitDatePickerValue
? format(visitDatePickerValue, "dd/MM/yyyy", { locale: ptBR }) ? format(visitDatePickerValue, "dd/MM/yyyy", { locale: ptBR })
: "Selecionar data"} : "Selecionar data"}
<CalendarIcon className="ml-2 size-4 text-neutral-500" /> <CalendarIcon className={`ml-2 size-4 ${visitDateMissing ? "text-rose-600" : "text-neutral-500"}`} />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
@ -1688,10 +1746,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
} }
}} }}
stepMinutes={5} 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"
}`}
/> />
</div> </div>
{visitError ? <p className="text-xs font-semibold text-rose-600">{visitError}</p> : null} {visitInlineError ? (
<p className="text-xs font-semibold text-rose-600">{visitInlineError}</p>
) : null}
</div> </div>
) : ( ) : (
<span className={sectionValueClass}> <span className={sectionValueClass}>
@ -1699,10 +1761,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</span> </span>
)} )}
</div> </div>
) : ticket.dueAt ? ( ) : slaDueDate ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className={sectionLabelClass}>SLA até</span> <span className={sectionLabelClass}>SLA até</span>
<span className={sectionValueClass}>{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span> <span className={sectionValueClass}>{format(slaDueDate, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div> </div>
) : null} ) : null}
{ticket.slaSnapshot ? ( {ticket.slaSnapshot ? (

View file

@ -47,6 +47,7 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
REQUESTER_CHANGED: IconUserCircle, REQUESTER_CHANGED: IconUserCircle,
MANAGER_NOTIFIED: IconUserCircle, MANAGER_NOTIFIED: IconUserCircle,
VISIT_SCHEDULED: IconCalendar, VISIT_SCHEDULED: IconCalendar,
VISIT_SCHEDULE_CHANGED: IconCalendar,
CSAT_RECEIVED: IconStar, CSAT_RECEIVED: IconStar,
CSAT_RATED: IconStar, CSAT_RATED: IconStar,
TICKET_LINKED: IconLink, TICKET_LINKED: IconLink,
@ -214,7 +215,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
</span> </span>
) : null} ) : null}
<span className="text-xs text-neutral-500"> <span className="text-xs text-neutral-500">
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })} {format(entry.createdAt, "dd/MM/yyyy, HH:mm", { locale: ptBR })}
</span> </span>
</div> </div>
{(() => { {(() => {
@ -515,16 +516,35 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
payload.managerUserId payload.managerUserId
message = manager ? `Gestor notificado: ${manager}` : "Gestor notificado" message = manager ? `Gestor notificado: ${manager}` : "Gestor notificado"
} }
if (entry.type === "VISIT_SCHEDULED") { if (entry.type === "VISIT_SCHEDULED" || entry.type === "VISIT_SCHEDULE_CHANGED") {
const scheduledRaw = payload.scheduledFor ?? payload.scheduledAt const formatVisitDate = (raw: unknown): string | null => {
let formatted: string | null = null if (typeof raw === "number" || typeof raw === "string") {
if (typeof scheduledRaw === "number" || typeof scheduledRaw === "string") { const date = new Date(raw)
const date = new Date(scheduledRaw)
if (!Number.isNaN(date.getTime())) { if (!Number.isNaN(date.getTime())) {
formatted = format(date, "dd MMM yyyy HH:mm", { locale: ptBR }) return format(date, "dd/MM/yyyy, HH:mm", { locale: ptBR })
} }
} }
message = formatted ? `Visita agendada para ${formatted}` : "Visita agendada" 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"
}
} }
if (entry.type === "CSAT_RECEIVED") { if (entry.type === "CSAT_RECEIVED") {
message = "CSAT recebido" message = "CSAT recebido"

View file

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

View file

@ -267,6 +267,15 @@ function buildTimelineMessage(type: string, payload: Record<string, unknown> | n
const sessionText = sessionDuration ? formatDurationMs(sessionDuration) : null const sessionText = sessionDuration ? formatDurationMs(sessionDuration) : null
const categoryName = p.categoryName as string | undefined const categoryName = p.categoryName as string | undefined
const subcategoryName = p.subcategoryName 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) { switch (type) {
case "STATUS_CHANGED": case "STATUS_CHANGED":
@ -323,8 +332,29 @@ function buildTimelineMessage(type: string, payload: Record<string, unknown> | n
} }
case "MANAGER_NOTIFIED": case "MANAGER_NOTIFIED":
return "Gestor notificado" return "Gestor notificado"
case "VISIT_SCHEDULED": case "VISIT_SCHEDULED": {
return "Visita agendada" 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": case "CSAT_RECEIVED":
return "CSAT recebido" return "CSAT recebido"
case "CSAT_RATED": case "CSAT_RATED":