255 lines
7.6 KiB
TypeScript
255 lines
7.6 KiB
TypeScript
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
|
|
}
|