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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
66
src/lib/sla-utils.ts
Normal 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
25
src/lib/ticket-filters.ts
Normal 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,
|
||||
}
|
||||
12
src/lib/ticket-matchers.ts
Normal file
12
src/lib/ticket-matchers.ts
Normal 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)))
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue