feat: agenda polish, SLA sync, filters

This commit is contained in:
Esdras Renan 2025-11-08 02:34:43 -03:00
parent 7fb6c65d9a
commit 6ab8a6ce89
40 changed files with 2771 additions and 154 deletions

255
src/lib/agenda-utils.ts Normal file
View file

@ -0,0 +1,255 @@
import {
addMinutes,
endOfDay,
endOfMonth,
endOfWeek,
isAfter,
isBefore,
isWithinInterval,
startOfDay,
startOfMonth,
startOfWeek,
} from "date-fns"
import type { Ticket, TicketPriority } from "@/lib/schemas/ticket"
import type { AgendaFilterState, AgendaPeriod } from "@/components/agenda/agenda-filters"
import { getSlaDisplayStatus, getSlaDueDate } from "@/lib/sla-utils"
import { isVisitTicket } from "@/lib/ticket-matchers"
export type AgendaSlaStatus = "on_track" | "at_risk" | "breached" | "met"
export type AgendaTicketSummary = {
id: string
reference: number
subject: string
queue: string | null
company: string | null
priority: TicketPriority
location?: string | null
startAt: Date | null
endAt: Date | null
slaStatus: AgendaSlaStatus
completedAt?: Date | null
href: string
}
export type AgendaCalendarEvent = {
id: string
ticketId: string
reference: number
title: string
queue: string | null
priority: TicketPriority
start: Date
end: Date
slaStatus: AgendaSlaStatus
href: string
}
export type AgendaDataset = {
range: { start: Date; end: Date }
availableQueues: string[]
kpis: {
pending: number
inProgress: number
paused: number
outsideSla: string
}
sections: {
upcoming: AgendaTicketSummary[]
overdue: AgendaTicketSummary[]
unscheduled: AgendaTicketSummary[]
completed: AgendaTicketSummary[]
}
calendarEvents: AgendaCalendarEvent[]
}
const DEFAULT_EVENT_DURATION_MINUTES = 60
export function buildAgendaDataset(tickets: Ticket[], filters: AgendaFilterState): AgendaDataset {
const now = new Date()
const range = computeRange(filters.period, now)
const availableQueues = Array.from(
new Set(
tickets
.map((ticket) => ticket.queue?.trim())
.filter((queue): queue is string => Boolean(queue))
)
).sort((a, b) => a.localeCompare(b, "pt-BR"))
const filteredTickets = tickets
.filter((ticket) => matchesFilters(ticket, filters))
.filter((ticket) => isVisitTicket(ticket))
const enriched = filteredTickets.map((ticket) => {
const schedule = deriveScheduleWindow(ticket)
const slaStatus = computeSlaStatus(ticket, now)
return { ticket, schedule, slaStatus }
})
const summarySections = {
upcoming: [] as AgendaTicketSummary[],
overdue: [] as AgendaTicketSummary[],
unscheduled: [] as AgendaTicketSummary[],
completed: [] as AgendaTicketSummary[],
}
for (const entry of enriched) {
const summary = buildSummary(entry.ticket, entry.schedule, entry.slaStatus)
const dueDate = entry.schedule.startAt
const createdAt = entry.ticket.createdAt
const resolvedAt = entry.ticket.resolvedAt
if (dueDate && isWithinRange(dueDate, range)) {
if (!entry.ticket.resolvedAt && isAfter(dueDate, now)) {
summarySections.upcoming.push(summary)
}
if (!entry.ticket.resolvedAt && isBefore(dueDate, now)) {
summarySections.overdue.push(summary)
}
}
if (!dueDate && entry.ticket.status !== "RESOLVED" && isWithinRange(createdAt, range)) {
summarySections.unscheduled.push(summary)
}
if (resolvedAt && isWithinRange(resolvedAt, range)) {
summarySections.completed.push(summary)
}
}
summarySections.upcoming.sort((a, b) => compareNullableDate(a.startAt, b.startAt, 1))
summarySections.overdue.sort((a, b) => compareNullableDate(a.startAt, b.startAt, -1))
summarySections.unscheduled.sort((a, b) => compareByPriorityThenReference(a, b))
summarySections.completed.sort((a, b) => compareNullableDate(a.completedAt ?? null, b.completedAt ?? null, -1))
const calendarEvents = enriched
.filter((entry): entry is typeof entry & { schedule: { startAt: Date; endAt: Date } } => Boolean(entry.schedule.startAt && entry.schedule.endAt))
.map((entry) => ({
id: `${entry.ticket.id}-event`,
ticketId: entry.ticket.id,
reference: entry.ticket.reference,
title: entry.ticket.subject,
queue: entry.ticket.queue ?? null,
priority: entry.ticket.priority,
start: entry.schedule.startAt!,
end: entry.schedule.endAt!,
slaStatus: entry.slaStatus,
href: `/tickets/${entry.ticket.id}`,
}))
.sort((a, b) => a.start.getTime() - b.start.getTime())
const outsideSlaCount = enriched.filter((entry) => entry.slaStatus === "breached" || entry.slaStatus === "at_risk").length
const outsideSlaPct = filteredTickets.length ? Math.round((outsideSlaCount / filteredTickets.length) * 100) : 0
const dataset: AgendaDataset = {
range,
availableQueues,
kpis: {
pending: countByStatus(filteredTickets, ["PENDING"]),
inProgress: countByStatus(filteredTickets, ["AWAITING_ATTENDANCE"]),
paused: countByStatus(filteredTickets, ["PAUSED"]),
outsideSla: `${outsideSlaPct}%`,
},
sections: summarySections,
calendarEvents,
}
return dataset
}
function matchesFilters(ticket: Ticket, filters: AgendaFilterState) {
if (filters.queues.length > 0) {
if (!ticket.queue) return false
const normalizedQueue = ticket.queue.toLowerCase()
const matchesQueue = filters.queues.some((queue) => queue.toLowerCase() === normalizedQueue)
if (!matchesQueue) return false
}
if (filters.priorities.length > 0 && !filters.priorities.includes(ticket.priority)) {
return false
}
if (filters.focusVisits && !isVisitTicket(ticket)) {
return false
}
return true
}
function computeRange(period: AgendaPeriod, pivot: Date) {
if (period === "today") {
return {
start: startOfDay(pivot),
end: endOfDay(pivot),
}
}
if (period === "month") {
return {
start: startOfMonth(pivot),
end: endOfMonth(pivot),
}
}
return {
start: startOfWeek(pivot, { weekStartsOn: 1 }),
end: endOfWeek(pivot, { weekStartsOn: 1 }),
}
}
function deriveScheduleWindow(ticket: Ticket) {
const due = getSlaDueDate(ticket, "solution")
if (!due) {
return { startAt: null, endAt: null }
}
const startAt = due
const endAt = addMinutes(startAt, DEFAULT_EVENT_DURATION_MINUTES)
return { startAt, endAt }
}
function computeSlaStatus(ticket: Ticket, now: Date): AgendaSlaStatus {
const status = getSlaDisplayStatus(ticket, "solution", now)
if (status === "n/a") {
return "on_track"
}
return status
}
function buildSummary(ticket: Ticket, schedule: { startAt: Date | null; endAt: Date | null }, slaStatus: AgendaSlaStatus): AgendaTicketSummary {
return {
id: ticket.id,
reference: ticket.reference,
subject: ticket.subject,
queue: ticket.queue ?? null,
company: ticket.company?.name ?? null,
priority: ticket.priority,
location: null,
startAt: schedule.startAt,
endAt: ticket.resolvedAt ?? schedule.endAt,
slaStatus,
completedAt: ticket.resolvedAt ?? null,
href: `/tickets/${ticket.id}`,
}
}
function isWithinRange(date: Date, range: { start: Date; end: Date }) {
return isWithinInterval(date, range)
}
function countByStatus(tickets: Ticket[], statuses: Ticket["status"][]): number {
const set = new Set(statuses)
return tickets.filter((ticket) => set.has(ticket.status)).length
}
function compareNullableDate(a: Date | null, b: Date | null, direction: 1 | -1) {
const aTime = a ? a.getTime() : Number.MAX_SAFE_INTEGER
const bTime = b ? b.getTime() : Number.MAX_SAFE_INTEGER
return (aTime - bTime) * direction
}
function compareByPriorityThenReference(a: AgendaTicketSummary, b: AgendaTicketSummary) {
const rank: Record<TicketPriority, number> = { URGENT: 1, HIGH: 2, MEDIUM: 3, LOW: 4 }
const diff = (rank[a.priority] ?? 5) - (rank[b.priority] ?? 5)
if (diff !== 0) return diff
return a.reference - b.reference
}

