"use client" import { useEffect, useMemo, useState, type ReactNode } from "react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" import { format, parse } 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" import { useLocalTimeZone } from "@/hooks/use-local-time-zone" type TicketCustomFieldsListProps = { record?: TicketCustomFieldRecord | null emptyMessage?: string className?: string actionSlot?: ReactNode } const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/ const DEFAULT_FORM: TicketFormDefinition = { key: "default", label: "Chamado", description: "", fields: [], } function toIsoDateString(value: unknown): string { if (!value && value !== 0) return "" if (typeof value === "string") { const trimmed = value.trim() if (!trimmed) return "" if (ISO_DATE_REGEX.test(trimmed)) { return trimmed } const parsed = new Date(trimmed) return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString().slice(0, 10) } const date = value instanceof Date ? value : typeof value === "number" ? new Date(value) : null if (!date || Number.isNaN(date.getTime())) { return "" } return date.toISOString().slice(0, 10) } function parseIsoDate(value: string): Date | null { if (!ISO_DATE_REGEX.test(value)) return null const parsed = parse(value, "yyyy-MM-dd", new Date()) return Number.isNaN(parsed.getTime()) ? null : parsed } function buildInitialValues( fields: TicketFormFieldDefinition[], record?: TicketCustomFieldRecord | null ): Record { if (!record) return {} const result: Record = {} 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": { const isoValue = toIsoDateString(value) result[field.id] = isoValue 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, actionSlot }: TicketCustomFieldsListProps) { const entries = useMemo(() => mapTicketCustomFields(record), [record]) const hasAction = Boolean(actionSlot) if (entries.length === 0) { return (
{hasAction ? (
{actionSlot}
) : null}

{emptyMessage ?? "Nenhum campo adicional preenchido neste chamado."}

) } return (
{hasAction ? actionSlot : null} {entries.map((entry) => (

{entry.label}

{entry.formattedValue}

))}
) } type TicketCustomFieldsSectionProps = { ticket: TicketWithDetails variant?: "card" | "inline" className?: string } export function TicketCustomFieldsSection({ ticket, variant = "card", className }: TicketCustomFieldsSectionProps) { const { convexUserId, role } = useAuth() const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent")) const calendarTimeZone = useLocalTimeZone() const viewerId = convexUserId as Id<"users"> | null const tenantId = ticket.tenantId const ticketCompanyId = ticket.company?.id ?? null 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, companyId: ticketCompanyId ? (ticketCompanyId as Id<"companies">) : undefined } : "skip" ) as TicketFormDefinition[] | undefined const availableForms = useMemo(() => { if (!formsRemote || formsRemote.length === 0) { return [DEFAULT_FORM] } return formsRemote }, [formsRemote]) const selectedForm = useMemo(() => { 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>({}) const [openCalendarField, setOpenCalendarField] = useState(null) const [isSaving, setIsSaving] = useState(false) const [validationError, setValidationError] = useState(null) const [currentFields, setCurrentFields] = useState(ticket.customFields) useEffect(() => { setCurrentFields(ticket.customFields) }, [ticket.customFields]) const initialValues = useMemo( () => buildInitialValues(selectedForm.fields, currentFields), [selectedForm.fields, currentFields] ) 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: "value" in normalized ? normalized.value : null, }) } setIsSaving(true) setValidationError(null) try { const result = await updateCustomFields({ ticketId: ticket.id as Id<"tickets">, actorId: viewerId, fields: payload, }) setCurrentFields(result?.customFields ?? currentFields) 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 hasConfiguredFields = selectedForm.fields.length > 0 const editActionSlot = canEdit && hasConfiguredFields ? ( ) : null const dialog = ( Editar campos personalizados Atualize os campos adicionais deste chamado. Os campos obrigatórios devem ser preenchidos. {hasConfiguredFields ? (

Informações adicionais

{selectedForm.fields.map((field) => renderFieldEditor(field))}
) : (

Nenhum campo configurado ainda.

)} {validationError ?

{validationError}

: null}
) const listClassName = cn(variant === "inline" ? "sm:col-span-2 lg:col-span-3" : "", className) if (variant === "inline") { return ( <> {dialog} ) } return (

Informações adicionais

{dialog}
) function renderFieldEditor(field: TicketFormFieldDefinition) { 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 = field.type === "boolean" || field.type === "date" || isTextarea ? "sm:col-span-2" : "" const helpText = field.description ? (

{field.description}

) : null if (field.type === "boolean") { const isIndeterminate = value === null || value === undefined return (
{ if (!element) return element.indeterminate = isIndeterminate }} onChange={(event) => handleFieldChange(field, event.target.checked)} />
{helpText} {!field.required ? ( ) : null}
) } if (field.type === "select") { return ( {field.label} {field.required ? * : null} {helpText} ) } if (field.type === "number") { return ( {field.label} {field.required ? * : null} handleFieldChange(field, event.target.value)} placeholder="0" className="rounded-lg border border-slate-300" /> {helpText} ) } if (field.type === "date") { const isoValue = toIsoDateString(value) const parsedDate = isoValue ? parseIsoDate(isoValue) : null return ( {field.label} {field.required ? * : null} setOpenCalendarField(open ? field.id : null)} > { handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "") setOpenCalendarField(null) }} timeZone={calendarTimeZone} /> {helpText} ) } if (field.type === "text" && !isTextarea) { return ( {field.label} {field.required ? * : null} handleFieldChange(field, event.target.value)} className="rounded-lg border border-slate-300" /> {helpText} ) } return ( {field.label} {field.required ? * : null}