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")
}
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<Doc<"tickets">> = { 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,
});
})
},
});

View file

@ -49,12 +49,12 @@ export function TicketQueueSummaryCards({ queues, layout = "default" }: TicketQu
<div className="h-full min-h-0 overflow-auto">
{/* Grade responsiva: compacta no modo widget, ampla nos demais contextos */}
<div className={gridLayoutClass}>
{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 (
<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"
>
<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 [visitDatePickerOpen, setVisitDatePickerOpen] = useState(false)
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 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) {
<span className={subtleBadgeClass}>{updatedRelative}</span>
</div>
</div>
{isVisitQueueTicket ? (
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Data da visita</span>
{visitSectionEnabled ? (
<div
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 ? (
<div className="space-y-2">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_160px]">
@ -1652,12 +1704,18 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<PopoverTrigger asChild>
<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
? format(visitDatePickerValue, "dd/MM/yyyy", { locale: ptBR })
: "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>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
@ -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"
}`}
/>
</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>
) : (
<span className={sectionValueClass}>
@ -1699,10 +1761,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</span>
)}
</div>
) : ticket.dueAt ? (
) : slaDueDate ? (
<div className="flex flex-col gap-1">
<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>
) : null}
{ticket.slaSnapshot ? (

View file

@ -47,6 +47,7 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
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) {
</span>
) : null}
<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>
</div>
{(() => {
@ -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 (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())) {
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") {
message = "CSAT recebido"

View file

@ -15,6 +15,7 @@ export const TICKET_TIMELINE_LABELS: Record<string, string> = {
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",

View file

@ -267,6 +267,15 @@ function buildTimelineMessage(type: string, payload: Record<string, unknown> | 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<string, unknown> | 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":