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

@ -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 (!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"