feat: agenda polish, SLA sync, filters
This commit is contained in:
parent
7fb6c65d9a
commit
6ab8a6ce89
40 changed files with 2771 additions and 154 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue