diff --git a/convex/tickets.ts b/convex/tickets.ts index cb73360..4c7ad2f 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -3516,6 +3516,74 @@ export const updateSummary = mutation({ }, }); +export const updateCustomFields = mutation({ + args: { + ticketId: v.id("tickets"), + actorId: v.id("users"), + fields: v.array( + v.object({ + fieldId: v.id("ticketFields"), + value: v.any(), + }) + ), + }, + handler: async (ctx, { ticketId, actorId, fields }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) + const normalizedRole = (viewer.role ?? "").toUpperCase() + if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") { + throw new ConvexError("Somente administradores e agentes podem editar campos personalizados.") + } + + const sanitizedInputs: CustomFieldInput[] = fields + .filter((entry) => entry.value !== undefined) + .map((entry) => ({ + fieldId: entry.fieldId, + value: entry.value, + })) + + const normalized = await normalizeCustomFieldValues( + ctx, + ticketDoc.tenantId, + sanitizedInputs, + ticketDoc.formTemplate ?? null + ) + + const now = Date.now() + + await ctx.db.patch(ticketId, { + customFields: normalized.length > 0 ? normalized : undefined, + updatedAt: now, + }) + + await ctx.db.insert("ticketEvents", { + ticketId, + type: "CUSTOM_FIELDS_UPDATED", + payload: { + actorId, + actorName: viewer.user.name, + actorAvatar: viewer.user.avatarUrl ?? undefined, + fields: normalized.map((field) => ({ + fieldId: field.fieldId, + fieldKey: field.fieldKey, + label: field.label, + type: field.type, + })), + }, + createdAt: now, + }) + + return { + customFields: mapCustomFieldsToRecord(normalized), + updatedAt: now, + } + }, +}) + export const playNext = mutation({ args: { tenantId: v.string(), diff --git a/src/app/api/dashboards/[id]/export/[format]/route.ts b/src/app/api/dashboards/[id]/export/[format]/route.ts index b668288..6ddf660 100644 --- a/src/app/api/dashboards/[id]/export/[format]/route.ts +++ b/src/app/api/dashboards/[id]/export/[format]/route.ts @@ -147,8 +147,9 @@ export async function GET( }, }) const bytes = pdfBuffer instanceof Uint8Array ? pdfBuffer : new Uint8Array(pdfBuffer) + const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) const filename = slugifyFilename(detail.dashboard.name ?? "dashboard", "pdf") - return new NextResponse(bytes, { + return new NextResponse(arrayBuffer, { status: 200, headers: { "Content-Type": "application/pdf", @@ -160,8 +161,9 @@ export async function GET( const screenshot = await page.screenshot({ type: "png", fullPage: true }) const bytes = screenshot instanceof Uint8Array ? screenshot : new Uint8Array(screenshot) + const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) const filename = slugifyFilename(detail.dashboard.name ?? "dashboard", "png") - return new NextResponse(bytes, { + return new NextResponse(arrayBuffer, { status: 200, headers: { "Content-Type": "image/png", diff --git a/src/components/admin/devices/admin-devices-overview.tsx b/src/components/admin/devices/admin-devices-overview.tsx index e3a6696..88f2415 100644 --- a/src/components/admin/devices/admin-devices-overview.tsx +++ b/src/components/admin/devices/admin-devices-overview.tsx @@ -68,6 +68,7 @@ import { useAuth } from "@/lib/auth-client" import type { Id } from "@/convex/_generated/dataModel" import { TicketStatusBadge } from "@/components/tickets/status-badge" import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket" +import { DeviceCustomFieldManager } from "@/components/admin/devices/device-custom-field-manager" type DeviceMetrics = Record | null @@ -2413,6 +2414,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { const { role: viewerRole } = useAuth() const normalizedViewerRole = (viewerRole ?? "").toLowerCase() const canManageRemoteAccess = normalizedViewerRole === "admin" || normalizedViewerRole === "agent" + const canManageFieldCatalog = normalizedViewerRole === "admin" const effectiveStatus = device ? resolveDeviceStatus(device) : "unknown" const [isActiveLocal, setIsActiveLocal] = useState(device?.isActive ?? true) const isDeactivated = !isActiveLocal || effectiveStatus === "deactivated" @@ -3430,12 +3432,17 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { .filter((entry) => entry.value !== undefined) as Array<{ fieldId: Id<"deviceFields">; value: unknown }> await saveCustomFields({ tenantId: device.tenantId, actorId: convexUserId as Id<"users">, machineId: device.id as Id<"machines">, fields }) toast.success("Campos salvos com sucesso.") + try { + router.refresh() + } catch { + // ignore refresh errors (e.g., when not in a routed context) + } setCustomFieldsEditorOpen(false) } catch (error) { console.error(error) toast.error("Não foi possível salvar os campos.") } - }, [device, convexUserId, editableFields, customFieldValues, saveCustomFields]) + }, [device, convexUserId, editableFields, customFieldValues, saveCustomFields, router]) const [newFieldOpen, setNewFieldOpen] = useState(false) const [newFieldLabel, setNewFieldLabel] = useState("") @@ -3458,6 +3465,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { required: false, options: (newFieldType === "select" || newFieldType === "multiselect") ? newFieldOptions : undefined, scope: (device.deviceType ?? "all") as string, + companyId: device.companyId ? (device.companyId as Id<"companies">) : undefined, }) toast.success("Campo criado") setNewFieldLabel("") @@ -3558,6 +3566,13 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { Editar + {canManageFieldCatalog && device ? ( + + ) : null} {device.customFields && device.customFields.length > 0 ? ( diff --git a/src/components/admin/devices/device-custom-field-manager.tsx b/src/components/admin/devices/device-custom-field-manager.tsx new file mode 100644 index 0000000..c89a65e --- /dev/null +++ b/src/components/admin/devices/device-custom-field-manager.tsx @@ -0,0 +1,484 @@ +"use client" + +import { useMemo, useState } from "react" +import { useMutation, useQuery } from "convex/react" +import { toast } from "sonner" +import { Plus, Trash2 } from "lucide-react" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Checkbox } from "@/components/ui/checkbox" +import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" + +type DeviceFieldDefinition = { + id: string + key: string + label: string + description?: string + type: string + required: boolean + options?: Array<{ value: string; label: string }> + scope: string + companyId: string | null +} + +const DEVICE_SCOPE_OPTIONS: Array<{ value: string; label: string }> = [ + { value: "all", label: "Todos os dispositivos" }, + { value: "desktop", label: "Somente desktops" }, + { value: "mobile", label: "Somente celulares" }, + { value: "tablet", label: "Somente tablets" }, +] + +type DeviceCustomFieldManagerProps = { + tenantId: string + defaultScope?: string | null + defaultCompanyId?: string | null + className?: string +} + +export function DeviceCustomFieldManager({ + tenantId, + defaultScope, + defaultCompanyId, + className, +}: DeviceCustomFieldManagerProps) { + const { convexUserId, role } = useAuth() + const isAdmin = (role ?? "").toLowerCase() === "admin" + const viewerId = convexUserId ? (convexUserId as Id<"users">) : null + const [open, setOpen] = useState(false) + const [confirmDeleteId, setConfirmDeleteId] = useState(null) + + const fields = useQuery( + api.deviceFields.list, + open && isAdmin && viewerId + ? { + tenantId, + viewerId, + scope: undefined, + companyId: undefined, + } + : "skip" + ) as DeviceFieldDefinition[] | undefined + + const companies = useQuery( + api.companies.list, + open && isAdmin && viewerId + ? { tenantId, viewerId } + : "skip" + ) as Array<{ id: string; name: string; slug?: string }> | undefined + + const createField = useMutation(api.deviceFields.create) + const removeField = useMutation(api.deviceFields.remove) + + const companyOptions = useMemo(() => { + if (!companies) return [] + return companies + .map((company) => ({ + value: company.id, + label: company.name, + description: company.slug ?? undefined, + keywords: company.slug ? [company.slug] : [], + })) + .sort((a, b) => a.label.localeCompare(b.label, "pt-BR")) + }, [companies]) + + const companyLabelById = useMemo(() => { + const map = new Map() + companyOptions.forEach((option) => map.set(option.value, option.label)) + return map + }, [companyOptions]) + + const [label, setLabel] = useState("") + const [description, setDescription] = useState("") + const [type, setType] = useState("text") + const [required, setRequired] = useState(false) + const [scope, setScope] = useState(defaultScope ?? "all") + const [companySelection, setCompanySelection] = useState(defaultCompanyId ?? "all") + const [options, setOptions] = useState>([]) + const [isSubmitting, setIsSubmitting] = useState(false) + + const resetForm = () => { + setLabel("") + setDescription("") + setType("text") + setRequired(false) + setScope(defaultScope ?? "all") + setCompanySelection(defaultCompanyId ?? "all") + setOptions([]) + } + + const handleCreate = async () => { + if (!viewerId) return + const trimmedLabel = label.trim() + if (trimmedLabel.length < 2) { + toast.error("Informe um rótulo para o campo.") + return + } + const normalizedOptions = + type === "select" || type === "multiselect" + ? options + .map((option) => ({ + label: option.label.trim(), + value: option.value.trim(), + })) + .filter((option) => option.label.length > 0 && option.value.length > 0) + : undefined + if ((type === "select" || type === "multiselect") && (!normalizedOptions || normalizedOptions.length === 0)) { + toast.error("Adicione ao menos uma opção para o campo de seleção.") + return + } + setIsSubmitting(true) + try { + await createField({ + tenantId, + actorId: viewerId, + label: trimmedLabel, + description: description.trim() || undefined, + type, + required, + options: normalizedOptions, + scope: scope === "all" ? undefined : scope, + companyId: companySelection === "all" ? undefined : (companySelection as Id<"companies">), + }) + toast.success("Campo criado com sucesso.") + resetForm() + } catch (error) { + console.error(error) + toast.error("Não foi possível criar o campo personalizado.") + } finally { + setIsSubmitting(false) + } + } + + const handleDelete = async (fieldId: string) => { + if (!viewerId) return + setConfirmDeleteId(null) + try { + await removeField({ + tenantId, + actorId: viewerId, + fieldId: fieldId as Id<"deviceFields">, + }) + toast.success("Campo removido com sucesso.") + } catch (error) { + console.error(error) + toast.error("Não foi possível remover o campo.") + } + } + + if (!isAdmin) { + return null + } + + const triggerButton = ( + + ) + + return ( + <> + {triggerButton} + { + setOpen(value) + if (!value) { + resetForm() + } + }}> + + + Campos personalizados de dispositivos + + Cadastre e organize campos adicionais que podem ser preenchidos pelos dispositivos monitorados. + Você pode restringir um campo a um tipo de dispositivo específico ou a uma empresa selecionada. + + + +
+
+
+

Campos cadastrados

+ + {fields?.length ?? 0} + +
+ +
+ {fields && fields.length > 0 ? ( + fields.map((field) => ( +
+
+
+

{field.label}

+

Chave: {field.key}

+
+ +
+
+ + Tipo: {translateType(field.type)} + + + Escopo: {translateScope(field.scope)} + + + Empresa:{" "} + + {field.companyId ? companyLabelById.get(field.companyId) ?? "Empresa específica" : "Todas"} + + + {field.required ? ( + + Obrigatório + + ) : null} +
+ {field.description ? ( +

{field.description}

+ ) : null} + {(field.options ?? []).length > 0 ? ( +
+

Opções:

+
+ {field.options!.map((option) => ( + + {option.label} ({option.value}) + + ))} +
+
+ ) : null} +
+ )) + ) : ( +
+ Nenhum campo cadastrado ainda. +
+ )} +
+
+
+ +
+
+

Novo campo

+

+ Informe o rótulo, tipo e demais características do campo personalizado. +

+
+
+
+ + setLabel(event.target.value)} placeholder="Ex.: Patrimônio" /> +
+
+ + setDescription(event.target.value)} + placeholder="Texto auxiliar exibido aos agentes" + /> +
+
+
+ + +
+
+ + +
+
+
+ + setCompanySelection(value)} + options={[ + { value: "all", label: "Todas as empresas" }, + ...companyOptions, + ]} + searchPlaceholder="Pesquisar empresa..." + emptyMessage="Nenhuma empresa encontrada." + /> +
+ + {(type === "select" || type === "multiselect") ? ( +
+

+ Opções de seleção +

+
+ {options.map((option, index) => ( +
+ + setOptions((previous) => { + const next = [...previous] + next[index] = { ...next[index], label: event.target.value } + return next + }) + } + /> + + setOptions((previous) => { + const next = [...previous] + next[index] = { ...next[index], value: event.target.value } + return next + }) + } + /> + +
+ ))} +
+
+ +
+
+ ) : null} +
+ + + + +
+
+
+
+ + { + if (!value) setConfirmDeleteId(null) + }}> + + + Remover campo personalizado + + Esta ação não pode ser desfeita. Confirme para remover o campo selecionado. Os dispositivos deixarão de exibir este dado imediatamente. + + + + + + + + + + ) +} + +function translateType(type: string) { + switch (type) { + case "text": + return "Texto" + case "number": + return "Número" + case "date": + return "Data" + case "boolean": + return "Verdadeiro/Falso" + case "select": + return "Seleção única" + case "multiselect": + return "Seleção múltipla" + default: + return type + } +} + +function translateScope(scope: string) { + const normalized = (scope ?? "all").toLowerCase() + const matched = DEVICE_SCOPE_OPTIONS.find((option) => option.value === normalized) + return matched?.label ?? "Todos os dispositivos" +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 4412c41..3cb6532 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -56,6 +56,7 @@ type NavigationItem = { requiredRole?: NavRoleRequirement exact?: boolean children?: NavigationItem[] + hidden?: boolean } type NavigationGroup = { @@ -104,7 +105,7 @@ const navigation: NavigationGroup[] = [ exact: true, }, { title: "Filas", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" }, - { title: "Times & papéis", url: "/admin/teams", icon: UserCog, requiredRole: "admin" }, + { title: "Times & papéis", url: "/admin/teams", icon: UserCog, requiredRole: "admin", hidden: true }, { title: "Empresas", url: "/admin/companies", @@ -228,7 +229,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {navigation.map((group) => { if (!canAccess(group.requiredRole)) return null - const visibleItems = group.items.filter((item) => canAccess(item.requiredRole)) + const visibleItems = group.items.filter((item) => !item.hidden && canAccess(item.requiredRole)) if (visibleItems.length === 0) return null return ( @@ -237,7 +238,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {visibleItems.map((item) => { if (item.children && item.children.length > 0) { - const childItems = item.children.filter((child) => canAccess(child.requiredRole)) + const childItems = item.children.filter((child) => !child.hidden && canAccess(child.requiredRole)) const isExpanded = expanded.has(item.title) const isChildActive = childItems.some((child) => isActive(child)) const parentActive = isActive(item) || isChildActive diff --git a/src/components/portal/portal-ticket-detail.tsx b/src/components/portal/portal-ticket-detail.tsx index 5736fce..8e03ab0 100644 --- a/src/components/portal/portal-ticket-detail.tsx +++ b/src/components/portal/portal-ticket-detail.tsx @@ -25,6 +25,8 @@ import { sanitizeEditorHtml, RichTextEditor } from "@/components/ui/rich-text-ed import { TicketStatusBadge } from "@/components/tickets/status-badge" import { Spinner } from "@/components/ui/spinner" import { TicketCsatCard } from "@/components/tickets/ticket-csat-card" +import { TicketCustomFieldsList } from "@/components/tickets/ticket-custom-fields" +import { mapTicketCustomFields } from "@/lib/ticket-custom-fields" const priorityLabel: Record = { LOW: "Baixa", @@ -189,11 +191,33 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { } } + if (event.type === "CUSTOM_FIELDS_UPDATED") { + const fields = Array.isArray((payload as { fields?: unknown }).fields) + ? ((payload as { fields?: Array<{ label?: string }> }).fields ?? []) + : [] + const labels = fields + .map((field) => (typeof field?.label === "string" ? field.label.trim() : "")) + .filter((label) => label.length > 0) + const description = + labels.length > 0 ? `Campos atualizados: ${labels.join(", ")}` : "Campos personalizados atualizados" + return { + id: event.id, + title: "Campos personalizados", + description, + when: event.createdAt, + } + } + return null }) .filter((entry): entry is ClientTimelineEntry => entry !== null) .sort((a, b) => b.when.getTime() - a.when.getTime()) -}, [ticket]) + }, [ticket]) + + const customFieldEntries = useMemo( + () => (ticket ? mapTicketCustomFields(ticket.customFields) : []), + [ticket] + ) if (ticketRaw === undefined) { return ( @@ -316,6 +340,12 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { ) : null} + {customFieldEntries.length > 0 ? ( +
+

Informações adicionais

+ +
+ ) : null} @@ -698,6 +728,3 @@ function PortalCommentAttachmentCard({ ) } - - - diff --git a/src/components/tickets/ticket-custom-fields.tsx b/src/components/tickets/ticket-custom-fields.tsx new file mode 100644 index 0000000..9e11a7b --- /dev/null +++ b/src/components/tickets/ticket-custom-fields.tsx @@ -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 { + 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": + 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 ( +

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

+ ) + } + + return ( +
+ {entries.map((entry) => ( +
+

{entry.label}

+

{entry.formattedValue}

+
+ ))} +
+ ) +} + +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(() => { + 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 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 ( +
+
+

Informações adicionais

+ {canEdit && hasConfiguredFields ? ( + + ) : null} +
+ + + + + + Editar campos personalizados + + Atualize os campos adicionais deste chamado. Os campos obrigatórios devem ser preenchidos. + + + + {hasConfiguredFields ? ( +
+ {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 ? ( +

{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)} + /> + {helpText} + + ) + } + + if (field.type === "date") { + const parsedDate = + typeof value === "string" && value + ? parseISO(value) + : undefined + const isValidDate = Boolean(parsedDate && !Number.isNaN(parsedDate.getTime())) + return ( + + + {field.label} {field.required ? * : null} + + setOpenCalendarField(open ? field.id : null)} + > + + + + + { + 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} + /> + + + {!field.required ? ( + + ) : null} + {helpText} + + ) + } + + if (isTextarea) { + return ( + + + {field.label} {field.required ? * : null} + +