feat: agenda polish, SLA sync, filters
This commit is contained in:
parent
7fb6c65d9a
commit
6ab8a6ce89
40 changed files with 2771 additions and 154 deletions
255
src/lib/agenda-utils.ts
Normal file
255
src/lib/agenda-utils.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue