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
}