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

View file

@ -0,0 +1,121 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useQuery } from "convex/react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale/pt-BR"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket } from "@/lib/schemas/ticket"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { CalendarPlus } from "lucide-react"
import { useAuth } from "@/lib/auth-client"
import { AgendaFilters, AgendaFilterState, AgendaPeriod, defaultAgendaFilters } from "@/components/agenda/agenda-filters"
import { AgendaSummaryView } from "@/components/agenda/agenda-summary-view"
import { AgendaCalendarView } from "@/components/agenda/agenda-calendar-view"
import { buildAgendaDataset, type AgendaDataset } from "@/lib/agenda-utils"
export function AgendaPageClient() {
const [activeTab, setActiveTab] = useState<"summary" | "calendar">("summary")
const [filters, setFilters] = useState<AgendaFilterState>(defaultAgendaFilters)
const { convexUserId, session } = useAuth()
const userId = convexUserId as Id<"users"> | null
const tenantId = session?.user?.tenantId ?? DEFAULT_TENANT_ID
const ticketsArgs = userId
? {
tenantId,
viewerId: userId,
status: undefined,
priority: filters.priorities.length ? filters.priorities : undefined,
queueId: undefined,
channel: undefined,
assigneeId: filters.onlyMyTickets ? userId : undefined,
search: undefined,
}
: "skip"
const ticketsRaw = useQuery(api.tickets.list, ticketsArgs)
const mappedTickets = useMemo<Ticket[] | null>(() => {
if (!Array.isArray(ticketsRaw)) return null
return mapTicketsFromServerList(ticketsRaw as unknown[])
}, [ticketsRaw])
const [cachedTickets, setCachedTickets] = useState<Ticket[]>([])
useEffect(() => {
if (mappedTickets) {
setCachedTickets(mappedTickets)
}
}, [mappedTickets])
const effectiveTickets = mappedTickets ?? cachedTickets
const isInitialLoading = !mappedTickets && cachedTickets.length === 0 && ticketsArgs !== "skip"
const dataset: AgendaDataset = useMemo(
() => buildAgendaDataset(effectiveTickets, filters),
[effectiveTickets, filters]
)
const greeting = getGreetingMessage()
const firstName = session?.user?.name?.split(" ")[0] ?? session?.user?.email?.split("@")[0] ?? "equipe"
const rangeDescription = formatRangeDescription(filters.period, dataset.range)
const headerLead = `${greeting}, ${firstName}! ${rangeDescription}`
return (
<AppShell
header={
<SiteHeader
title="Agenda"
lead={headerLead}
secondaryAction={
<Button variant="secondary" size="sm" className="gap-2" disabled>
<CalendarPlus className="size-4" />
Em breve: novo compromisso
</Button>
}
/>
}
>
<div className="flex flex-col gap-6 px-4 lg:px-6">
<AgendaFilters filters={filters} onChange={setFilters} queues={dataset.availableQueues} />
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as typeof activeTab)}>
<TabsList className="mb-4">
<TabsTrigger value="summary">Resumo</TabsTrigger>
<TabsTrigger value="calendar">Calendário</TabsTrigger>
</TabsList>
<TabsContent value="summary" className="focus-visible:outline-none">
<AgendaSummaryView data={dataset} isLoading={isInitialLoading} />
</TabsContent>
<TabsContent value="calendar" className="focus-visible:outline-none">
<AgendaCalendarView events={dataset.calendarEvents} range={dataset.range} />
</TabsContent>
</Tabs>
</div>
</AppShell>
)
}
function getGreetingMessage(date: Date = new Date()) {
const hour = date.getHours()
if (hour < 12) return "Bom dia"
if (hour < 18) return "Boa tarde"
return "Boa noite"
}
function formatRangeDescription(period: AgendaPeriod, range: { start: Date; end: Date }) {
if (period === "today") {
return `Hoje é ${format(range.start, "eeee, d 'de' MMMM", { locale: ptBR })}`
}
if (period === "week") {
return `Semana de ${format(range.start, "d MMM", { locale: ptBR })} a ${format(range.end, "d MMM", { locale: ptBR })}`
}
return `Mês de ${format(range.start, "MMMM 'de' yyyy", { locale: ptBR })}`
}

9
src/app/agenda/page.tsx Normal file
View file

@ -0,0 +1,9 @@
import { requireAuthenticatedSession } from "@/lib/auth-server"
import { AgendaPageClient } from "./agenda-page-client"
export default async function AgendaPage() {
await requireAuthenticatedSession()
return <AgendaPageClient />
}

View file

@ -1,8 +1,49 @@
import { TicketsPageClient } from "./tickets-page-client"
import { requireAuthenticatedSession } from "@/lib/auth-server"
import type { TicketFiltersState } from "@/lib/ticket-filters"
import type { TicketStatus } from "@/lib/schemas/ticket"
export default async function TicketsPage() {
type TicketsPageProps = {
searchParams?: Record<string, string | string[] | undefined>
}
export default async function TicketsPage({ searchParams }: TicketsPageProps) {
await requireAuthenticatedSession()
return <TicketsPageClient />
const initialFilters = deriveInitialFilters(searchParams ?? {})
return <TicketsPageClient initialFilters={initialFilters} />
}
function getParamValue(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) {
return value[0]
}
return value
}
function deriveInitialFilters(params: Record<string, string | string[] | undefined>): Partial<TicketFiltersState> {
const initial: Partial<TicketFiltersState> = {}
const view = getParamValue(params.view)
if (view === "completed" || view === "active") {
initial.view = view
}
const status = getParamValue(params.status)
if (status && ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"].includes(status)) {
initial.status = status as TicketStatus
}
const priority = getParamValue(params.priority)
if (priority) initial.priority = priority
const queue = getParamValue(params.queue)
if (queue) initial.queue = queue
const channel = getParamValue(params.channel)
if (channel) initial.channel = channel
const company = getParamValue(params.company)
if (company) initial.company = company
const assigneeId = getParamValue(params.assignee)
if (assigneeId) initial.assigneeId = assigneeId
const focus = getParamValue(params.focus)
if (focus === "visits") {
initial.focusVisits = true
}
return initial
}

View file