View file

@ -89,6 +89,27 @@ const serverTicketSchema = z.object({
.nullable(),
machine: serverMachineSummarySchema.optional().nullable(),
slaPolicy: z.any().nullable().optional(),
slaSnapshot: z
.object({
categoryId: z.any().optional(),
categoryName: z.string().optional(),
priority: z.string().optional(),
responseTargetMinutes: z.number().optional().nullable(),
responseMode: z.string().optional(),
solutionTargetMinutes: z.number().optional().nullable(),
solutionMode: z.string().optional(),
alertThreshold: z.number().optional(),
pauseStatuses: z.array(z.string()).optional(),
})
.nullable()
.optional(),
slaResponseDueAt: z.number().nullable().optional(),
slaSolutionDueAt: z.number().nullable().optional(),
slaResponseStatus: z.string().nullable().optional(),
slaSolutionStatus: z.string().nullable().optional(),
slaPausedAt: z.number().nullable().optional(),
slaPausedBy: z.string().nullable().optional(),
slaPausedMs: z.number().nullable().optional(),
dueAt: z.number().nullable().optional(),
firstResponseAt: z.number().nullable().optional(),
resolvedAt: z.number().nullable().optional(),
@ -200,6 +221,19 @@ export function mapTicketFromServer(input: unknown) {
...base
} = serverTicketSchema.parse(input);
const s = { csatScore, csatMaxScore, csatComment, csatRatedAt, csatRatedBy, ...base };
const slaSnapshot = s.slaSnapshot
? {
categoryId: s.slaSnapshot.categoryId ? String(s.slaSnapshot.categoryId) : undefined,
categoryName: s.slaSnapshot.categoryName ?? undefined,
priority: s.slaSnapshot.priority ?? "",
responseTargetMinutes: s.slaSnapshot.responseTargetMinutes ?? null,
responseMode: (s.slaSnapshot.responseMode ?? "calendar") as "business" | "calendar",
solutionTargetMinutes: s.slaSnapshot.solutionTargetMinutes ?? null,
solutionMode: (s.slaSnapshot.solutionMode ?? "calendar") as "business" | "calendar",
alertThreshold: typeof s.slaSnapshot.alertThreshold === "number" ? s.slaSnapshot.alertThreshold : null,
pauseStatuses: s.slaSnapshot.pauseStatuses ?? [],
}
: null;
const ui = {
...base,
status: normalizeTicketStatus(s.status),
@ -230,6 +264,14 @@ export function mapTicketFromServer(input: unknown) {
csatRatedAt: csatRatedAt ? new Date(csatRatedAt) : null,
csatRatedBy: csatRatedBy ?? null,
formTemplateLabel: base.formTemplateLabel ?? null,
slaSnapshot,
slaResponseDueAt: s.slaResponseDueAt ? new Date(s.slaResponseDueAt) : null,
slaSolutionDueAt: s.slaSolutionDueAt ? new Date(s.slaSolutionDueAt) : null,
slaResponseStatus: typeof s.slaResponseStatus === "string" ? (s.slaResponseStatus as string) : null,
slaSolutionStatus: typeof s.slaSolutionStatus === "string" ? (s.slaSolutionStatus as string) : null,
slaPausedAt: s.slaPausedAt ? new Date(s.slaPausedAt) : null,
slaPausedBy: s.slaPausedBy ?? null,
slaPausedMs: typeof s.slaPausedMs === "number" ? s.slaPausedMs : null,
workSummary: s.workSummary
? {
totalWorkedMs: s.workSummary.totalWorkedMs,
@ -271,6 +313,19 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
...base
} = serverTicketWithDetailsSchema.parse(input);
const s = { csatScore, csatMaxScore, csatComment, csatRatedAt, csatRatedBy, ...base };
const slaSnapshot = s.slaSnapshot
? {
categoryId: s.slaSnapshot.categoryId ? String(s.slaSnapshot.categoryId) : undefined,
categoryName: s.slaSnapshot.categoryName ?? undefined,
priority: s.slaSnapshot.priority ?? "",
responseTargetMinutes: s.slaSnapshot.responseTargetMinutes ?? null,
responseMode: (s.slaSnapshot.responseMode ?? "calendar") as "business" | "calendar",
solutionTargetMinutes: s.slaSnapshot.solutionTargetMinutes ?? null,
solutionMode: (s.slaSnapshot.solutionMode ?? "calendar") as "business" | "calendar",
alertThreshold: typeof s.slaSnapshot.alertThreshold === "number" ? s.slaSnapshot.alertThreshold : null,
pauseStatuses: s.slaSnapshot.pauseStatuses ?? [],
}
: null;
const customFields = Object.entries(s.customFields ?? {}).reduce<
Record<string, { label: string; type: string; value?: unknown; displayValue?: string }>
>(
@ -317,6 +372,14 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
status: base.machine.status ?? null,
}
: null,
slaSnapshot,
slaResponseDueAt: base.slaResponseDueAt ? new Date(base.slaResponseDueAt) : null,
slaSolutionDueAt: base.slaSolutionDueAt ? new Date(base.slaSolutionDueAt) : null,
slaResponseStatus: typeof base.slaResponseStatus === "string" ? (base.slaResponseStatus as string) : null,
slaSolutionStatus: typeof base.slaSolutionStatus === "string" ? (base.slaSolutionStatus as string) : null,
slaPausedAt: base.slaPausedAt ? new Date(base.slaPausedAt) : null,
slaPausedBy: base.slaPausedBy ?? null,
slaPausedMs: typeof base.slaPausedMs === "number" ? base.slaPausedMs : null,
timeline: base.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })),
comments: base.comments.map((c) => ({
...c,

View file

@ -6,8 +6,25 @@ export const ticketStatusSchema = z.enum([
"PAUSED",
"RESOLVED",
])
export type TicketStatus = z.infer<typeof ticketStatusSchema>
export type TicketStatus = z.infer<typeof ticketStatusSchema>
const slaStatusSchema = z.enum(["pending", "met", "breached", "n/a"])
const slaTimeModeSchema = z.enum(["business", "calendar"])
export const ticketSlaSnapshotSchema = z.object({
categoryId: z.string().optional(),
categoryName: z.string().optional(),
priority: z.string(),
responseTargetMinutes: z.number().nullable().optional(),
responseMode: slaTimeModeSchema.optional(),
solutionTargetMinutes: z.number().nullable().optional(),
solutionMode: slaTimeModeSchema.optional(),
alertThreshold: z.number().optional(),
pauseStatuses: z.array(z.string()).default([]),
})
export type TicketSlaSnapshot = z.infer<typeof ticketSlaSnapshotSchema>
export const ticketPrioritySchema = z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"])
export type TicketPriority = z.infer<typeof ticketPrioritySchema>
@ -130,15 +147,23 @@ export const ticketSchema = z.object({
company: ticketCompanySummarySchema.optional().nullable(),
machine: ticketMachineSummarySchema.nullable().optional(),
slaPolicy: z
.object({
id: z.string(),
name: z.string(),
targetMinutesToFirstResponse: z.number().nullable(),
targetMinutesToResolution: z.number().nullable(),
})
.nullable(),
dueAt: z.coerce.date().nullable(),
firstResponseAt: z.coerce.date().nullable(),
.object({
id: z.string(),
name: z.string(),
targetMinutesToFirstResponse: z.number().nullable(),
targetMinutesToResolution: z.number().nullable(),
})
.nullable(),
slaSnapshot: ticketSlaSnapshotSchema.nullable().optional(),
slaResponseDueAt: z.coerce.date().nullable().optional(),
slaSolutionDueAt: z.coerce.date().nullable().optional(),
slaResponseStatus: slaStatusSchema.nullable().optional(),
slaSolutionStatus: slaStatusSchema.nullable().optional(),
slaPausedAt: z.coerce.date().nullable().optional(),
slaPausedBy: z.string().nullable().optional(),
slaPausedMs: z.number().nullable().optional(),
dueAt: z.coerce.date().nullable(),
firstResponseAt: z.coerce.date().nullable(),
resolvedAt: z.coerce.date().nullable(),
updatedAt: z.coerce.date(),
createdAt: z.coerce.date(),

66
src/lib/sla-utils.ts Normal file
View file

@ -0,0 +1,66 @@
import type { Ticket } from "@/lib/schemas/ticket"
export type SlaTimerType = "response" | "solution"
export type SlaDisplayStatus = "on_track" | "at_risk" | "breached" | "met" | "n/a"
const DEFAULT_ALERT_THRESHOLD = 0.8
export function getSlaDueDate(ticket: Ticket, type: SlaTimerType): Date | null {
if (type === "response") {
return ticket.slaResponseDueAt ?? null
}
return ticket.slaSolutionDueAt ?? ticket.dueAt ?? null
}
export function getSlaDisplayStatus(ticket: Ticket, type: SlaTimerType, now: Date = new Date()): SlaDisplayStatus {
const snapshot = ticket.slaSnapshot
const dueAt = getSlaDueDate(ticket, type)
const finalStatus = type === "response" ? ticket.slaResponseStatus : ticket.slaSolutionStatus
if (!snapshot || !dueAt) {
if (finalStatus === "met" || finalStatus === "breached") {
return finalStatus
}
return "n/a"
}
if (finalStatus === "met" || finalStatus === "breached") {
return finalStatus
}
const completedAt = type === "response" ? ticket.firstResponseAt : ticket.resolvedAt
if (completedAt) {
return completedAt.getTime() <= dueAt.getTime() ? "met" : "breached"
}
const elapsed = getEffectiveElapsedMs(ticket, now)
const total = dueAt.getTime() - ticket.createdAt.getTime()
if (total <= 0) {
return now.getTime() <= dueAt.getTime() ? "on_track" : "breached"
}
if (now.getTime() > dueAt.getTime()) {
return "breached"
}
const threshold = snapshot.alertThreshold ?? DEFAULT_ALERT_THRESHOLD
const ratio = elapsed / total
if (ratio >= 1) {
return "breached"
}
if (ratio >= threshold) {
return "at_risk"
}
return "on_track"
}
function getEffectiveElapsedMs(ticket: Ticket, now: Date) {
const pausedMs = ticket.slaPausedMs ?? 0
const pausedAt = ticket.slaPausedAt ?? null
const createdAt = ticket.createdAt instanceof Date ? ticket.createdAt : new Date(ticket.createdAt)
let elapsed = now.getTime() - createdAt.getTime() - pausedMs
if (pausedAt) {
elapsed -= now.getTime() - pausedAt.getTime()
}
return Math.max(0, elapsed)
}

25
src/lib/ticket-filters.ts Normal file
View file

@ -0,0 +1,25 @@
import type { TicketStatus } from "@/lib/schemas/ticket"
export type TicketFiltersState = {
search: string
status: TicketStatus | null
priority: string | null
queue: string | null
channel: string | null
company: string | null
assigneeId: string | null
view: "active" | "completed"
focusVisits: boolean
}
export const defaultTicketFilters: TicketFiltersState = {
search: "",
status: null,
priority: null,
queue: null,
channel: null,
company: null,
assigneeId: null,
view: "active",
focusVisits: false,
}

View file

@ -0,0 +1,12 @@
import type { Ticket } from "@/lib/schemas/ticket"
export const VISIT_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"]
export function isVisitTicket(ticket: Ticket): boolean {
const queueName = ticket.queue?.toLowerCase() ?? ""
if (VISIT_KEYWORDS.some((keyword) => queueName.includes(keyword))) {
return true
}
const tags = Array.isArray(ticket.tags) ? ticket.tags : []
return tags.some((tag) => VISIT_KEYWORDS.some((keyword) => tag.toLowerCase().includes(keyword)))
}

View file

@ -4,8 +4,14 @@ import { toast } from "sonner"
const METHODS = ["success", "error", "info", "warning", "message", "loading"] as const
const TRAILING_PUNCTUATION_REGEX = /[\s!?.,;:]+$/u
const toastAny = toast as typeof toast & { __punctuationPatched?: boolean }
type ToastMethodKey = (typeof METHODS)[number]
type PatchedToast = typeof toast &
Pick<typeof toast, ToastMethodKey | "promise"> & {
__punctuationPatched?: boolean
}
const patchedToast = toast as PatchedToast
function stripTrailingPunctuation(value: string): string {
const trimmed = value.trimEnd()
@ -32,25 +38,27 @@ function sanitizeOptions<T>(options: T): T {
}
function wrapSimpleMethod<K extends ToastMethodKey>(method: K) {
const original = toastAny[method] as typeof toast[K]
const original = patchedToast[method]
if (typeof original !== "function") return
const patched = ((...args: Parameters<typeof toast[K]>) => {
const nextArgs = args.slice() as Parameters<typeof toast[K]>
type ToastFn = (...args: unknown[]) => unknown
const callable = original as ToastFn
const patched = ((...args: Parameters<ToastFn>) => {
const nextArgs = args.slice()
if (nextArgs.length > 0) {
nextArgs[0] = sanitizeContent(nextArgs[0])
}
if (nextArgs.length > 1) {
nextArgs[1] = sanitizeOptions(nextArgs[1])
}
return original.apply(null, nextArgs as Parameters<typeof toast[K]>)
}) as typeof toast[K]
toastAny[method] = patched
return callable(...nextArgs)
}) as typeof patchedToast[K]
patchedToast[method] = patched
}
function wrapPromise() {
const originalPromise = toastAny.promise
const originalPromise = patchedToast.promise
if (typeof originalPromise !== "function") return
toastAny.promise = ((promise, messages) => {
patchedToast.promise = ((promise, messages) => {
const normalizedMessages =
messages && typeof messages === "object"
? ({
@ -66,8 +74,8 @@ function wrapPromise() {
}) as typeof toast.promise
}
if (!toastAny.__punctuationPatched) {
toastAny.__punctuationPatched = true
if (!patchedToast.__punctuationPatched) {
patchedToast.__punctuationPatched = true
METHODS.forEach(wrapSimpleMethod)
wrapPromise()
}