feat: custom fields improvements

This commit is contained in:
Esdras Renan 2025-11-06 14:05:51 -03:00
parent 9495b54a28
commit 0f0f367b3a
11 changed files with 1290 additions and 12 deletions

View file

@ -0,0 +1,570 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { format, parseISO } from "date-fns"
import { ptBR } from "date-fns/locale"
import { CalendarIcon, Pencil, X } from "lucide-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
import { mapTicketCustomFields, type TicketCustomFieldRecord } from "@/lib/ticket-custom-fields"
import { useAuth } from "@/lib/auth-client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Calendar } from "@/components/ui/calendar"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Field, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
type TicketCustomFieldsListProps = {
record?: TicketCustomFieldRecord | null
emptyMessage?: string
className?: string
}
const DEFAULT_FORM: TicketFormDefinition = {
key: "default",
label: "Chamado",
description: "",
fields: [],
}
function buildInitialValues(
fields: TicketFormFieldDefinition[],
record?: TicketCustomFieldRecord | null
): Record<string, unknown> {
if (!record) return {}
const result: Record<string, unknown> = {}
for (const field of fields) {
const entry = record[field.key]
if (!entry) continue
const value = entry.value
switch (field.type) {
case "number":
result[field.id] =
typeof value === "number"
? String(value)
: typeof value === "string"
? value
: ""
break
case "date":
if (typeof value === "number") {
const date = new Date(value)
result[field.id] = Number.isNaN(date.getTime()) ? "" : format(date, "yyyy-MM-dd")
} else if (typeof value === "string") {
const parsed = parseISO(value)
result[field.id] = Number.isNaN(parsed.getTime()) ? value : format(parsed, "yyyy-MM-dd")
} else {
result[field.id] = ""
}
break
case "boolean":
if (value === null || value === undefined) {
result[field.id] = field.required ? false : null
} else {
result[field.id] = Boolean(value)
}
break
default:
result[field.id] = value ?? ""
}
}
return result
}
function isEmptyValue(value: unknown): boolean {
if (value === undefined || value === null) return true
if (typeof value === "string" && value.trim().length === 0) return true
return false
}
function normalizeFieldValue(
field: TicketFormFieldDefinition,
raw: unknown
): { ok: true; value: unknown } | { ok: false; message: string } | { ok: true; skip: true } {
if (field.type === "boolean") {
if (raw === null || raw === undefined) {
if (field.required) {
return { ok: false, message: `Preencha o campo "${field.label}".` }
}
return { ok: true, skip: true }
}
return { ok: true, value: Boolean(raw) }
}
if (isEmptyValue(raw)) {
if (field.required) {
return { ok: false, message: `Preencha o campo "${field.label}".` }
}
return { ok: true, skip: true }
}
switch (field.type) {
case "number": {
const numeric = typeof raw === "number" ? raw : Number(String(raw).replace(",", "."))
if (!Number.isFinite(numeric)) {
return { ok: false, message: `Informe um valor numérico válido para "${field.label}".` }
}
return { ok: true, value: numeric }
}
case "date": {
const value = String(raw)
if (!value || Number.isNaN(Date.parse(value))) {
return { ok: false, message: `Selecione uma data válida para "${field.label}".` }
}
return { ok: true, value }
}
case "select": {
const value = String(raw)
if (!value) {
if (field.required) {
return { ok: false, message: `Selecione uma opção para "${field.label}".` }
}
return { ok: true, skip: true }
}
return { ok: true, value }
}
default: {
if (typeof raw === "string") {
return { ok: true, value: raw.trim() }
}
return { ok: true, value: raw }
}
}
}
export function TicketCustomFieldsList({ record, emptyMessage, className }: TicketCustomFieldsListProps) {
const entries = useMemo(() => mapTicketCustomFields(record), [record])
if (entries.length === 0) {
return (
<p className={cn("text-sm text-neutral-500", className)}>
{emptyMessage ?? "Nenhum campo adicional preenchido neste chamado."}
</p>
)
}
return (
<div className={cn("grid gap-3 sm:grid-cols-2", className)}>
{entries.map((entry) => (
<div
key={entry.key}
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">{entry.label}</p>
<p className="mt-1 text-sm font-semibold text-neutral-900">{entry.formattedValue}</p>
</div>
))}
</div>
)
}
type TicketCustomFieldsSectionProps = {
ticket: TicketWithDetails
}
export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionProps) {
const { convexUserId, role } = useAuth()
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
const viewerId = convexUserId as Id<"users"> | null
const tenantId = ticket.tenantId
const ensureTicketFormDefaults = useMutation(api.tickets.ensureTicketFormDefaults)
useEffect(() => {
if (!canEdit || !viewerId) return
let cancelled = false
;(async () => {
try {
await ensureTicketFormDefaults({
tenantId,
actorId: viewerId,
})
} catch (error) {
if (!cancelled) {
console.error("[ticket-custom-fields] Falha ao garantir campos padrão", error)
}
}
})()
return () => {
cancelled = true
}
}, [canEdit, ensureTicketFormDefaults, tenantId, viewerId])
const formsRemote = useQuery(
api.tickets.listTicketForms,
canEdit && viewerId
? { tenantId, viewerId }
: "skip"
) as TicketFormDefinition[] | undefined
const availableForms = useMemo<TicketFormDefinition[]>(() => {
if (!formsRemote || formsRemote.length === 0) {
return [DEFAULT_FORM]
}
return formsRemote
}, [formsRemote])
const selectedForm = useMemo<TicketFormDefinition>(() => {
const key = ticket.formTemplate ?? "default"
return availableForms.find((form) => form.key === key) ?? availableForms[0] ?? DEFAULT_FORM
}, [availableForms, ticket.formTemplate])
const [editorOpen, setEditorOpen] = useState(false)
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [validationError, setValidationError] = useState<string | null>(null)
const initialValues = useMemo(
() => buildInitialValues(selectedForm.fields, ticket.customFields),
[selectedForm.fields, ticket.customFields]
)
useEffect(() => {
if (!editorOpen) return
setCustomFieldValues(initialValues)
setValidationError(null)
}, [editorOpen, initialValues])
const updateCustomFields = useMutation(api.tickets.updateCustomFields)
const handleFieldChange = (field: TicketFormFieldDefinition, value: unknown) => {
setCustomFieldValues((previous) => ({
...previous,
[field.id]: value,
}))
}
const handleClearField = (fieldId: string) => {
setCustomFieldValues((previous) => {
const next = { ...previous }
delete next[fieldId]
return next
})
}
const handleSubmit = async () => {
if (!viewerId) return
const payload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
for (const field of selectedForm.fields) {
const raw = customFieldValues[field.id]
const normalized = normalizeFieldValue(field, raw)
if (!normalized.ok) {
if ("message" in normalized) {
setValidationError(normalized.message)
}
return
}
if ("skip" in normalized && normalized.skip) {
continue
}
payload.push({
fieldId: field.id as Id<"ticketFields">,
value: normalized.value,
})
}
setIsSaving(true)
setValidationError(null)
try {
await updateCustomFields({
ticketId: ticket.id as Id<"tickets">,
actorId: viewerId,
fields: payload,
})
toast.success("Campos personalizados atualizados!", { id: "ticket-custom-fields" })
setEditorOpen(false)
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar os campos personalizados.", { id: "ticket-custom-fields" })
} finally {
setIsSaving(false)
}
}
const entries = useMemo(() => mapTicketCustomFields(ticket.customFields), [ticket.customFields])
const hasConfiguredFields = selectedForm.fields.length > 0
return (
<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">Informações adicionais</h3>
{canEdit && hasConfiguredFields ? (
<Button
type="button"
variant="outline"
size="sm"
className="inline-flex items-center gap-2"
onClick={() => setEditorOpen(true)}
>
<Pencil className="size-3.5" />
Editar campos
</Button>
) : null}
</div>
<TicketCustomFieldsList
record={ticket.customFields}
emptyMessage="Nenhum campo adicional preenchido neste chamado."
/>
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
<DialogContent className="max-w-3xl gap-4">
<DialogHeader>
<DialogTitle>Editar campos personalizados</DialogTitle>
<DialogDescription>
Atualize os campos adicionais deste chamado. Os campos obrigatórios devem ser preenchidos.
</DialogDescription>
</DialogHeader>
{hasConfiguredFields ? (
<div className="grid gap-4 sm:grid-cols-2">
{selectedForm.fields.map((field) => {
const value = customFieldValues[field.id]
const fieldId = `ticket-custom-field-${field.id}`
const isTextarea =
field.type === "text" && (field.key.includes("observacao") || field.key.includes("permissao"))
const spanClass =
isTextarea || field.type === "boolean" || field.type === "date" ? "sm:col-span-2" : ""
const helpText = field.description ? (
<p className="text-xs text-neutral-500">{field.description}</p>
) : null
if (field.type === "boolean") {
const isIndeterminate = value === null || value === undefined
return (
<div
key={field.id}
className={cn(
"flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2",
spanClass
)}
>
<input
id={fieldId}
type="checkbox"
className="mt-1 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)}
ref={(element) => {
if (!element) return
element.indeterminate = isIndeterminate
}}
onChange={(event) => handleFieldChange(field, event.target.checked)}
/>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</label>
{helpText}
{!field.required ? (
<button
type="button"
className="inline-flex items-center gap-1 text-xs font-medium text-neutral-500 transition hover:text-neutral-700"
onClick={() => handleClearField(field.id)}
>
<X className="size-3" />
Remover valor
</button>
) : null}
</div>
</div>
)
}
if (field.type === "select") {
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(selected) => handleFieldChange(field, selected)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{!field.required ? (
<SelectItem value="" className="text-neutral-500">
Limpar seleção
</SelectItem>
) : null}
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{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} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Input
id={fieldId}
type="number"
inputMode="decimal"
value={
typeof value === "number"
? String(value)
: typeof value === "string"
? value
: ""
}
onChange={(event) => handleFieldChange(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={cn("flex flex-col gap-1", spanClass)}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</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) => {
handleFieldChange(
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>
{!field.required ? (
<button
type="button"
className="inline-flex items-center gap-1 text-xs font-medium text-neutral-500 transition hover:text-neutral-700"
onClick={() => handleFieldChange(field, "")}
>
<X className="size-3" />
Limpar data
</button>
) : null}
{helpText}
</Field>
)
}
if (isTextarea) {
return (
<Field key={field.id} className={cn("flex-col", spanClass)}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Textarea
id={fieldId}
value={typeof value === "string" ? value : ""}
onChange={(event) => handleFieldChange(field, event.target.value)}
className="min-h-[90px]"
/>
{helpText}
</Field>
)
}
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Input
id={fieldId}
value={typeof value === "string" ? value : value ?? ""}
onChange={(event) => handleFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
})}
</div>
) : (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-neutral-600">
Nenhum campo personalizado configurado para este formulário.
</div>
)}
{validationError ? (
<p className="text-sm font-medium text-destructive">{validationError}</p>
) : null}
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => setEditorOpen(false)}
disabled={isSaving}
>
Cancelar
</Button>
<Button type="button" onClick={handleSubmit} disabled={isSaving || !hasConfiguredFields}>
{isSaving ? <Spinner className="size-4" /> : null}
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
)
}

View file

@ -3,10 +3,11 @@ import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { cn } from "@/lib/utils"
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
interface TicketDetailsPanelProps {
ticket: TicketWithDetails
@ -128,6 +129,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
</div>
</section>
<TicketCustomFieldsSection ticket={ticket} />
<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>

View file

@ -304,6 +304,24 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
</div>
)
}
if (entry.type === "CUSTOM_FIELDS_UPDATED") {
const payloadFields = Array.isArray((payload as { fields?: unknown }).fields)
? ((payload as { fields?: Array<{ label?: string }> }).fields ?? [])
: []
const fieldLabels = payloadFields
.map((field) => (typeof field?.label === "string" ? field.label.trim() : ""))
.filter((label) => label.length > 0)
message = (
<div className="space-y-1">
<span className="block text-sm text-neutral-600">
<span className="font-semibold text-neutral-800">Campos personalizados atualizados</span>
{fieldLabels.length > 0 ? (
<span className="text-neutral-500"> ({fieldLabels.join(", ")})</span>
) : null}
</span>
</div>
)
}
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
message = "Status alterado para " + (payload.toLabel || payload.to)
}