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:
parent
e0ef66555d
commit
a8333c010f
28 changed files with 1752 additions and 455 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue