fix(reports): remove truncation cap in range collectors to avoid dropped records

feat(calendar): migrate to react-day-picker v9 and polish UI
- Update classNames and CSS import (style.css)
- Custom Dropdown via shadcn Select
- Nav arrows aligned with caption (around)
- Today highlight with cyan tone, weekdays in sentence case
- Wider layout to avoid overflow; remove inner wrapper

chore(tickets): make 'Patrimônio do computador (se houver)' optional
- Backend hotfix to enforce optional + label on existing tenants
- Hide required asterisk for this field in portal/new-ticket

refactor(new-ticket): remove channel dropdown from admin/agent flow
- Keep default channel as MANUAL

feat(ux): simplify requester section and enlarge combobox trigger
- Remove RequesterPreview redundancy; show company badge in trigger
This commit is contained in:
codex-bot 2025-11-04 11:51:08 -03:00
parent e0ef66555d
commit a8333c010f
28 changed files with 1752 additions and 455 deletions

View file

@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"
import { useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
@ -14,9 +14,18 @@ import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import type { TicketStatus } from "@/lib/schemas/ticket"
type ClosingTemplate = { id: string; title: string; body: string }
type TicketLinkSuggestion = {
id: string
reference: number
subject: string
status: string
priority: string
}
const DEFAULT_PHONE_NUMBER = "(11) 4173-5368"
const DEFAULT_COMPANY_NAME = "Rever Tecnologia"
@ -100,6 +109,21 @@ const formatDurationLabel = (ms: number) => {
return `${minutes}min`
}
const STATUS_LABELS: Record<TicketStatus, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Em atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
}
function formatTicketStatusLabel(status: string) {
const normalized = (status ?? "").toUpperCase()
if (normalized in STATUS_LABELS) {
return STATUS_LABELS[normalized as TicketStatus]
}
return status
}
export function CloseTicketDialog({
open,
onOpenChange,
@ -164,23 +188,35 @@ export function CloseTicketDialog({
const [adjustReason, setAdjustReason] = useState<string>("")
const enableAdjustment = Boolean(canAdjustTime && workSummary)
const [linkedReference, setLinkedReference] = useState<string>("")
const [linkSuggestions, setLinkSuggestions] = useState<TicketLinkSuggestion[]>([])
const [linkedTicketSelection, setLinkedTicketSelection] = useState<TicketLinkSuggestion | null>(null)
const [showLinkSuggestions, setShowLinkSuggestions] = useState(false)
const [isSearchingLinks, setIsSearchingLinks] = useState(false)
const linkSuggestionsAbortRef = useRef<AbortController | null>(null)
const linkedReferenceInputRef = useRef<HTMLInputElement | null>(null)
const suggestionHideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [reopenWindowDays, setReopenWindowDays] = useState<string>("14")
const digitsOnlyReference = linkedReference.replace(/[^0-9]/g, "").trim()
const normalizedReference = useMemo(() => {
const digits = linkedReference.replace(/[^0-9]/g, "").trim()
if (!digits) return null
const parsed = Number(digits)
if (!digitsOnlyReference) return null
const parsed = Number(digitsOnlyReference)
if (!Number.isFinite(parsed) || parsed <= 0) return null
if (ticketReference && parsed === ticketReference) return null
return parsed
}, [linkedReference, ticketReference])
}, [digitsOnlyReference, ticketReference])
const linkedTicket = useQuery(
api.tickets.findByReference,
actorId && normalizedReference ? { tenantId, viewerId: actorId, reference: normalizedReference } : "skip"
) as { id: Id<"tickets">; reference: number; subject: string; status: string } | null | undefined
const isLinkLoading = Boolean(actorId && normalizedReference && linkedTicket === undefined)
const linkNotFound = Boolean(normalizedReference && linkedTicket === null && !isLinkLoading)
const hasSufficientDigits = digitsOnlyReference.length >= 3
const linkedTicketCandidate = linkedTicketSelection ?? (linkedTicket ?? null)
const linkNotFound = Boolean(
hasSufficientDigits && !isLinkLoading && !linkedTicketSelection && linkedTicket === null && linkSuggestions.length === 0
)
const hydrateTemplateBody = useCallback((templateHtml: string) => {
const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName)
@ -198,8 +234,21 @@ export function CloseTicketDialog({
setInternalMinutes("0")
setExternalHours("0")
setExternalMinutes("0")
setLinkedReference("")
setLinkedTicketSelection(null)
setLinkSuggestions([])
setShowLinkSuggestions(false)
}, [open])
useEffect(() => {
return () => {
linkSuggestionsAbortRef.current?.abort()
if (suggestionHideTimeoutRef.current) {
clearTimeout(suggestionHideTimeoutRef.current)
}
}
}, [])
useEffect(() => {
if (!open) return
if (templates.length > 0 && !selectedTemplateId && !message) {
@ -232,11 +281,138 @@ export function CloseTicketDialog({
}
}, [shouldAdjustTime])
useEffect(() => {
if (!open) return
const rawQuery = linkedReference.trim()
if (rawQuery.length < 2) {
linkSuggestionsAbortRef.current?.abort()
setIsSearchingLinks(false)
setLinkSuggestions([])
setShowLinkSuggestions(false)
return
}
const digitsForSelection = String(linkedTicketSelection?.reference ?? "")
if (linkedTicketSelection && digitsForSelection === digitsOnlyReference) {
setLinkSuggestions([])
setIsSearchingLinks(false)
return
}
linkSuggestionsAbortRef.current?.abort()
const controller = new AbortController()
linkSuggestionsAbortRef.current = controller
setIsSearchingLinks(true)
if (linkedReferenceInputRef.current && document.activeElement === linkedReferenceInputRef.current) {
setShowLinkSuggestions(true)
}
fetch(`/api/tickets/mentions?q=${encodeURIComponent(rawQuery)}`, {
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
return { items: [] as TicketLinkSuggestion[] }
}
const json = (await response.json()) as { items?: Array<{ id: string; reference: number; subject?: string | null; status?: string | null; priority?: string | null }> }
const items = Array.isArray(json.items) ? json.items : []
return {
items: items
.filter((item) => String(item.id) !== ticketId && Number(item.reference) !== ticketReference)
.map((item) => ({
id: String(item.id),
reference: Number(item.reference),
subject: item.subject ?? "",
status: item.status ?? "PENDING",
priority: item.priority ?? "MEDIUM",
})),
}
})
.then(({ items }) => {
if (controller.signal.aborted) {
return
}
setLinkSuggestions(items)
if (linkedReferenceInputRef.current && document.activeElement === linkedReferenceInputRef.current) {
setShowLinkSuggestions(true)
}
})
.catch((error) => {
if ((error as Error).name !== "AbortError") {
console.error("Falha ao buscar tickets para vincular", error)
}
})
.finally(() => {
if (!controller.signal.aborted) {
setIsSearchingLinks(false)
}
})
return () => {
controller.abort()
}
}, [open, linkedReference, digitsOnlyReference, linkedTicketSelection, ticketId, ticketReference])
const handleTemplateSelect = (template: ClosingTemplate) => {
setSelectedTemplateId(template.id)
setMessage(hydrateTemplateBody(template.body))
}
const handleLinkedReferenceChange = (value: string) => {
setLinkedReference(value)
const digits = value.replace(/[^0-9]/g, "").trim()
if (value.trim().length === 0) {
setLinkedTicketSelection(null)
setLinkSuggestions([])
setShowLinkSuggestions(false)
return
}
if (linkedTicketSelection && String(linkedTicketSelection.reference) !== digits) {
setLinkedTicketSelection(null)
}
if (value.trim().length >= 2) {
setShowLinkSuggestions(true)
} else {
setShowLinkSuggestions(false)
}
}
const handleLinkedReferenceFocus = () => {
if (suggestionHideTimeoutRef.current) {
clearTimeout(suggestionHideTimeoutRef.current)
suggestionHideTimeoutRef.current = null
}
if (linkSuggestions.length > 0) {
setShowLinkSuggestions(true)
}
}
const handleLinkedReferenceBlur = () => {
suggestionHideTimeoutRef.current = setTimeout(() => {
setShowLinkSuggestions(false)
}, 150)
}
const handleSelectLinkSuggestion = (suggestion: TicketLinkSuggestion) => {
if (suggestionHideTimeoutRef.current) {
clearTimeout(suggestionHideTimeoutRef.current)
suggestionHideTimeoutRef.current = null
}
setLinkedTicketSelection(suggestion)
setLinkedReference(`#${suggestion.reference}`)
setShowLinkSuggestions(false)
setLinkSuggestions([])
}
const handleLinkedReferenceKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
if (linkSuggestions.length > 0) {
event.preventDefault()
handleSelectLinkSuggestion(linkSuggestions[0])
}
}
}
const handleSubmit = async () => {
if (!actorId) {
toast.error("É necessário estar autenticado para encerrar o ticket.")
@ -296,7 +472,7 @@ export function CloseTicketDialog({
setIsSubmitting(false)
return
}
if (linkNotFound || !linkedTicket) {
if (linkNotFound || !linkedTicketCandidate) {
toast.error("Não encontramos o ticket informado para vincular. Verifique o número e tente novamente.", {
id: "close-ticket",
})
@ -320,7 +496,7 @@ export function CloseTicketDialog({
await resolveTicketMutation({
ticketId: ticketId as unknown as Id<"tickets">,
actorId,
resolvedWithTicketId: linkedTicket ? (linkedTicket.id as Id<"tickets">) : undefined,
resolvedWithTicketId: linkedTicketCandidate ? (linkedTicketCandidate.id as Id<"tickets">) : undefined,
reopenWindowDays: Number.isFinite(reopenDaysNumber) ? reopenDaysNumber : undefined,
})
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
@ -399,13 +575,51 @@ export function CloseTicketDialog({
<Label htmlFor="linked-reference" className="text-sm font-medium text-neutral-800">
Ticket relacionado (opcional)
</Label>
<Input
id="linked-reference"
value={linkedReference}
onChange={(event) => setLinkedReference(event.target.value)}
placeholder="Número do ticket relacionado (ex.: 12345)"
disabled={isSubmitting}
/>
<div className="relative">
<Input
id="linked-reference"
ref={linkedReferenceInputRef}
value={linkedReference}
onChange={(event) => handleLinkedReferenceChange(event.target.value)}
onFocus={handleLinkedReferenceFocus}
onBlur={handleLinkedReferenceBlur}
onKeyDown={handleLinkedReferenceKeyDown}
placeholder="Buscar por número ou assunto do ticket"
disabled={isSubmitting}
/>
{showLinkSuggestions ? (
<div className="absolute left-0 right-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-lg border border-slate-200 bg-white shadow-lg">
{isSearchingLinks ? (
<div className="flex items-center gap-2 px-3 py-2 text-sm text-neutral-500">
<Spinner className="size-3" /> Buscando tickets relacionados...
</div>
) : linkSuggestions.length === 0 ? (
<div className="px-3 py-2 text-sm text-neutral-500">Nenhum ticket encontrado.</div>
) : (
linkSuggestions.map((suggestion) => {
const statusLabel = formatTicketStatusLabel(suggestion.status)
return (
<button
key={suggestion.id}
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleSelectLinkSuggestion(suggestion)}
className="flex w-full flex-col gap-1 px-3 py-2 text-left text-sm transition hover:bg-slate-100 focus:bg-slate-100"
>
<span className="font-semibold text-neutral-900">
#{suggestion.reference}
</span>
{suggestion.subject ? (
<span className="text-xs text-neutral-600">{suggestion.subject}</span>
) : null}
<span className="text-xs text-neutral-500">Status: {statusLabel}</span>
</button>
)
})
)}
</div>
) : null}
</div>
{linkedReference.trim().length === 0 ? (
<p className="text-xs text-neutral-500">Informe o número de outro ticket quando o atendimento estiver relacionado.</p>
) : isLinkLoading ? (
@ -414,9 +628,9 @@ export function CloseTicketDialog({
</p>
) : linkNotFound ? (
<p className="text-xs text-red-500">Ticket não encontrado ou sem acesso permitido. Verifique o número informado.</p>
) : linkedTicket ? (
) : linkedTicketCandidate ? (
<p className="text-xs text-emerald-600">
Será registrado vínculo com o ticket #{linkedTicket.reference} {linkedTicket.subject ?? "Sem assunto"}
Será registrado vínculo com o ticket #{linkedTicketCandidate.reference} {linkedTicketCandidate.subject ?? "Sem assunto"}
</p>
) : null}
</div>