feat: improve reports filters and ticket flows

This commit is contained in:
Esdras Renan 2025-11-14 19:41:47 -03:00
parent 9c74e10675
commit 15d11b6b12
29 changed files with 437 additions and 140 deletions

View file

@ -49,7 +49,10 @@ export function AgendaCalendarView({ events, range }: AgendaCalendarViewProps) {
const monthMatrix = useMemo(() => buildCalendarMatrix(currentMonth), [currentMonth])
const availableYears = useMemo(() => {
const years = new Set<number>()
years.add(new Date().getFullYear())
const baseYear = new Date().getFullYear()
for (let offset = 0; offset <= 5; offset += 1) {
years.add(baseYear + offset)
}
years.add(currentMonth.getFullYear())
years.add(currentWeekStart.getFullYear())
events.forEach((event) => {

View file

@ -49,20 +49,17 @@ export function PortalTicketCard({ ticket }: PortalTicketCardProps) {
<span>{format(ticket.createdAt, "dd/MM/yyyy")}</span>
</div>
<h3 className="mt-1 text-lg font-semibold text-neutral-900">{ticket.subject}</h3>
{ticket.summary ? (
<p className="mt-1 line-clamp-2 text-sm text-neutral-600">{ticket.summary}</p>
</div>
<div className="flex flex-col items-end gap-2 text-right">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", getTicketStatusBadgeClass(ticket.status))}>
{getTicketStatusLabel(ticket.status)}
</Badge>
{!isCustomer ? (
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityTone[ticket.priority])}>
{priorityLabel[ticket.priority]}
</Badge>
) : null}
</div>
<div className="flex flex-col items-end gap-2 text-right">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", getTicketStatusBadgeClass(ticket.status))}>
{getTicketStatusLabel(ticket.status)}
</Badge>
{!isCustomer ? (
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityTone[ticket.priority])}>
{priorityLabel[ticket.priority]}
</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="flex flex-wrap items-center justify-between gap-4 border-t border-slate-100 px-5 py-4 text-sm text-neutral-600">
{!isCustomer ? (

View file

@ -447,9 +447,6 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
<div>
<p className="text-sm text-neutral-500">Ticket #{ticket.reference}</p>
<h1 className="mt-1 text-2xl font-semibold text-neutral-900">{ticket.subject}</h1>
{ticket.summary ? (
<p className="mt-2 max-w-3xl text-sm text-neutral-600">{ticket.summary}</p>
) : null}
</div>
<div className="flex flex-col items-end gap-2 text-sm">
<div className="flex items-center gap-2">

View file

@ -119,7 +119,6 @@ export function PortalTicketForm() {
}
const [subject, setSubject] = useState("")
const [summary, setSummary] = useState("")
const [description, setDescription] = useState("")
const [categoryId, setCategoryId] = useState<string | null>(null)
const [subcategoryId, setSubcategoryId] = useState<string | null>(null)
@ -164,7 +163,6 @@ export function PortalTicketForm() {
}
const trimmedSubject = subject.trim()
const trimmedSummary = summary.trim()
const sanitizedDescription = sanitizeEditorHtml(description || "")
const plainDescription = sanitizedDescription.replace(/<[^>]*>/g, "").trim()
if (plainDescription.length === 0) {
@ -189,7 +187,6 @@ export function PortalTicketForm() {
actorId: viewerId,
tenantId,
subject: trimmedSubject,
summary: trimmedSummary || undefined,
priority: DEFAULT_PRIORITY,
channel: "MANUAL",
queueId: undefined,
@ -208,7 +205,7 @@ export function PortalTicketForm() {
setIsSubmitting(false)
return
}
const htmlBody = sanitizedDescription || toHtml(trimmedSummary || trimmedSubject)
const htmlBody = sanitizedDescription || toHtml(trimmedSubject)
const typedAttachments = attachments.map((file) => ({
storageId: file.storageId as Id<"_storage">,
@ -298,19 +295,6 @@ export function PortalTicketForm() {
required
/>
</div>
<div className="space-y-1">
<label htmlFor="summary" className="text-sm font-medium text-neutral-800">
Resumo (opcional)
</label>
<Input
id="summary"
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder="Descreva rapidamente o que está acontecendo"
maxLength={600}
disabled={machineInactive || isSubmitting}
/>
</div>
<div className="space-y-1">
<label htmlFor="description" className="flex items-center gap-1 text-sm font-medium text-neutral-800">
Detalhes <span className="text-red-500">*</span>

View file

@ -98,6 +98,15 @@ export function BacklogReport() {
return entries.reduce((prev, current) => (current[1] > prev[1] ? current : prev))
}, [data])
const exportHref = useMemo(() => {
const params = new URLSearchParams()
params.set("range", timeRange)
if (companyId !== "all") params.set("companyId", companyId)
if (dateFrom) params.set("dateFrom", dateFrom)
if (dateTo) params.set("dateTo", dateTo)
return `/api/reports/backlog.xlsx?${params.toString()}`
}, [companyId, dateFrom, dateTo, timeRange])
if (!data) {
return (
<div className="space-y-6">
@ -136,9 +145,7 @@ export function BacklogReport() {
setDateFrom(from)
setDateTo(to)
}}
exportHref={`/api/reports/backlog.xlsx?range=${timeRange}${
companyId !== "all" ? `&companyId=${companyId}` : ""
}`}
exportHref={exportHref}
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
/>
<div className="grid gap-4 md:grid-cols-3">

View file

@ -118,6 +118,15 @@ export function CategoryReport() {
const chartHeight = Math.max(240, chartData.length * 48)
const exportHref = useMemo(() => {
const params = new URLSearchParams()
params.set("range", timeRange)
if (companyId !== "all") params.set("companyId", companyId)
if (dateFrom) params.set("dateFrom", dateFrom)
if (dateTo) params.set("dateTo", dateTo)
return `/api/reports/category-insights.xlsx?${params.toString()}`
}, [companyId, dateFrom, dateTo, timeRange])
const summarySkeleton = (
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
@ -203,6 +212,7 @@ export function CategoryReport() {
setDateFrom(from)
setDateTo(to)
}}
exportHref={exportHref}
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
/>

View file

@ -54,6 +54,15 @@ export function CsatReport() {
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const exportHref = useMemo(() => {
const params = new URLSearchParams()
params.set("range", timeRange)
if (companyId !== "all") params.set("companyId", companyId)
if (dateFrom) params.set("dateFrom", dateFrom)
if (dateTo) params.set("dateTo", dateTo)
return `/api/reports/csat.xlsx?${params.toString()}`
}, [companyId, dateFrom, dateTo, timeRange])
if (!data) {
return (
<div className="space-y-6">
@ -119,9 +128,7 @@ export function CsatReport() {
setDateFrom(from)
setDateTo(to)
}}
exportHref={`/api/reports/csat.xlsx?range=${timeRange}${
companyId !== "all" ? `&companyId=${companyId}` : ""
}`}
exportHref={exportHref}
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
/>

View file

@ -205,6 +205,16 @@ export function HoursReport() {
[filteredCompaniesWithComputed]
)
const exportHref = useMemo(() => {
const params = new URLSearchParams()
const effectiveRange = timeRange === "365d" || timeRange === "all" ? "90d" : timeRange
params.set("range", effectiveRange)
if (companyId !== "all") params.set("companyId", companyId)
if (dateFrom) params.set("dateFrom", dateFrom)
if (dateTo) params.set("dateTo", dateTo)
return `/api/reports/hours-by-client.xlsx?${params.toString()}`
}, [companyId, dateFrom, dateTo, timeRange])
return (
<div className="space-y-6">
{isAdmin ? (
@ -230,9 +240,7 @@ export function HoursReport() {
showBillingFilter
billingFilter={billingFilter}
onBillingFilterChange={(value) => setBillingFilter(value)}
exportHref={`/api/reports/hours-by-client.xlsx?range=${
timeRange === "365d" || timeRange === "all" ? "90d" : timeRange
}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
exportHref={exportHref}
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
dateFrom={dateFrom}
dateTo={dateTo}

View file

@ -158,6 +158,17 @@ export function MachineCategoryReport() {
const [selectedMachineId, setSelectedMachineId] = useState<string>("all")
const [selectedUserId, setSelectedUserId] = useState<string>("all")
const exportHref = useMemo(() => {
const params = new URLSearchParams()
params.set("range", timeRange)
if (companyId !== "all") params.set("companyId", companyId)
if (selectedMachineId !== "all") params.set("machineId", selectedMachineId)
if (selectedUserId !== "all") params.set("userId", selectedUserId)
if (dateFrom) params.set("dateFrom", dateFrom)
if (dateTo) params.set("dateTo", dateTo)
return `/api/reports/machine-category.xlsx?${params.toString()}`
}, [companyId, dateFrom, dateTo, selectedMachineId, selectedUserId, timeRange])
const hours = useQuery(
api.reports.hoursByMachine,
enabled && selectedMachineId !== "all"
@ -259,6 +270,7 @@ export function MachineCategoryReport() {
setDateTo(to)
}}
allowExtendedRanges
exportHref={exportHref}
/>
<Card className="border-slate-200">

View file

@ -126,6 +126,15 @@ export function SlaReport() {
]
}, [companies])
const exportHref = useMemo(() => {
const params = new URLSearchParams()
params.set("range", timeRange)
if (companyId !== "all") params.set("companyId", companyId)
if (dateFrom) params.set("dateFrom", dateFrom)
if (dateTo) params.set("dateTo", dateTo)
return `/api/reports/sla.xlsx?${params.toString()}`
}, [companyId, dateFrom, dateTo, timeRange])
const queueTotal = useMemo(
() => data?.queueBreakdown.reduce((acc: number, queue: { open: number }) => acc + queue.open, 0) ?? 0,
[data]
@ -170,9 +179,7 @@ export function SlaReport() {
setDateFrom(from)
setDateTo(to)
}}
exportHref={`/api/reports/sla.xlsx?range=${timeRange}${
companyId !== "all" ? `&companyId=${companyId}` : ""
}`}
exportHref={exportHref}
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
/>
<div className="grid gap-4 md:grid-cols-4">

View file

@ -593,6 +593,14 @@ export function CloseTicketDialog({
}
}
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
const sanitizedMessage = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
const hasContent = sanitizedMessage.replace(/<[^>]*>/g, "").trim().length > 0
if (!hasContent) {
toast.error("Inclua uma mensagem de encerramento antes de finalizar o ticket.", { id: "close-ticket" })
return
}
toast.dismiss("close-ticket")
setIsSubmitting(true)
toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" })
@ -630,18 +638,13 @@ export function CloseTicketDialog({
resolvedWithTicketId: linkedTicketCandidate ? (linkedTicketCandidate.id as Id<"tickets">) : undefined,
reopenWindowDays: Number.isFinite(reopenDaysNumber) ? reopenDaysNumber : undefined,
})
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
const sanitized = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
const hasContent = sanitized.replace(/<[^>]*>/g, "").trim().length > 0
if (hasContent) {
await addComment({
ticketId: ticketId as unknown as Id<"tickets">,
authorId: actorId,
visibility: "PUBLIC",
body: sanitized,
attachments: [],
})
}
await addComment({
ticketId: ticketId as unknown as Id<"tickets">,
authorId: actorId,
visibility: "PUBLIC",
body: sanitizedMessage,
attachments: [],
})
toast.success("Ticket encerrado com sucesso!", { id: "close-ticket" })
if (typeof window !== "undefined") {
window.localStorage.removeItem(draftStorageKey)

View file

@ -116,7 +116,6 @@ function TicketRow({ ticket }: { ticket: Ticket }) {
</span>
<span className="line-clamp-1 pr-32 text-base font-semibold text-neutral-900">{ticket.subject}</span>
</div>
<p className="line-clamp-2 pr-32 text-sm text-neutral-600">{ticket.summary ?? "Sem descrição"}</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-neutral-600">
{categoryBadges.length > 0 ? (

View file

@ -104,7 +104,6 @@ const NO_COMPANY_VALUE = "__no_company__"
const schema = z.object({
subject: z.string().default(""),
summary: z.string().optional(),
description: z.string().default(""),
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
@ -131,7 +130,6 @@ export function NewTicketDialog({
resolver: zodResolver(schema),
defaultValues: {
subject: "",
summary: "",
description: "",
priority: "MEDIUM",
channel: "MANUAL",
@ -551,7 +549,6 @@ export function NewTicketDialog({
actorId: convexUserId as Id<"users">,
tenantId: DEFAULT_TENANT_ID,
subject: subjectTrimmed,
summary: values.summary?.trim() || undefined,
priority: values.priority,
channel: values.channel,
queueId: sel?.id as Id<"queues"> | undefined,
@ -563,8 +560,8 @@ export function NewTicketDialog({
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
visitDate: visitDateTimestamp,
})
const summaryFallback = values.summary?.trim() ?? ""
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
const summaryFallback = subjectTrimmed
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : toHtml(summaryFallback)
const MAX_COMMENT_CHARS = 20000
const plainForLimit = (plainDescription.length > 0 ? plainDescription : summaryFallback).trim()
if (plainForLimit.length > MAX_COMMENT_CHARS) {
@ -585,7 +582,6 @@ export function NewTicketDialog({
setOpen(false)
form.reset({
subject: "",
summary: "",
description: "",
priority: "MEDIUM",
channel: "MANUAL",
@ -704,21 +700,6 @@ export function NewTicketDialog({
<Input id="subject" {...form.register("subject")} placeholder="Ex.: Erro 500 no portal" />
<FieldError errors={form.formState.errors.subject ? [{ message: form.formState.errors.subject.message }] : []} />
</Field>
<Field>
<FieldLabel htmlFor="summary">Resumo</FieldLabel>
<textarea
id="summary"
className="min-h-[96px] w-full resize-none overflow-hidden rounded-lg border border-slate-300 bg-background p-3 text-sm shadow-sm focus-visible:border-[#00d6eb] focus-visible:outline-none"
maxLength={600}
{...form.register("summary")}
placeholder="Explique em poucas linhas o contexto do chamado."
onInput={(e) => {
const el = e.currentTarget
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}}
/>
</Field>
<Field>
<FieldLabel className="flex items-center gap-1">
Descrição <span className="text-destructive">*</span>

View file

@ -117,7 +117,6 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
</div>
<div className="space-y-1">
<h2 className="text-xl font-semibold text-neutral-900">{ticket.subject}</h2>
<p className="text-sm text-neutral-600">{ticket.summary}</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600">
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>

View file

@ -48,7 +48,6 @@ function TicketRow({ ticket, entering }: { ticket: Ticket; entering: boolean })
<span className="line-clamp-1 text-[20px] font-semibold text-neutral-900 transition-colors group-hover:text-neutral-700">
{ticket.subject}
</span>
<p className="line-clamp-2 text-base text-neutral-600">{ticket.summary ?? "Sem descrição informada."}</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-neutral-600">
<span className="font-semibold text-neutral-700">{requesterName}</span>

View file

@ -193,7 +193,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const changeQueue = useMutation(api.tickets.changeQueue)
const changeRequester = useMutation(api.tickets.changeRequester)
const updateSubject = useMutation(api.tickets.updateSubject)
const updateSummary = useMutation(api.tickets.updateSummary)
const startWork = useMutation(api.tickets.startWork)
const pauseWork = useMutation(api.tickets.pauseWork)
const updateCategories = useMutation(api.tickets.updateCategories)
@ -263,7 +262,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [assigneeState, setAssigneeState] = useState(ticket.assignee ?? null)
const [editing, setEditing] = useState(false)
const [subject, setSubject] = useState(ticket.subject)
const [summary, setSummary] = useState(ticket.summary ?? "")
const [categorySelection, setCategorySelection] = useState<{ categoryId: string; subcategoryId: string }>(
{
categoryId: ticket.category?.id ?? "",
@ -288,10 +286,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [customersInitialized, setCustomersInitialized] = useState(false)
const selectedCategoryId = categorySelection.categoryId
const selectedSubcategoryId = categorySelection.subcategoryId
const dirty = useMemo(
() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
[subject, summary, ticket.subject, ticket.summary]
)
const dirty = useMemo(() => subject !== ticket.subject, [subject, ticket.subject])
const currentCategoryId = ticket.category?.id ?? ""
const currentSubcategoryId = ticket.subcategory?.id ?? ""
const categoryDirty = useMemo(() => {
@ -616,13 +611,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
actorId: convexUserId as Id<"users">,
})
}
if ((summary ?? "") !== (ticket.summary ?? "")) {
await updateSummary({
ticketId: ticket.id as Id<"tickets">,
summary: (summary ?? "").trim(),
actorId: convexUserId as Id<"users">,
})
}
toast.success("Cabeçalho atualizado!", { id: "save-header" })
}
setEditing(false)
@ -635,7 +623,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
function handleCancel() {
setSubject(ticket.subject)
setSummary(ticket.summary ?? "")
setCategorySelection({
categoryId: currentCategoryId,
subcategoryId: currentSubcategoryId,
@ -1299,25 +1286,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
onChange={(e) => setSubject(e.target.value)}
className="h-10 rounded-lg border border-slate-300 text-lg font-semibold text-neutral-900"
/>
<textarea
value={summary}
onChange={(e) => {
const el = e.currentTarget
// auto-resize height based on content
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
setSummary(e.target.value)
}}
onInput={(e) => {
const el = e.currentTarget
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}}
rows={3}
maxLength={600}
className="w-full resize-none overflow-hidden rounded-lg border border-slate-300 bg-white p-3 text-sm text-neutral-800 shadow-sm"
placeholder="Adicione um resumo opcional"
/>
</div>
) : (
<div className="space-y-1">
@ -1329,7 +1297,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</span>
) : null}
</div>
{summary ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null}
</div>
)}
</div>

View file

@ -218,9 +218,6 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
<span className="text-[15px] font-semibold text-neutral-900 line-clamp-2 md:line-clamp-1 break-words">
{ticket.subject}
</span>
<span className="text-sm text-neutral-600 line-clamp-1 break-words">
{ticket.summary ?? "Sem resumo"}
</span>
{ticket.formTemplateLabel || ticket.formTemplate ? (
<div className="flex items-center gap-2">
<Badge className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700">

View file

@ -1041,7 +1041,17 @@ export function RichTextEditor({
</ToolbarButton>
</div>
</div>
<div style={{ minHeight }} className="rich-text p-3">
<div
style={{ minHeight }}
className="rich-text p-3"
onMouseDown={(event) => {
if (!editor) return
if (event.target === event.currentTarget) {
event.preventDefault()
editor.commands.focus("end")
}
}}
>
<EditorContent editor={editor} />
</div>
</div>