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>

View file

@ -1,7 +1,9 @@
"use client"
import { z } from "zod"
import { useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { format, parseISO } from "date-fns"
import { ptBR } from "date-fns/locale"
import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
import { useMutation, useQuery } from "convex/react"
@ -24,8 +26,13 @@ import { CategorySelectFields } from "@/components/tickets/category-select"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { useDefaultQueues } from "@/hooks/use-default-queues"
import { cn } from "@/lib/utils"
import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
import { Calendar as CalendarIcon } from "lucide-react"
type CustomerOption = {
id: string
@ -90,23 +97,6 @@ function RequesterPreview({ customer, company }: RequesterPreviewProps) {
const NO_COMPANY_VALUE = "__no_company__"
type TicketFormFieldDefinition = {
id: string
key: string
label: string
type: string
required: boolean
description: string
options: Array<{ value: string; label: string }>
}
type TicketFormDefinition = {
key: string
label: string
description: string
fields: TicketFormFieldDefinition[]
}
const schema = z.object({
subject: z.string().default(""),
summary: z.string().optional(),
@ -175,6 +165,21 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
[companiesRemote]
)
const ensureTicketFormDefaultsMutation = useMutation(api.tickets.ensureTicketFormDefaults)
const hasEnsuredFormsRef = useRef(false)
useEffect(() => {
if (!convexUserId || hasEnsuredFormsRef.current) return
hasEnsuredFormsRef.current = true
ensureTicketFormDefaultsMutation({
tenantId: DEFAULT_TENANT_ID,
actorId: convexUserId as Id<"users">,
}).catch((error) => {
console.error("Falha ao preparar formulários personalizados", error)
hasEnsuredFormsRef.current = false
})
}, [convexUserId, ensureTicketFormDefaultsMutation])
const formsRemote = useQuery(
api.tickets.listTicketForms,
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
@ -195,6 +200,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
const selectedForm = useMemo(() => forms.find((formDef) => formDef.key === selectedFormKey) ?? forms[0], [forms, selectedFormKey])
@ -225,7 +231,6 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
[attachments]
)
const priorityValue = form.watch("priority") as TicketPriority
const channelValue = form.watch("channel")
const queueValue = form.watch("queueName") ?? "NONE"
const assigneeValue = form.watch("assigneeId") ?? null
const assigneeSelectValue = assigneeValue ?? "NONE"
@ -386,6 +391,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
useEffect(() => {
if (!open) {
setAssigneeInitialized(false)
setOpenCalendarField(null)
return
}
if (assigneeInitialized) return
@ -449,49 +455,13 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
for (const field of selectedForm.fields) {
const raw = customFieldValues[field.id]
const isBooleanField = field.type === "boolean"
const isEmpty =
raw === undefined ||
raw === null ||
(typeof raw === "string" && raw.trim().length === 0)
if (isBooleanField) {
const boolValue = Boolean(raw)
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value: boolValue })
continue
}
if (field.required && isEmpty) {
toast.error(`Preencha o campo "${field.label}".`, { id: "new-ticket" })
setLoading(false)
return
}
if (isEmpty) {
continue
}
let value: unknown = raw
if (field.type === "number") {
const parsed = typeof raw === "number" ? raw : Number(raw)
if (!Number.isFinite(parsed)) {
toast.error(`Informe um valor numérico válido para "${field.label}".`, { id: "new-ticket" })
setLoading(false)
return
}
value = parsed
} else if (field.type === "boolean") {
value = Boolean(raw)
} else if (field.type === "date") {
value = String(raw)
} else {
value = String(raw)
}
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value })
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
if (!normalized.ok) {
toast.error(normalized.message, { id: "new-ticket" })
setLoading(false)
return
}
customFieldsPayload = normalized.payload
}
setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" })
@ -601,7 +571,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
</div>
{forms.length > 1 ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<p className="text-sm font-semibold text-neutral-800">Modelo de ticket</p>
<p className="text-sm font-semibold text-neutral-800">Tipo de solicitação</p>
<div className="mt-2 flex flex-wrap gap-2">
{forms.map((formDef) => (
<Button
@ -744,7 +714,6 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
<FieldLabel className="flex items-center gap-1">
Solicitante <span className="text-destructive">*</span>
</FieldLabel>
<RequesterPreview customer={selectedRequester} company={selectedCompanyOption} />
<SearchableCombobox
value={requesterValue || null}
onValueChange={(nextValue) => {
@ -778,18 +747,27 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
placeholder={filteredCustomers.length === 0 ? "Nenhum usuário disponível" : "Selecionar solicitante"}
searchPlaceholder="Buscar por nome ou e-mail..."
disabled={filteredCustomers.length === 0}
renderValue={(option) =>
option ? (
<div className="flex flex-col">
<span className="truncate font-medium text-foreground">{option.label}</span>
{option.description ? (
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
renderValue={(option) => {
if (!option) return <span className="text-muted-foreground">Selecionar solicitante</span>
return (
<div className="flex w-full items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-foreground">{option.label}</span>
{option.description ? (
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
) : null}
</div>
{selectedCompanyOption && selectedCompanyOption.id !== NO_COMPANY_VALUE ? (
<Badge
variant="outline"
className="hidden shrink-0 rounded-full px-2.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground sm:inline-flex"
>
{selectedCompanyOption.name}
</Badge>
) : null}
</div>
) : (
<span className="text-muted-foreground">Selecionar solicitante</span>
)
}
}}
renderOption={(option) => {
const record = requesterById.get(option.value)
const initials = getInitials(record?.name, record?.email ?? option.label)
@ -862,34 +840,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Canal</FieldLabel>
<Select value={channelValue} onValueChange={(v) => form.setValue("channel", v as z.infer<typeof schema>["channel"])}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Canal" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
<SelectItem value="EMAIL" className={selectItemClass}>
E-mail
</SelectItem>
<SelectItem value="WHATSAPP" className={selectItemClass}>
WhatsApp
</SelectItem>
<SelectItem value="CHAT" className={selectItemClass}>
Chat
</SelectItem>
<SelectItem value="PHONE" className={selectItemClass}>
Telefone
</SelectItem>
<SelectItem value="API" className={selectItemClass}>
API
</SelectItem>
<SelectItem value="MANUAL" className={selectItemClass}>
Manual
</SelectItem>
</SelectContent>
</Select>
</Field>
{/* Canal removido da UI: padrão MANUAL será enviado */}
<Field>
<FieldLabel>Fila</FieldLabel>
<Select value={queueValue} onValueChange={(v) => form.setValue("queueName", v === "NONE" ? null : v)}>
@ -935,119 +886,180 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
</Select>
</Field>
</div>
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? (
<div className="space-y-4 rounded-xl border border-slate-200 bg-white px-4 py-4">
<p className="text-sm font-semibold text-neutral-800">Informações adicionais</p>
{selectedForm.fields.map((field) => {
const value = customFieldValues[field.id]
const fieldId = `custom-field-${field.id}`
const labelSuffix = field.required ? <span className="text-destructive">*</span> : null
const helpText = field.description ? (
<p className="text-xs text-neutral-500">{field.description}</p>
) : null
if (field.type === "boolean") {
return (
<div
key={field.id}
className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"
>
<input
id={fieldId}
type="checkbox"
className="size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
checked={Boolean(value)}
onChange={(event) => handleCustomFieldChange(field, event.target.checked)}
/>
<div className="flex flex-col">
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
{field.label} {labelSuffix}
</label>
{helpText}
</div>
</div>
)
}
if (field.type === "select") {
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(selected) => handleCustomFieldChange(field, selected)}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value} className={selectItemClass}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{helpText}
</Field>
)
}
if (field.type === "number") {
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
type="number"
inputMode="decimal"
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
if (field.type === "date") {
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
type="date"
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
})}
</div>
) : null}
</div>
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? (
<div className="grid gap-4 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2 lg:col-span-2">
<p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
{selectedForm.fields.map((field) => {
const value = customFieldValues[field.id]
const fieldId = `custom-field-${field.id}`
const isRequiredStar = field.required && field.key !== "colaborador_patrimonio"
const labelSuffix = isRequiredStar ? <span className="text-destructive">*</span> : null
const helpText = field.description ? (
<p className="text-xs text-neutral-500">{field.description}</p>
) : null
const shouldUseTextarea = field.key.includes("observacao") || field.key.includes("permissao")
const spanClass = shouldUseTextarea || field.type === "boolean" ? "sm:col-span-2" : ""
if (field.type === "boolean") {
return (
<div
key={field.id}
className={cn(
"flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2",
spanClass,
"sm:col-span-2"
)}
>
<input
id={fieldId}
type="checkbox"
className="size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
checked={Boolean(value)}
onChange={(event) => handleCustomFieldChange(field, event.target.checked)}
/>
<div className="flex flex-col">
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
{field.label} {labelSuffix}
</label>
{helpText}
</div>
</div>
)
}
if (field.type === "select") {
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(selected) => handleCustomFieldChange(field, selected)}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value} className={selectItemClass}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{helpText}
</Field>
)
}
if (field.type === "number") {
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
type="number"
inputMode="decimal"
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
if (field.type === "date") {
const parsedDate =
typeof value === "string" && value ? parseISO(value) : undefined
const isValidDate = Boolean(parsedDate && !Number.isNaN(parsedDate.getTime()))
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Popover
open={openCalendarField === field.id}
onOpenChange={(open) => setOpenCalendarField(open ? field.id : null)}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"w-full justify-between gap-2 text-left font-normal",
!isValidDate && "text-muted-foreground"
)}
>
<span>
{isValidDate
? format(parsedDate as Date, "dd/MM/yyyy", { locale: ptBR })
: "Selecionar data"}
</span>
<CalendarIcon className="size-4 text-muted-foreground" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={isValidDate ? (parsedDate as Date) : undefined}
onSelect={(selected) => {
handleCustomFieldChange(
field,
selected ? format(selected, "yyyy-MM-dd") : ""
)
setOpenCalendarField(null)
}}
initialFocus
captionLayout="dropdown"
startMonth={new Date(1900, 0)}
endMonth={new Date(new Date().getFullYear() + 5, 11)}
locale={ptBR}
/>
</PopoverContent>
</Popover>
{helpText}
</Field>
)
}
if (shouldUseTextarea) {
return (
<Field key={field.id} className={cn("flex-col", spanClass, "sm:col-span-2")}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<textarea
id={fieldId}
className="min-h-[90px] rounded-lg border border-slate-300 px-3 py-2 text-sm text-neutral-800 shadow-sm focus:border-neutral-900 focus:outline-none focus:ring-2 focus:ring-neutral-900/10"
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
})}
</div>
) : null}
</FieldGroup>
</FieldSet>
</form>

View file

@ -32,10 +32,44 @@ function formatRelative(timestamp: Date | null | undefined) {
export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
const router = useRouter()
const { session, convexUserId, role: authRole } = useAuth()
const { session, convexUserId, role: authRole, machineContext } = useAuth()
const submitCsat = useMutation(api.tickets.submitCsat)
const viewerRole = (authRole ?? session?.user.role ?? "").toUpperCase()
const deriveViewerRole = () => {
const authRoleNormalized = authRole?.toLowerCase()?.trim()
const machinePersona = machineContext?.persona ?? session?.user.machinePersona ?? null
const assignedRole = machineContext?.assignedUserRole ?? null
const sessionRole = session?.user.role?.toLowerCase()?.trim()
if (authRoleNormalized && authRoleNormalized !== "machine") {
return authRoleNormalized.toUpperCase()
}
if (authRoleNormalized === "machine" && machinePersona) {
return machinePersona.toUpperCase()
}
if (machinePersona) {
return machinePersona.toUpperCase()
}
if (assignedRole) {
return assignedRole.toUpperCase()
}
if (sessionRole && sessionRole !== "machine") {
return sessionRole.toUpperCase()
}
if (sessionRole === "machine") {
return "COLLABORATOR"
}
return "COLLABORATOR"
}
const viewerRole = deriveViewerRole()
const viewerEmail = session?.user.email?.trim().toLowerCase() ?? ""
const viewerId = convexUserId as Id<"users"> | undefined
@ -187,6 +221,10 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
</span>
{ratedAtRelative ? `${ratedAtRelative}` : null}
</p>
) : viewerIsStaff ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-xs text-neutral-600">
Nenhuma avaliação registrada ainda.
</div>
) : null}
{canSubmit ? (
<div className="space-y-2">