@ -4,6 +4,7 @@ import dynamic from "next/dynamic"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import type { TicketFiltersState } from "@/components/tickets/tickets-filters"
const TicketQueueSummaryCards = dynamic(
() =>
@ -29,7 +30,11 @@ const NewTicketDialog = dynamic(
{ ssr: false }
)
export function TicketsPageClient() {
type TicketsPageClientProps = {
initialFilters?: Partial<TicketFiltersState>
}
export function TicketsPageClient({ initialFilters }: TicketsPageClientProps = {}) {
return (
<AppShell
header={
@ -44,7 +49,7 @@ export function TicketsPageClient() {
<div className="px-4 lg:px-6">
<TicketQueueSummaryCards />
</div>
<TicketsView />
<TicketsView initialFilters={initialFilters} />
</div>
</AppShell>
)

View file

@ -1,6 +1,6 @@
"use client"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
@ -22,6 +22,8 @@ import {
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { cn } from "@/lib/utils"
type DeleteState<T extends "category" | "subcategory"> =
| { type: T; targetId: string; reason: string }
@ -36,6 +38,7 @@ export function CategoriesManager() {
const [subcategoryDraft, setSubcategoryDraft] = useState("")
const [subcategoryList, setSubcategoryList] = useState<string[]>([])
const [deleteState, setDeleteState] = useState<DeleteState<"category" | "subcategory">>(null)
const [slaCategory, setSlaCategory] = useState<TicketCategory | null>(null)
const createCategory = useMutation(api.categories.createCategory)
const deleteCategory = useMutation(api.categories.deleteCategory)
const updateCategory = useMutation(api.categories.updateCategory)
@ -196,6 +199,7 @@ export function CategoriesManager() {
const pendingDelete = deleteState
const isDisabled = !convexUserId
const viewerId = convexUserId as Id<"users"> | null
return (
<div className="space-y-6">
@ -311,6 +315,7 @@ export function CategoriesManager() {
onDeleteSubcategory={(subcategoryId) =>
setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" })
}
onConfigureSla={() => setSlaCategory(category)}
disabled={isDisabled}
/>
))
@ -373,6 +378,12 @@ export function CategoriesManager() {
</DialogFooter>
</DialogContent>
</Dialog>
<CategorySlaDrawer
category={slaCategory}
tenantId={tenantId}
viewerId={viewerId}
onClose={() => setSlaCategory(null)}
/>
</div>
)
}
@ -385,6 +396,7 @@ interface CategoryItemProps {
onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise<void>
onUpdateSubcategory: (subcategory: TicketSubcategory, name: string) => Promise<void>
onDeleteSubcategory: (subcategoryId: string) => void
onConfigureSla: () => void
}
function CategoryItem({
@ -395,6 +407,7 @@ function CategoryItem({
onCreateSubcategory,
onUpdateSubcategory,
onDeleteSubcategory,
onConfigureSla,
}: CategoryItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [name, setName] = useState(category.name)
@ -448,6 +461,9 @@ function CategoryItem({
</div>
) : (
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={onConfigureSla} disabled={disabled}>
Configurar SLA
</Button>
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}>
Editar
</Button>
@ -552,3 +568,360 @@ function SubcategoryItem({ subcategory, disabled, onUpdate, onDelete }: Subcateg
</div>
)
}
type RuleFormState = {
responseValue: string
responseUnit: "minutes" | "hours" | "days"
responseMode: "business" | "calendar"
solutionValue: string
solutionUnit: "minutes" | "hours" | "days"
solutionMode: "business" | "calendar"
alertThreshold: number
pauseStatuses: string[]
}
const PRIORITY_ROWS = [
{ value: "URGENT", label: "Crítico" },
{ value: "HIGH", label: "Alta" },
{ value: "MEDIUM", label: "Média" },
{ value: "LOW", label: "Baixa" },
{ value: "DEFAULT", label: "Sem prioridade" },
] as const
const TIME_UNITS: Array<{ value: RuleFormState["responseUnit"]; label: string; factor: number }> = [
{ value: "minutes", label: "Minutos", factor: 1 },
{ value: "hours", label: "Horas", factor: 60 },
{ value: "days", label: "Dias", factor: 1440 },
]
const MODE_OPTIONS: Array<{ value: RuleFormState["responseMode"]; label: string }> = [
{ value: "calendar", label: "Horas corridas" },
{ value: "business", label: "Horas úteis" },
]
const PAUSE_STATUS_OPTIONS = [
{ value: "PENDING", label: "Pendente" },
{ value: "AWAITING_ATTENDANCE", label: "Em atendimento" },
{ value: "PAUSED", label: "Pausado" },
] as const
const DEFAULT_RULE_STATE: RuleFormState = {
responseValue: "",
responseUnit: "hours",
responseMode: "calendar",
solutionValue: "",
solutionUnit: "hours",
solutionMode: "calendar",
alertThreshold: 80,
pauseStatuses: ["PAUSED"],
}
type CategorySlaDrawerProps = {
category: TicketCategory | null
tenantId: string
viewerId: Id<"users"> | null
onClose: () => void
}
function CategorySlaDrawer({ category, tenantId, viewerId, onClose }: CategorySlaDrawerProps) {
const [rules, setRules] = useState<Record<string, RuleFormState>>(() => buildDefaultRuleState())
const [saving, setSaving] = useState(false)
const drawerOpen = Boolean(category)
const canLoad = Boolean(category && viewerId)
const existing = useQuery(
api.categorySlas.get,
canLoad
? {
tenantId,
viewerId: viewerId as Id<"users">,
categoryId: category!.id as Id<"ticketCategories">,
}
: "skip"
) as { rules: Array<{ priority: string; responseTargetMinutes: number | null; responseMode?: string | null; solutionTargetMinutes: number | null; solutionMode?: string | null; alertThreshold?: number | null; pauseStatuses?: string[] | null }> } | undefined
const saveSla = useMutation(api.categorySlas.save)
useEffect(() => {
if (!existing?.rules) {
setRules(buildDefaultRuleState())
return
}
const next = buildDefaultRuleState()
for (const rule of existing.rules) {
const priority = rule.priority?.toUpperCase() ?? "DEFAULT"
next[priority] = convertRuleToForm(rule)
}
setRules(next)
}, [existing, category?.id])
const handleChange = (priority: string, patch: Partial<RuleFormState>) => {
setRules((current) => ({
...current,
[priority]: {
...current[priority],
...patch,
},
}))
}
const togglePause = (priority: string, status: string) => {
setRules((current) => {
const selected = new Set(current[priority].pauseStatuses)
if (selected.has(status)) {
selected.delete(status)
} else {
selected.add(status)
}
if (selected.size === 0) {
selected.add("PAUSED")
}
return {
...current,
[priority]: {
...current[priority],
pauseStatuses: Array.from(selected),
},
}
})
}
const handleSave = async () => {
if (!category || !viewerId) return
setSaving(true)
toast.loading("Salvando SLA...", { id: "category-sla" })
try {
const payload = PRIORITY_ROWS.map((row) => {
const form = rules[row.value]
return {
priority: row.value,
responseTargetMinutes: convertToMinutes(form.responseValue, form.responseUnit),
responseMode: form.responseMode,
solutionTargetMinutes: convertToMinutes(form.solutionValue, form.solutionUnit),
solutionMode: form.solutionMode,
alertThreshold: Math.min(Math.max(form.alertThreshold, 5), 95) / 100,
pauseStatuses: form.pauseStatuses,
}
})
await saveSla({
tenantId,
actorId: viewerId,
categoryId: category.id as Id<"ticketCategories">,
rules: payload,
})
toast.success("SLA atualizado", { id: "category-sla" })
onClose()
} catch (error) {
console.error(error)
toast.error("Não foi possível salvar as regras de SLA.", { id: "category-sla" })
} finally {
setSaving(false)
}
}
return (
<Dialog
open={drawerOpen}
onOpenChange={(open) => {
if (!open) {
onClose()
}
}}
>
<DialogContent className="max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Configurar SLA {category?.name ?? ""}</DialogTitle>
<DialogDescription>
Defina metas de resposta e resolução para cada prioridade. Os prazos em horas úteis consideram apenas
segunda a sexta, das 8h às 18h.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{PRIORITY_ROWS.map((row) => {
const form = rules[row.value]
return (
<div key={row.value} className="space-y-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-neutral-900">{row.label}</p>
<p className="text-xs text-neutral-500">
{row.value === "DEFAULT" ? "Aplicado quando o ticket não tem prioridade definida." : "Aplica-se aos tickets desta prioridade."}
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<SlaInputGroup
title="Tempo de resposta"
amount={form.responseValue}
unit={form.responseUnit}
mode={form.responseMode}
onAmountChange={(value) => handleChange(row.value, { responseValue: value })}
onUnitChange={(value) => handleChange(row.value, { responseUnit: value as RuleFormState["responseUnit"] })}
onModeChange={(value) => handleChange(row.value, { responseMode: value as RuleFormState["responseMode"] })}
/>
<SlaInputGroup
title="Tempo de solução"
amount={form.solutionValue}
unit={form.solutionUnit}
mode={form.solutionMode}
onAmountChange={(value) => handleChange(row.value, { solutionValue: value })}
onUnitChange={(value) => handleChange(row.value, { solutionUnit: value as RuleFormState["solutionUnit"] })}
onModeChange={(value) => handleChange(row.value, { solutionMode: value as RuleFormState["solutionMode"] })}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Alertar quando</p>
<div className="mt-2 flex items-center gap-2">
<Input
type="number"
min={10}
max={95}
step={5}
value={form.alertThreshold}
onChange={(event) => handleChange(row.value, { alertThreshold: Number(event.target.value) || 0 })}
/>
<span className="text-xs text-neutral-500">% do tempo for consumido.</span>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Estados que pausam</p>
<div className="mt-2 flex flex-wrap gap-2">
{PAUSE_STATUS_OPTIONS.map((option) => {
const selected = form.pauseStatuses.includes(option.value)
return (
<button
key={option.value}
type="button"
onClick={() => togglePause(row.value, option.value)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-semibold transition",
selected ? "border-primary bg-primary text-primary-foreground" : "border-slate-200 bg-white text-neutral-600"
)}
>
{option.label}
</button>
)
})}
</div>
</div>
</div>
</div>
)
})}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button onClick={handleSave} disabled={saving || !viewerId}>
{saving ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function buildDefaultRuleState() {
return PRIORITY_ROWS.reduce<Record<string, RuleFormState>>((acc, row) => {
acc[row.value] = { ...DEFAULT_RULE_STATE }
return acc
}, {})
}
function convertRuleToForm(rule: {
priority: string
responseTargetMinutes: number | null
responseMode?: string | null
solutionTargetMinutes: number | null
solutionMode?: string | null
alertThreshold?: number | null
pauseStatuses?: string[] | null
}): RuleFormState {
const response = minutesToForm(rule.responseTargetMinutes)
const solution = minutesToForm(rule.solutionTargetMinutes)
return {
responseValue: response.amount,
responseUnit: response.unit,
responseMode: (rule.responseMode ?? "calendar") as RuleFormState["responseMode"],
solutionValue: solution.amount,
solutionUnit: solution.unit,
solutionMode: (rule.solutionMode ?? "calendar") as RuleFormState["solutionMode"],
alertThreshold: Math.round(((rule.alertThreshold ?? 0.8) * 100)),
pauseStatuses: rule.pauseStatuses && rule.pauseStatuses.length > 0 ? rule.pauseStatuses : ["PAUSED"],
}
}
function minutesToForm(input?: number | null) {
if (!input || input <= 0) {
return { amount: "", unit: "hours" as RuleFormState["responseUnit"] }
}
for (const option of [...TIME_UNITS].reverse()) {
if (input % option.factor === 0) {
return { amount: String(Math.round(input / option.factor)), unit: option.value }
}
}
return { amount: String(input), unit: "minutes" as RuleFormState["responseUnit"] }
}
function convertToMinutes(value: string, unit: RuleFormState["responseUnit"]) {
const numeric = Number(value)
if (!Number.isFinite(numeric) || numeric <= 0) {
return undefined
}
const factor = TIME_UNITS.find((item) => item.value === unit)?.factor ?? 1
return Math.round(numeric * factor)
}
type SlaInputGroupProps = {
title: string
amount: string
unit: RuleFormState["responseUnit"]
mode: RuleFormState["responseMode"]
onAmountChange: (value: string) => void
onUnitChange: (value: string) => void
onModeChange: (value: string) => void
}
function SlaInputGroup({ title, amount, unit, mode, onAmountChange, onUnitChange, onModeChange }: SlaInputGroupProps) {
return (
<div className="space-y-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{title}</p>
<div className="flex flex-col gap-2 md:flex-row">
<Input
type="number"
min={0}
step={1}
value={amount}
onChange={(event) => onAmountChange(event.target.value)}
placeholder="0"
/>
<Select value={unit} onValueChange={onUnitChange}>
<SelectTrigger>
<SelectValue placeholder="Unidade" />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select value={mode} onValueChange={onModeChange}>
<SelectTrigger>
<SelectValue placeholder="Tipo de contagem" />
</SelectTrigger>
<SelectContent>
{MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View file

@ -0,0 +1,372 @@
"use client"
import Link from "next/link"
import { Fragment, useMemo, useState } from "react"
import {
addDays,
addWeeks,
addMonths,
endOfMonth,
endOfWeek,
format,
isSameDay,
isSameMonth,
isToday,
startOfMonth,
startOfWeek,
} from "date-fns"
import { ptBR } from "date-fns/locale/pt-BR"
import { ChevronLeft, ChevronRight, CalendarDays, AlertCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { priorityStyles } from "@/lib/ticket-priority-style"
import type { AgendaCalendarEvent } from "@/lib/agenda-utils"
import { cn } from "@/lib/utils"
type AgendaCalendarViewProps = {
events: AgendaCalendarEvent[]
range: { start: Date; end: Date }
}
const weekdayLabels = ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"]
const slaColors: Record<AgendaCalendarEvent["slaStatus"], string> = {
on_track: "border-emerald-200 bg-emerald-50 text-emerald-800",
at_risk: "border-amber-200 bg-amber-50 text-amber-800",
breached: "border-rose-200 bg-rose-50 text-rose-800",
met: "border-primary/20 bg-primary/5 text-primary",
}
export function AgendaCalendarView({ events, range }: AgendaCalendarViewProps) {
const [viewMode, setViewMode] = useState<"month" | "week">("month")
const [currentMonth, setCurrentMonth] = useState<Date>(startOfMonth(range.start))
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(startOfWeek(range.start, { weekStartsOn: 1 }))
const monthMatrix = useMemo(() => buildCalendarMatrix(currentMonth), [currentMonth])
const availableYears = useMemo(() => {
const years = new Set<number>()
years.add(new Date().getFullYear())
years.add(currentMonth.getFullYear())
years.add(currentWeekStart.getFullYear())
events.forEach((event) => {
years.add(event.start.getFullYear())
years.add(event.end.getFullYear())
})
return Array.from(years).sort((a, b) => a - b)
}, [currentMonth, currentWeekStart, events])
const eventsByDay = useMemo(() => {
const map = new Map<string, AgendaCalendarEvent[]>()
for (const event of events) {
const key = format(event.start, "yyyy-MM-dd")
if (!map.has(key)) {
map.set(key, [])
}
map.get(key)!.push(event)
}
for (const value of map.values()) {
value.sort((a, b) => a.start.getTime() - b.start.getTime())
}
return map
}, [events])
const weekDays = useMemo(() => {
const start = currentWeekStart
return Array.from({ length: 7 }, (_, index) => addDays(start, index))
}, [currentWeekStart])
const handlePrev = () => {
if (viewMode === "month") {
setCurrentMonth(addMonths(currentMonth, -1))
} else {
setCurrentWeekStart(addWeeks(currentWeekStart, -1))
}
}
const handleNext = () => {
if (viewMode === "month") {
setCurrentMonth(addMonths(currentMonth, 1))
} else {
setCurrentWeekStart(addWeeks(currentWeekStart, 1))
}
}
const handleToday = () => {
const today = new Date()
setCurrentMonth(startOfMonth(today))
setCurrentWeekStart(startOfWeek(today, { weekStartsOn: 1 }))
}
const handleSelectMonth = (year: number, monthIndex: number) => {
const nextMonth = startOfMonth(new Date(year, monthIndex, 1))
setCurrentMonth(nextMonth)
setCurrentWeekStart(startOfWeek(nextMonth, { weekStartsOn: 1 }))
}
const currentLabel =
viewMode === "month"
? format(currentMonth, "MMMM 'de' yyyy", { locale: ptBR })
: `${format(currentWeekStart, "d MMM", { locale: ptBR })} ${format(
addDays(currentWeekStart, 6),
"d MMM",
{ locale: ptBR },
)}`
return (
<div className="rounded-2xl border border-border/60 bg-card/70 p-4 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-foreground">Calendário operacional</h3>
<p className="text-sm text-muted-foreground">{currentLabel}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(value) => value && setViewMode(value as "month" | "week")}
className="rounded-xl border border-border/70 bg-background/60 p-0.5"
>
<ToggleGroupItem value="month" className="rounded-lg px-4 py-2 text-sm font-medium data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
Mês
</ToggleGroupItem>
<ToggleGroupItem value="week" className="rounded-lg px-4 py-2 text-sm font-medium data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
Semana
</ToggleGroupItem>
</ToggleGroup>
<div className="flex flex-wrap items-center gap-2 rounded-2xl bg-white/80 px-2 py-1.5 shadow-sm">
<Button variant="ghost" size="icon" onClick={handlePrev}>
<ChevronLeft className="size-4" />
</Button>
<Button variant="ghost" size="icon" onClick={handleNext}>
<ChevronRight className="size-4" />
</Button>
<Button variant="secondary" onClick={handleToday}>
Hoje
</Button>
<YearPopover years={availableYears} onSelectMonth={handleSelectMonth} />
</div>
</div>
</div>
{viewMode === "month" ? (
<MonthView
monthMatrix={monthMatrix}
eventsByDay={eventsByDay}
currentMonth={currentMonth}
/>
) : (
<WeekView weekDays={weekDays} eventsByDay={eventsByDay} />
)}
{events.length === 0 ? (
<div className="mt-6 flex items-center gap-3 rounded-lg border border-dashed border-border/60 bg-background/60 p-4 text-sm text-muted-foreground">
<AlertCircle className="size-4" />
Nenhum compromisso previsto para o período filtrado.
</div>
) : null}
</div>
)
}
function MonthView({
monthMatrix,
eventsByDay,
currentMonth,
}: {
monthMatrix: Date[][]
eventsByDay: Map<string, AgendaCalendarEvent[]>
currentMonth: Date
}) {
return (
<div className="mt-6">
<div className="grid grid-cols-7 text-center text-xs font-medium text-muted-foreground">
{weekdayLabels.map((label) => (
<div key={label} className="pb-2">
{label}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1 text-sm">
{monthMatrix.map((week, weekIndex) => (
<Fragment key={`week-${weekIndex}`}>
{week.map((day) => {
const dayKey = format(day, "yyyy-MM-dd")
const dayEvents = eventsByDay.get(dayKey) ?? []
const isCurrent = isSameMonth(day, currentMonth)
return (
<div
key={dayKey}
className={cn(
"min-h-[110px] rounded-xl border bg-background/70 p-2",
isCurrent ? "border-border/80" : "border-border/40 bg-muted/30",
isToday(day) ? "ring-2 ring-primary/60" : ""
)}
>
<div className="flex items-center justify-between text-xs font-semibold text-foreground">
<span className={isCurrent ? "text-foreground" : "text-muted-foreground"}>{day.getDate()}</span>
{dayEvents.length > 0 ? (
<Badge variant="secondary" className="text-[10px]">
{dayEvents.length}
</Badge>
) : null}
</div>
<div className="mt-2 space-y-1">
{dayEvents.slice(0, 3).map((event) => (
<CalendarEventBadge key={event.id} event={event} />
))}
{dayEvents.length > 3 ? (
<p className="text-[10px] text-muted-foreground">+{dayEvents.length - 3} mais</p>
) : null}
</div>
</div>
)
})}
</Fragment>
))}
</div>
</div>
)
}
function WeekView({
weekDays,
eventsByDay,
}: {
weekDays: Date[]
eventsByDay: Map<string, AgendaCalendarEvent[]>
}) {
return (
<div className="mt-6">
<div className="grid grid-cols-7 text-center text-xs font-medium text-muted-foreground">
{weekDays.map((day, index) => (
<div key={`label-${index}`} className="pb-2">
{weekdayLabels[index]} <span className="ml-1 font-semibold text-foreground">{day.getDate()}</span>
</div>
))}
</div>
<div className="grid grid-cols-7 gap-2 text-sm">
{weekDays.map((day) => {
const dayKey = format(day, "yyyy-MM-dd")
const dayEvents = eventsByDay.get(dayKey) ?? []
return (
<div
key={`week-${dayKey}`}
className={cn(
"min-h-[150px] rounded-2xl border border-border/70 bg-background/70 p-3",
isToday(day) ? "ring-2 ring-primary/60" : ""
)}
>
<div className="space-y-2">
{dayEvents.length === 0 ? (
<p className="text-[11px] text-muted-foreground">Sem eventos</p>
) : (
dayEvents.map((event) => (
<CalendarEventBadge key={event.id} event={event} />
))
)}
</div>
</div>
)
})}
</div>
</div>
)
}
function YearPopover({
years,
onSelectMonth,
}: {
years: number[]
onSelectMonth: (year: number, monthIndex: number) => void
}) {
const monthLabels = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"]
const sortedYears = years.length > 0 ? years : [new Date().getFullYear()]
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-2 rounded-full border border-slate-200 px-3 text-sm font-medium text-neutral-700 hover:border-slate-300 hover:bg-white"
>
Selecionar mês/ano
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-0" align="end">
<ScrollArea className="h-64">
<div className="divide-y divide-slate-100">
{sortedYears.map((year) => (
<div key={year} className="px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{year}</p>
<div className="mt-2 grid grid-cols-3 gap-2">
{monthLabels.map((label, index) => (
<Button
key={`${year}-${label}`}
variant="outline"
size="sm"
className="h-8 rounded-full text-xs"
onClick={() => onSelectMonth(year, index)}
>
{label}
</Button>
))}
</div>
</div>
))}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
)
}
function CalendarEventBadge({ event }: { event: AgendaCalendarEvent }) {
const priorityStyle = priorityStyles[event.priority]
const slaColor = slaColors[event.slaStatus]
return (
<Link
href={event.href}
prefetch={false}
className={cn(
"block rounded-lg border px-1.5 py-1 text-[10px] transition hover:ring-2 hover:ring-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
slaColor
)}
aria-label={`Abrir ticket #${event.reference}`}
>
<div className="flex items-center justify-between gap-1">
<span className="truncate font-semibold">#{event.reference}</span>
<span className="text-[9px] text-muted-foreground">{format(event.start, "HH:mm")}</span>
</div>
<p className="truncate text-[10px] text-foreground">{event.title}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-1">
{event.queue ? (
<Badge variant="outline" className="rounded-full px-1.5 text-[9px]">
{event.queue}
</Badge>
) : null}
<Badge className={cn("rounded-full px-1.5 text-[9px]", priorityStyle?.badgeClass ?? "")}>
{priorityStyle?.label ?? event.priority}
</Badge>
</div>
</Link>
)
}
function buildCalendarMatrix(currentMonth: Date): Date[][] {
const start = startOfWeek(startOfMonth(currentMonth), { weekStartsOn: 1 })
const end = endOfWeek(endOfMonth(currentMonth), { weekStartsOn: 1 })
const matrix: Date[][] = []
let cursor = start
while (cursor <= end) {
const week: Date[] = []
for (let i = 0; i < 7; i += 1) {
week.push(cursor)
cursor = addDays(cursor, 1)
}
matrix.push(week)
}
return matrix
}

View file

@ -0,0 +1,201 @@
"use client"
import { useMemo } from "react"
import { SlidersHorizontal } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { priorityStyles } from "@/lib/ticket-priority-style"
import { cn } from "@/lib/utils"
import type { TicketPriority } from "@/lib/schemas/ticket"
export type AgendaPeriod = "today" | "week" | "month"
export type AgendaFilterState = {
period: AgendaPeriod
queues: string[]
priorities: TicketPriority[]
onlyMyTickets: boolean
focusVisits: boolean
}
export const defaultAgendaFilters: AgendaFilterState = {
period: "week",
queues: [],
priorities: [],
onlyMyTickets: true,
focusVisits: false,
}
type AgendaFiltersProps = {
filters: AgendaFilterState
queues: string[]
onChange: (next: AgendaFilterState) => void
}
export function AgendaFilters({ filters, queues, onChange }: AgendaFiltersProps) {
const periodOptions: Array<{ value: AgendaPeriod; label: string }> = [
{ value: "today", label: "Hoje" },
{ value: "week", label: "Semana" },
{ value: "month", label: "Mês" },
]
const queueLabel = useMemo(() => {
if (filters.queues.length === 0) return "Todas as filas"
if (filters.queues.length === 1) return filters.queues[0]
return `${filters.queues[0]} +${filters.queues.length - 1}`
}, [filters.queues])
const priorityLabel = useMemo(() => {
if (filters.priorities.length === 0) return "Todas prioridades"
if (filters.priorities.length === 1) {
return priorityStyles[filters.priorities[0]].label
}
return `${priorityStyles[filters.priorities[0]].label} +${filters.priorities.length - 1}`
}, [filters.priorities])
const updateFilters = (partial: Partial<AgendaFilterState>) => {
onChange({ ...filters, ...partial })
}
const handleQueueToggle = (queue: string, checked: boolean) => {
const set = new Set(filters.queues)
if (checked) {
set.add(queue)
} else {
set.delete(queue)
}
updateFilters({ queues: Array.from(set) })
}
const handlePriorityToggle = (priority: TicketPriority, checked: boolean) => {
const set = new Set(filters.priorities)
if (checked) {
set.add(priority)
} else {
set.delete(priority)
}
updateFilters({ priorities: Array.from(set) })
}
const resetFilters = () => {
onChange(defaultAgendaFilters)
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-card/70 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap items-center gap-2 rounded-2xl border border-slate-200 bg-white/80 px-2 py-1.5 shadow-sm">
{periodOptions.map((option) => {
const isActive = filters.period === option.value
return (
<Button
key={option.value}
type="button"
size="sm"
variant={isActive ? "secondary" : "ghost"}
onClick={() => updateFilters({ period: option.value })}
className={cn(
"gap-2 rounded-full border px-3 text-sm font-medium transition",
isActive
? "border-slate-200 bg-slate-900 text-white"
: "border-slate-200 text-neutral-700 hover:border-slate-300 hover:bg-white"
)}
>
{option.label}
</Button>
)
})}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
{queueLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>Filtrar filas</DropdownMenuLabel>
<DropdownMenuSeparator />
{queues.length === 0 ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">Nenhuma fila disponível.</div>
) : (
queues.map((queue) => (
<DropdownMenuCheckboxItem
key={queue}
checked={filters.queues.includes(queue)}
onCheckedChange={(checked) => handleQueueToggle(queue, Boolean(checked))}
>
{queue}
</DropdownMenuCheckboxItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
{priorityLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="start">
<DropdownMenuLabel>Prioridades</DropdownMenuLabel>
<DropdownMenuSeparator />
{(Object.keys(priorityStyles) as TicketPriority[]).map((priority) => (
<DropdownMenuCheckboxItem
key={priority}
checked={filters.priorities.includes(priority)}
onCheckedChange={(checked) => handlePriorityToggle(priority, Boolean(checked))}
className="gap-2"
>
<div className="flex items-center gap-2">
<Badge className={cn("rounded-full px-2.5 py-0.5 text-xs font-semibold", priorityStyles[priority].badgeClass ?? "")}>
{priorityStyles[priority].label}
</Badge>
<span className="text-sm text-foreground">{priorityStyles[priority].label}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Switch
id="onlyMe"
checked={filters.onlyMyTickets}
onCheckedChange={(checked) => updateFilters({ onlyMyTickets: checked })}
className="data-[state=checked]:border-sidebar-ring data-[state=checked]:bg-sidebar-accent"
/>
<Label htmlFor="onlyMe" className="text-sm text-muted-foreground">
Somente meus tickets
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="visitsOnly"
checked={filters.focusVisits}
onCheckedChange={(checked) => updateFilters({ focusVisits: checked })}
className="data-[state=checked]:border-sidebar-ring data-[state=checked]:bg-sidebar-accent"
/>
<Label htmlFor="visitsOnly" className="text-sm text-muted-foreground">
Apenas visitas
</Label>
</div>
<Button variant="ghost" size="sm" className="gap-2" onClick={resetFilters}>
<SlidersHorizontal className="size-4" />
Resetar
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,191 @@
import Link from "next/link"
import { Fragment } from "react"
import { AlertTriangle, CalendarClock, Clock, CheckCircle2, Circle } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { priorityStyles } from "@/lib/ticket-priority-style"
import { cn } from "@/lib/utils"
import type { AgendaDataset, AgendaTicketSummary } from "@/lib/agenda-utils"
type AgendaSummaryViewProps = {
data: AgendaDataset
isLoading?: boolean
}
const statusIndicator: Record<string, { label: string; icon: React.ComponentType<{ className?: string }>; className: string }> = {
on_track: { label: "No prazo", icon: Circle, className: "text-emerald-500" },
at_risk: { label: "Em risco", icon: AlertTriangle, className: "text-amber-500" },
breached: { label: "Violado", icon: AlertTriangle, className: "text-rose-500" },
met: { label: "Concluído", icon: CheckCircle2, className: "text-emerald-500" },
}
export function AgendaSummaryView({ data, isLoading }: AgendaSummaryViewProps) {
if (isLoading) {
return (
<Card>
<CardContent className="space-y-4 py-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-24 w-full rounded-xl" />
</CardContent>
</Card>
)
}
const sections: Array<{
key: keyof AgendaDataset["sections"]
title: string
empty: string
icon: React.ComponentType<{ className?: string }>
iconClass?: string
}> = [
{ key: "upcoming", title: "Próximas", empty: "Nenhum compromisso para o período selecionado.", icon: CalendarClock, iconClass: "text-foreground" },
{ key: "overdue", title: "Atrasadas", empty: "Sem pendências atrasadas — ótimo trabalho!", icon: AlertTriangle, iconClass: "text-amber-500" },
{ key: "unscheduled", title: "Sem agendamento", empty: "Todos os tickets críticos têm agenda.", icon: Clock, iconClass: "text-foreground" },
{ key: "completed", title: "Concluídas", empty: "Ainda não há visitas concluídas neste período.", icon: CheckCircle2, iconClass: "text-foreground" },
]
return (
<div className="space-y-6">
<KpiGrid data={data} />
<div className="grid gap-6 lg:grid-cols-2">
{sections.map((section) => {
const items = data.sections[section.key]
const SectionIcon = section.icon
const visibleItems = items.slice(0, 3)
const viewAllHref = buildSectionLink(section.key)
return (
<Card key={section.key} className="min-h-[320px]">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2">
<SectionIcon className={cn("size-4", section.iconClass ?? "text-foreground")} />
{section.title}
{items.length > 0 ? (
<Badge variant="secondary" className="ml-2">
{items.length}
</Badge>
) : null}
</CardTitle>
<Link
href={viewAllHref}
className="text-sm font-semibold text-[#006879] underline-offset-4 transition-colors hover:text-[#004d5a] hover:underline"
>
Ver todos
</Link>
</CardHeader>
<CardContent className="space-y-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">{section.empty}</p>
) : (
<div className="space-y-3">
{visibleItems.map((item) => (
<AgendaSummaryRow key={`${section.key}-${item.id}`} item={item} />
))}
</div>
)}
</CardContent>
</Card>
)
})}
</div>
</div>
)
}
function KpiGrid({ data }: { data: AgendaDataset }) {
const kpis = [
{ label: "Pendentes", value: data.kpis.pending, description: "Aguardando início" },
{ label: "Em andamento", value: data.kpis.inProgress, description: "Atividades em curso" },
{ label: "Pausados", value: data.kpis.paused, description: "Dependem de cliente/terceiros" },
{ label: "% fora do SLA", value: data.kpis.outsideSla, description: "Chamados com risco/violação" },
]
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{kpis.map((item) => (
<Card
key={item.label}
className="rounded-2xl border border-border/60 bg-gradient-to-br from-white/95 via-white to-primary/5 shadow-sm"
>
<CardContent className="space-y-3 px-5 py-5">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">{item.label}</p>
<p className="text-4xl font-semibold leading-tight text-neutral-900">{item.value}</p>
<p className="text-sm text-muted-foreground">{item.description}</p>
</CardContent>
</Card>
))}
</div>
)
}
function AgendaSummaryRow({ item }: { item: AgendaTicketSummary }) {
const status = statusIndicator[item.slaStatus ?? "on_track"] ?? statusIndicator.on_track
return (
<Link
href={item.href}
prefetch={false}
className="block rounded-xl border border-border/60 bg-background/70 p-3 shadow-sm transition hover:border-primary/60 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
aria-label={`Abrir ticket #${item.reference}`}
>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<span>
#{item.reference} · {item.subject}
</span>
{item.queue ? (
<Badge variant="outline" className="rounded-full border-dashed px-2.5 py-0.5 text-[11px] font-medium">
{item.queue}
</Badge>
) : null}
<Badge className={cn("rounded-full px-2.5 py-0.5 text-[11px] font-semibold", priorityStyles[item.priority]?.badgeClass ?? "")}>
{priorityStyles[item.priority]?.label ?? item.priority}
</Badge>
</div>
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
{item.company ? <span>{item.company}</span> : null}
{item.location ? <span className="flex items-center gap-1"><Clock className="size-3" /> {item.location}</span> : null}
{item.startAt ? (
<span className="flex items-center gap-1">
<CalendarClock className="size-3" />
{formatDateRange(item.startAt, item.endAt)}
</span>
) : (
<span className="flex items-center gap-1 text-foreground">
<AlertTriangle className="size-3" />
Não agendado
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide">
<status.icon className={cn("size-4", status.className)} />
<span className={status.className}>{status.label}</span>
</div>
</div>
</Link>
)
}
function formatDateRange(start?: Date | null, end?: Date | null) {
if (!start) return "Sem data"
if (!end) return new Intl.DateTimeFormat("pt-BR", { dateStyle: "medium", timeStyle: "short" }).format(start)
const sameDay = start.toDateString() === end.toDateString()
if (sameDay) {
return `${new Intl.DateTimeFormat("pt-BR", { dateStyle: "short" }).format(start)} · ${new Intl.DateTimeFormat("pt-BR", { timeStyle: "short" }).format(start)} - ${new Intl.DateTimeFormat("pt-BR", { timeStyle: "short" }).format(end)}`
}
return `${new Intl.DateTimeFormat("pt-BR", { dateStyle: "short", timeStyle: "short" }).format(start)} → ${new Intl.DateTimeFormat("pt-BR", { dateStyle: "short", timeStyle: "short" }).format(end)}`
}
function buildSectionLink(section: keyof AgendaDataset["sections"]) {
const params = new URLSearchParams()
params.set("focus", "visits")
params.set("view", section === "completed" ? "completed" : "active")
if (section === "completed") {
params.set("status", "RESOLVED")
}
return `/tickets?${params.toString()}`
}

View file

@ -22,6 +22,7 @@ import {
ShieldCheck,
Users,
Layers3,
CalendarDays,
} from "lucide-react"
import { usePathname } from "next/navigation"
import Link from "next/link"
@ -80,6 +81,7 @@ const navigation: NavigationGroup[] = [
},
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" },
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
{ title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" },
],
},
{

View file

@ -21,6 +21,7 @@ import { RichTextEditor } from "@/components/ui/rich-text-editor"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { normalizeCustomFieldInputs, hasMissingRequiredCustomFields } from "@/lib/ticket-form-helpers"
import { cn } from "@/lib/utils"
import { useLocalTimeZone } from "@/hooks/use-local-time-zone"
import type { TicketFormDefinition } from "@/lib/ticket-form-types"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
@ -69,6 +70,7 @@ export function PortalTicketForm() {
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
const calendarTimeZone = useLocalTimeZone()
const hasEnsuredFormsRef = useRef(false)
useEffect(() => {
@ -467,6 +469,7 @@ export function PortalTicketForm() {
startMonth={new Date(1900, 0)}
endMonth={new Date(new Date().getFullYear() + 5, 11)}
locale={ptBR}
timeZone={calendarTimeZone}
/>
</PopoverContent>
</Popover>

View file

@ -12,6 +12,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Separator } from "@/components/ui/separator"
import { useAuth, signOut } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { FieldsManager } from "@/components/admin/fields/fields-manager"
import type { LucideIcon } from "lucide-react"
@ -270,6 +271,17 @@ export function SettingsContent() {
})}
</div>
</section>
{isStaff ? (
<section id="custom-fields" className="space-y-4">
<div>
<h2 className="text-base font-semibold text-neutral-900">Campos personalizados</h2>
<p className="text-sm text-neutral-600">
Ajuste os campos de admissão, desligamento e demais metadados diretamente pelo painel administrativo.
</p>
</div>
<FieldsManager />
</section>
) : null}
</div>
)
}

View file

@ -29,6 +29,7 @@ import { SearchableCombobox, type SearchableComboboxOption } from "@/components/
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { useDefaultQueues } from "@/hooks/use-default-queues"
import { useLocalTimeZone } from "@/hooks/use-local-time-zone"
import { cn } from "@/lib/utils"
import { priorityStyles } from "@/lib/ticket-priority-style"
import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
@ -115,6 +116,7 @@ const schema = z.object({
export function NewTicketDialog({ triggerClassName }: { triggerClassName?: string } = {}) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const calendarTimeZone = useLocalTimeZone()
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
@ -1039,6 +1041,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
startMonth={new Date(1900, 0)}
endMonth={new Date(new Date().getFullYear() + 5, 11)}
locale={ptBR}
timeZone={calendarTimeZone}
/>
</PopoverContent>
</Popover>

View file

@ -107,11 +107,14 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
const effectiveScore = hasSubmitted ? score : hoverScore ?? score
const viewerIsAdmin = viewerRole === "ADMIN"
const viewerIsStaff =
viewerRole === "MANAGER" || viewerRole === "AGENT" || viewerIsAdmin
const collaboratorCanView = !viewerIsStaff && isRequester
const adminCanInspect = viewerIsAdmin && ticket.status !== "PENDING"
const canSubmit =
Boolean(viewerId && viewerRole === "COLLABORATOR" && isRequester && isResolved && !hasSubmitted)
const hasRating = hasSubmitted
const showCard = adminCanInspect || isRequester
const showCard = adminCanInspect || collaboratorCanView
const ratedAtRelative = useMemo(() => formatRelative(ratedAt), [ratedAt])
@ -181,7 +184,7 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
Conte como foi sua experiência com este chamado.
</CardDescription>
</div>
{hasRating && !viewerIsAdmin ? (
{hasRating && collaboratorCanView ? (
<div className="flex items-center gap-1 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
Obrigado pelo feedback!
</div>

View file

@ -27,6 +27,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Field, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
import { useLocalTimeZone } from "@/hooks/use-local-time-zone"
type TicketCustomFieldsListProps = {
record?: TicketCustomFieldRecord | null
@ -216,6 +217,7 @@ type TicketCustomFieldsSectionProps = {
export function TicketCustomFieldsSection({ ticket, variant = "card", className }: TicketCustomFieldsSectionProps) {
const { convexUserId, role } = useAuth()
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
const calendarTimeZone = useLocalTimeZone()
const viewerId = convexUserId as Id<"users"> | null
const tenantId = ticket.tenantId
@ -368,10 +370,9 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className
</DialogDescription>
</DialogHeader>
{hasConfiguredFields ? (
<div className="rounded-2xl border border-slate-200 bg-white/70 px-4 py-4">
<div className="grid gap-4 sm:grid-cols-2">
{selectedForm.fields.map((field) => renderFieldEditor(field))}
</div>
<div className="grid gap-4 rounded-2xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2">
<p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
{selectedForm.fields.map((field) => renderFieldEditor(field))}
</div>
) : (
<p className="text-sm text-neutral-500">Nenhum campo configurado ainda.</p>
@ -473,7 +474,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
if (field.type === "select") {
return (
<Field key={field.id} className={spanClass}>
<Field key={field.id} className={cn("gap-1.5", spanClass)}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
@ -501,7 +502,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
if (field.type === "number") {
return (
<Field key={field.id} className={spanClass}>
<Field key={field.id} className={cn("gap-1.5", spanClass)}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
@ -521,7 +522,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
const isoValue = toIsoDateString(value)
const parsedDate = isoValue ? parseIsoDate(isoValue) : null
return (
<Field key={field.id} className={cn("flex flex-col gap-2", spanClass)}>
<Field key={field.id} className={cn("flex flex-col gap-1.5", spanClass)}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
@ -554,6 +555,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "")
setOpenCalendarField(null)
}}
timeZone={calendarTimeZone}
/>
</PopoverContent>
</Popover>
@ -564,7 +566,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
if (field.type === "text" && !isTextarea) {
return (
<Field key={field.id} className={spanClass}>
<Field key={field.id} className={cn("gap-1.5", spanClass)}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
@ -580,7 +582,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
}
return (
<Field key={field.id} className={spanClass}>
<Field key={field.id} className={cn("gap-1.5", spanClass)}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>

View file

@ -7,6 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { getSlaDisplayStatus, getSlaDueDate, type SlaDisplayStatus } from "@/lib/sla-utils"
interface TicketDetailsPanelProps {
ticket: TicketWithDetails
@ -28,6 +29,13 @@ const priorityTone: Record<TicketWithDetails["priority"], SummaryTone> = {
URGENT: "danger",
}
const slaStatusTone: Record<Exclude<SlaDisplayStatus, "n/a">, { label: string; className: string }> = {
on_track: { label: "No prazo", className: "text-emerald-600" },
at_risk: { label: "Em risco", className: "text-amber-600" },
breached: { label: "Violado", className: "text-rose-600" },
met: { label: "Concluído", className: "text-emerald-600" },
}
function formatDuration(ms?: number | null) {
if (!ms || ms <= 0) return "0s"
const totalSeconds = Math.floor(ms / 1000)
@ -48,6 +56,22 @@ function formatMinutes(value?: number | null) {
return `${value} min`
}
function formatSlaTarget(value?: number | null, mode?: string) {
if (!value) return "—"
if (value < 60) return `${value} min${mode === "business" ? " úteis" : ""}`
const hours = Math.floor(value / 60)
const minutes = value % 60
if (minutes === 0) {
return `${hours}h${mode === "business" ? " úteis" : ""}`
}
return `${hours}h ${minutes}m${mode === "business" ? " úteis" : ""}`
}
function getSlaStatusDisplay(status: SlaDisplayStatus) {
const normalized = status === "n/a" ? "on_track" : status
return slaStatusTone[normalized as Exclude<SlaDisplayStatus, "n/a">]
}
type SummaryChipConfig = {
key: string
label: string
@ -59,6 +83,10 @@ type SummaryChipConfig = {
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
const isAvulso = Boolean(ticket.company?.isAvulso)
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
const responseStatus = getSlaDisplayStatus(ticket, "response")
const solutionStatus = getSlaDisplayStatus(ticket, "solution")
const responseDue = getSlaDueDate(ticket, "response")
const solutionDue = getSlaDueDate(ticket, "solution")
const summaryChips = useMemo(() => {
const chips: SummaryChipConfig[] = [
@ -148,26 +176,37 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-neutral-900">SLA & métricas</h3>
{ticket.slaPolicy ? (
<span className="text-xs font-medium text-neutral-500">{ticket.slaPolicy.name}</span>
{ticket.slaSnapshot ? (
<span className="text-xs font-medium text-neutral-500">
{ticket.slaSnapshot.categoryName ?? "Configuração personalizada"}
</span>
) : null}
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Política de SLA</p>
{ticket.slaPolicy ? (
<div className="mt-3 space-y-2 text-sm text-neutral-700">
<div className="flex items-center justify-between gap-4">
<span className="text-xs text-neutral-500">Resposta inicial</span>
<span className="text-sm font-semibold text-neutral-900">
{formatMinutes(ticket.slaPolicy.targetMinutesToFirstResponse)}
</span>
{ticket.slaSnapshot ? (
<div className="mt-3 space-y-4 text-sm text-neutral-700">
<div>
<span className="text-xs text-neutral-500">Categoria</span>
<p className="font-semibold text-neutral-900">{ticket.slaSnapshot.categoryName ?? "Categoria padrão"}</p>
<p className="text-xs text-neutral-500">
Prioridade: {priorityLabel[ticket.priority] ?? ticket.priority}
</p>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-xs text-neutral-500">Resolução</span>
<span className="text-sm font-semibold text-neutral-900">
{formatMinutes(ticket.slaPolicy.targetMinutesToResolution)}
</span>
<div className="grid gap-3 sm:grid-cols-2">
<SlaMetric
label="Resposta"
target={formatSlaTarget(ticket.slaSnapshot.responseTargetMinutes, ticket.slaSnapshot.responseMode)}
dueDate={responseDue}
status={responseStatus}
/>
<SlaMetric
label="Resolução"
target={formatSlaTarget(ticket.slaSnapshot.solutionTargetMinutes, ticket.slaSnapshot.solutionMode)}
dueDate={solutionDue}
status={solutionStatus}
/>
</div>
</div>
) : (
@ -289,3 +328,30 @@ function SummaryChip({
</div>
)
}
interface SlaMetricProps {
label: string
target: string
dueDate: Date | null
status: SlaDisplayStatus
}
function SlaMetric({ label, target, dueDate, status }: SlaMetricProps) {
const display = getSlaStatusDisplay(status)
return (
<div className="rounded-xl border border-slate-200 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs text-neutral-500">{label}</p>
<p className="text-sm font-semibold text-neutral-900">{target}</p>
{dueDate ? (
<p className="text-xs text-neutral-500">{format(dueDate, "dd/MM/yyyy HH:mm", { locale: ptBR })}</p>
) : (
<p className="text-xs text-neutral-500">Sem prazo calculado</p>
)}
</div>
<span className={cn("text-xs font-semibold uppercase", display.className)}>{display.label}</span>
</div>
</div>
)
}

View file

@ -1563,10 +1563,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={sectionValueClass}>{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
) : null}
{ticket.slaPolicy ? (
{ticket.slaSnapshot ? (
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Política</span>
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
<span className={sectionValueClass}>{ticket.slaSnapshot.categoryName ?? "Configuração personalizada"}</span>
</div>
) : null}
<TicketCustomFieldsSection ticket={ticket} variant="inline" className="sm:col-span-2 lg:col-span-3" />

View file

@ -8,6 +8,11 @@ import {
ticketPrioritySchema,
type TicketStatus,
} from "@/lib/schemas/ticket"
import type { TicketFiltersState } from "@/lib/ticket-filters"
import { defaultTicketFilters } from "@/lib/ticket-filters"
export type { TicketFiltersState }
export { defaultTicketFilters }
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@ -60,28 +65,6 @@ const channelOptions = ticketChannelSchema.options.map((channel) => ({
type QueueOption = string
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"
}
export const defaultTicketFilters: TicketFiltersState = {
search: "",
status: null,
priority: null,
queue: null,
channel: null,
company: null,
assigneeId: null,
view: "active",
}
interface TicketsFiltersProps {
onChange?: (filters: TicketFiltersState) => void
queues?: QueueOption[]
@ -127,6 +110,7 @@ export function TicketsFilters({ onChange, queues = [], companies = [], assignee
chips.push(`Responsável: ${found?.name ?? filters.assigneeId}`)
}
if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos")
if (filters.focusVisits) chips.push("Somente visitas/lab")
return chips
}, [filters, assignees])

View file

@ -15,6 +15,7 @@ import { useAuth } from "@/lib/auth-client"
import { useDefaultQueues } from "@/hooks/use-default-queues"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { LayoutGrid, List } from "lucide-react"
import { isVisitTicket } from "@/lib/ticket-matchers"
type TicketsViewProps = {
initialFilters?: Partial<TicketFiltersState>
@ -163,9 +164,12 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
if (filters.company) {
working = working.filter((t) => (((t as unknown as { company?: { name?: string } })?.company?.name) ?? null) === filters.company)
}
if (filters.focusVisits) {
working = working.filter((t) => isVisitTicket(t))
}
return working
}, [tickets, filters.queue, filters.status, filters.view, filters.company])
}, [tickets, filters.queue, filters.status, filters.view, filters.company, filters.focusVisits])
const previousIdsRef = useRef<string[]>([])
const [enteringIds, setEnteringIds] = useState<Set<string>>(new Set())

View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-sidebar-accent data-[state=checked]:text-sidebar-accent-foreground",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block h-5 w-5 translate-x-0 rounded-full bg-background shadow transition-transform duration-200 data-[state=checked]:translate-x-[20px]",
)}
/>
</SwitchPrimitive.Root>
))
Switch.displayName = SwitchPrimitive.Root.displayName
export { Switch }

View file

@ -0,0 +1,19 @@
import { useEffect, useState } from "react"
export function useLocalTimeZone(fallback?: string) {
const [timeZone, setTimeZone] = useState<string | undefined>(fallback)
useEffect(() => {
if (typeof window === "undefined") return
try {
const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone
if (resolved) {
setTimeZone(resolved)
}
} catch {
/* ignore */
}
}, [])
return timeZone
}

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()
}