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")
|
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,
|
||||||
});
|
})
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue