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

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