feat: enforce visit scheduling ux
This commit is contained in:
parent
6473e8d40f
commit
72a4748a81
6 changed files with 160 additions and 36 deletions
|
|
@ -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,
|
||||
});
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue