feat: custom fields improvements
This commit is contained in:
parent
9495b54a28
commit
0f0f367b3a
11 changed files with 1290 additions and 12 deletions
|
|
@ -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({
|
export const playNext = mutation({
|
||||||
args: {
|
args: {
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
|
|
||||||
|
|
@ -147,8 +147,9 @@ export async function GET(
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const bytes = pdfBuffer instanceof Uint8Array ? pdfBuffer : new Uint8Array(pdfBuffer)
|
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")
|
const filename = slugifyFilename(detail.dashboard.name ?? "dashboard", "pdf")
|
||||||
return new NextResponse(bytes, {
|
return new NextResponse(arrayBuffer, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/pdf",
|
"Content-Type": "application/pdf",
|
||||||
|
|
@ -160,8 +161,9 @@ export async function GET(
|
||||||
|
|
||||||
const screenshot = await page.screenshot({ type: "png", fullPage: true })
|
const screenshot = await page.screenshot({ type: "png", fullPage: true })
|
||||||
const bytes = screenshot instanceof Uint8Array ? screenshot : new Uint8Array(screenshot)
|
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")
|
const filename = slugifyFilename(detail.dashboard.name ?? "dashboard", "png")
|
||||||
return new NextResponse(bytes, {
|
return new NextResponse(arrayBuffer, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "image/png",
|
"Content-Type": "image/png",
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ import { useAuth } from "@/lib/auth-client"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||||
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
|
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
|
||||||
|
import { DeviceCustomFieldManager } from "@/components/admin/devices/device-custom-field-manager"
|
||||||
|
|
||||||
type DeviceMetrics = Record<string, unknown> | null
|
type DeviceMetrics = Record<string, unknown> | null
|
||||||
|
|
||||||
|
|
@ -2413,6 +2414,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
const { role: viewerRole } = useAuth()
|
const { role: viewerRole } = useAuth()
|
||||||
const normalizedViewerRole = (viewerRole ?? "").toLowerCase()
|
const normalizedViewerRole = (viewerRole ?? "").toLowerCase()
|
||||||
const canManageRemoteAccess = normalizedViewerRole === "admin" || normalizedViewerRole === "agent"
|
const canManageRemoteAccess = normalizedViewerRole === "admin" || normalizedViewerRole === "agent"
|
||||||
|
const canManageFieldCatalog = normalizedViewerRole === "admin"
|
||||||
const effectiveStatus = device ? resolveDeviceStatus(device) : "unknown"
|
const effectiveStatus = device ? resolveDeviceStatus(device) : "unknown"
|
||||||
const [isActiveLocal, setIsActiveLocal] = useState<boolean>(device?.isActive ?? true)
|
const [isActiveLocal, setIsActiveLocal] = useState<boolean>(device?.isActive ?? true)
|
||||||
const isDeactivated = !isActiveLocal || effectiveStatus === "deactivated"
|
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 }>
|
.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 })
|
await saveCustomFields({ tenantId: device.tenantId, actorId: convexUserId as Id<"users">, machineId: device.id as Id<"machines">, fields })
|
||||||
toast.success("Campos salvos com sucesso.")
|
toast.success("Campos salvos com sucesso.")
|
||||||
|
try {
|
||||||
|
router.refresh()
|
||||||
|
} catch {
|
||||||
|
// ignore refresh errors (e.g., when not in a routed context)
|
||||||
|
}
|
||||||
setCustomFieldsEditorOpen(false)
|
setCustomFieldsEditorOpen(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
toast.error("Não foi possível salvar os campos.")
|
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 [newFieldOpen, setNewFieldOpen] = useState(false)
|
||||||
const [newFieldLabel, setNewFieldLabel] = useState("")
|
const [newFieldLabel, setNewFieldLabel] = useState("")
|
||||||
|
|
@ -3458,6 +3465,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
required: false,
|
required: false,
|
||||||
options: (newFieldType === "select" || newFieldType === "multiselect") ? newFieldOptions : undefined,
|
options: (newFieldType === "select" || newFieldType === "multiselect") ? newFieldOptions : undefined,
|
||||||
scope: (device.deviceType ?? "all") as string,
|
scope: (device.deviceType ?? "all") as string,
|
||||||
|
companyId: device.companyId ? (device.companyId as Id<"companies">) : undefined,
|
||||||
})
|
})
|
||||||
toast.success("Campo criado")
|
toast.success("Campo criado")
|
||||||
setNewFieldLabel("")
|
setNewFieldLabel("")
|
||||||
|
|
@ -3558,6 +3566,13 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
Editar
|
Editar
|
||||||
</Button>
|
</Button>
|
||||||
|
{canManageFieldCatalog && device ? (
|
||||||
|
<DeviceCustomFieldManager
|
||||||
|
tenantId={device.tenantId}
|
||||||
|
defaultScope={device.deviceType ?? "all"}
|
||||||
|
defaultCompanyId={device.companyId ?? null}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{device.customFields && device.customFields.length > 0 ? (
|
{device.customFields && device.customFields.length > 0 ? (
|
||||||
|
|
|
||||||
484
src/components/admin/devices/device-custom-field-manager.tsx
Normal file
484
src/components/admin/devices/device-custom-field-manager.tsx
Normal file
|
|
@ -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<string | null>(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<SearchableComboboxOption[]>(() => {
|
||||||
|
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<string, string>()
|
||||||
|
companyOptions.forEach((option) => map.set(option.value, option.label))
|
||||||
|
return map
|
||||||
|
}, [companyOptions])
|
||||||
|
|
||||||
|
const [label, setLabel] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [type, setType] = useState<string>("text")
|
||||||
|
const [required, setRequired] = useState(false)
|
||||||
|
const [scope, setScope] = useState<string>(defaultScope ?? "all")
|
||||||
|
const [companySelection, setCompanySelection] = useState<string>(defaultCompanyId ?? "all")
|
||||||
|
const [options, setOptions] = useState<Array<{ label: string; value: string }>>([])
|
||||||
|
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 = (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn("gap-2", className)}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Gerenciar campos
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{triggerButton}
|
||||||
|
<Dialog open={open} onOpenChange={(value) => {
|
||||||
|
setOpen(value)
|
||||||
|
if (!value) {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-3xl space-y-5">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Campos personalizados de dispositivos</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
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.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1.2fr_1fr]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900">Campos cadastrados</h3>
|
||||||
|
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold">
|
||||||
|
{fields?.length ?? 0}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="max-h-[320px] rounded-lg border border-slate-200">
|
||||||
|
<div className="divide-y divide-slate-200">
|
||||||
|
{fields && fields.length > 0 ? (
|
||||||
|
fields.map((field) => (
|
||||||
|
<div key={field.id} className="flex flex-col gap-2 p-3 text-sm text-neutral-700">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-neutral-900">{field.label}</p>
|
||||||
|
<p className="text-xs text-neutral-500">Chave: {field.key}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-neutral-500 hover:text-destructive"
|
||||||
|
onClick={() => setConfirmDeleteId(field.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5">
|
||||||
|
Tipo: <span className="font-medium text-neutral-800">{translateType(field.type)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5">
|
||||||
|
Escopo: <span className="font-medium text-neutral-800">{translateScope(field.scope)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5">
|
||||||
|
Empresa:{" "}
|
||||||
|
<span className="font-medium text-neutral-800">
|
||||||
|
{field.companyId ? companyLabelById.get(field.companyId) ?? "Empresa específica" : "Todas"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{field.required ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-amber-700">
|
||||||
|
Obrigatório
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{field.description ? (
|
||||||
|
<p className="text-xs text-neutral-500">{field.description}</p>
|
||||||
|
) : null}
|
||||||
|
{(field.options ?? []).length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium text-neutral-500">Opções:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{field.options!.map((option) => (
|
||||||
|
<span
|
||||||
|
key={option.value}
|
||||||
|
className="inline-flex items-center rounded-full border border-slate-200 px-2 py-0.5 text-[11px] text-neutral-600"
|
||||||
|
>
|
||||||
|
{option.label} <span className="ml-1 text-neutral-400">({option.value})</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-sm text-neutral-500">
|
||||||
|
Nenhum campo cadastrado ainda.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border border-slate-200 bg-white p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900">Novo campo</h3>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
Informe o rótulo, tipo e demais características do campo personalizado.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-neutral-600">Rótulo</label>
|
||||||
|
<Input value={label} onChange={(event) => setLabel(event.target.value)} placeholder="Ex.: Patrimônio" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-neutral-600">Descrição (opcional)</label>
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
placeholder="Texto auxiliar exibido aos agentes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-neutral-600">Tipo</label>
|
||||||
|
<Select value={type} onValueChange={(value) => setType(value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="text">Texto</SelectItem>
|
||||||
|
<SelectItem value="number">Número</SelectItem>
|
||||||
|
<SelectItem value="date">Data</SelectItem>
|
||||||
|
<SelectItem value="boolean">Verdadeiro/Falso</SelectItem>
|
||||||
|
<SelectItem value="select">Seleção única</SelectItem>
|
||||||
|
<SelectItem value="multiselect">Seleção múltipla</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-neutral-600">Escopo</label>
|
||||||
|
<Select value={scope} onValueChange={(value) => setScope(value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DEVICE_SCOPE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-neutral-600">Empresa</label>
|
||||||
|
<SearchableCombobox
|
||||||
|
value={companySelection}
|
||||||
|
onChange={(value) => setCompanySelection(value)}
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: "Todas as empresas" },
|
||||||
|
...companyOptions,
|
||||||
|
]}
|
||||||
|
searchPlaceholder="Pesquisar empresa..."
|
||||||
|
emptyMessage="Nenhuma empresa encontrada."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex items-center gap-2 text-xs font-medium text-neutral-600">
|
||||||
|
<Checkbox checked={required} onCheckedChange={(value) => setRequired(value === true)} />
|
||||||
|
Campo obrigatório
|
||||||
|
</label>
|
||||||
|
{(type === "select" || type === "multiselect") ? (
|
||||||
|
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||||
|
<p className="text-xs font-medium text-neutral-600">
|
||||||
|
Opções de seleção
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<div key={`option-${index}`} className="grid gap-2 sm:grid-cols-[1fr_160px_auto]">
|
||||||
|
<Input
|
||||||
|
value={option.label}
|
||||||
|
placeholder="Rótulo"
|
||||||
|
onChange={(event) =>
|
||||||
|
setOptions((previous) => {
|
||||||
|
const next = [...previous]
|
||||||
|
next[index] = { ...next[index], label: event.target.value }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={option.value}
|
||||||
|
placeholder="Valor"
|
||||||
|
onChange={(event) =>
|
||||||
|
setOptions((previous) => {
|
||||||
|
const next = [...previous]
|
||||||
|
next[index] = { ...next[index], value: event.target.value }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() =>
|
||||||
|
setOptions((previous) => previous.filter((_, optionIndex) => optionIndex !== index))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => setOptions((previous) => [...previous, { label: "", value: "" }])}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Adicionar opção
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button type="button" variant="outline" onClick={resetForm}>
|
||||||
|
Limpar
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleCreate} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Criando..." : "Criar campo"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={confirmDeleteId !== null} onOpenChange={(value) => {
|
||||||
|
if (!value) setConfirmDeleteId(null)
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-md space-y-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remover campo personalizado</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Esta ação não pode ser desfeita. Confirme para remover o campo selecionado. Os dispositivos deixarão de exibir este dado imediatamente.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setConfirmDeleteId(null)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirmDeleteId) {
|
||||||
|
void handleDelete(confirmDeleteId)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remover
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,7 @@ type NavigationItem = {
|
||||||
requiredRole?: NavRoleRequirement
|
requiredRole?: NavRoleRequirement
|
||||||
exact?: boolean
|
exact?: boolean
|
||||||
children?: NavigationItem[]
|
children?: NavigationItem[]
|
||||||
|
hidden?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type NavigationGroup = {
|
type NavigationGroup = {
|
||||||
|
|
@ -104,7 +105,7 @@ const navigation: NavigationGroup[] = [
|
||||||
exact: true,
|
exact: true,
|
||||||
},
|
},
|
||||||
{ title: "Filas", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
|
{ 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",
|
title: "Empresas",
|
||||||
url: "/admin/companies",
|
url: "/admin/companies",
|
||||||
|
|
@ -228,7 +229,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{navigation.map((group) => {
|
{navigation.map((group) => {
|
||||||
if (!canAccess(group.requiredRole)) return null
|
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
|
if (visibleItems.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<SidebarGroup key={group.title}>
|
<SidebarGroup key={group.title}>
|
||||||
|
|
@ -237,7 +238,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{visibleItems.map((item) => {
|
{visibleItems.map((item) => {
|
||||||
if (item.children && item.children.length > 0) {
|
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 isExpanded = expanded.has(item.title)
|
||||||
const isChildActive = childItems.some((child) => isActive(child))
|
const isChildActive = childItems.some((child) => isActive(child))
|
||||||
const parentActive = isActive(item) || isChildActive
|
const parentActive = isActive(item) || isChildActive
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ import { sanitizeEditorHtml, RichTextEditor } from "@/components/ui/rich-text-ed
|
||||||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card"
|
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<TicketWithDetails["priority"], string> = {
|
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
|
||||||
LOW: "Baixa",
|
LOW: "Baixa",
|
||||||
|
|
@ -189,12 +191,34 @@ 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
|
return null
|
||||||
})
|
})
|
||||||
.filter((entry): entry is ClientTimelineEntry => entry !== null)
|
.filter((entry): entry is ClientTimelineEntry => entry !== null)
|
||||||
.sort((a, b) => b.when.getTime() - a.when.getTime())
|
.sort((a, b) => b.when.getTime() - a.when.getTime())
|
||||||
}, [ticket])
|
}, [ticket])
|
||||||
|
|
||||||
|
const customFieldEntries = useMemo(
|
||||||
|
() => (ticket ? mapTicketCustomFields(ticket.customFields) : []),
|
||||||
|
[ticket]
|
||||||
|
)
|
||||||
|
|
||||||
if (ticketRaw === undefined) {
|
if (ticketRaw === undefined) {
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
|
@ -316,6 +340,12 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
) : null}
|
) : null}
|
||||||
<DetailItem label="Criado em" value={createdAt} />
|
<DetailItem label="Criado em" value={createdAt} />
|
||||||
<DetailItem label="Última atualização" value={updatedAgo} />
|
<DetailItem label="Última atualização" value={updatedAgo} />
|
||||||
|
{customFieldEntries.length > 0 ? (
|
||||||
|
<div className="sm:col-span-2 space-y-2 pt-2">
|
||||||
|
<p className="text-sm font-semibold text-neutral-900">Informações adicionais</p>
|
||||||
|
<TicketCustomFieldsList record={ticket.customFields} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -698,6 +728,3 @@ function PortalCommentAttachmentCard({
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
570
src/components/tickets/ticket-custom-fields.tsx
Normal file
570
src/components/tickets/ticket-custom-fields.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,11 @@ import { format } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
|
|
||||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
|
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 {
|
interface TicketDetailsPanelProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
|
|
@ -128,6 +129,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<TicketCustomFieldsSection ticket={ticket} />
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">SLA & métricas</h3>
|
<h3 className="text-sm font-semibold text-neutral-900">SLA & métricas</h3>
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,24 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
</div>
|
</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)) {
|
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
|
||||||
message = "Status alterado para " + (payload.toLabel || payload.to)
|
message = "Status alterado para " + (payload.toLabel || payload.to)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
89
src/lib/ticket-custom-fields.ts
Normal file
89
src/lib/ticket-custom-fields.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { format } from "date-fns"
|
||||||
|
import { ptBR } from "date-fns/locale"
|
||||||
|
|
||||||
|
export type TicketCustomFieldRecord = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label: string
|
||||||
|
type: string
|
||||||
|
value?: unknown
|
||||||
|
displayValue?: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export type TicketCustomFieldEntry = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: string
|
||||||
|
value: unknown
|
||||||
|
displayValue?: string
|
||||||
|
formattedValue: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBoolean(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "—"
|
||||||
|
return value === true || String(value).toLowerCase() === "true" ? "Sim" : "Não"
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "—"
|
||||||
|
const date =
|
||||||
|
typeof value === "number"
|
||||||
|
? Number.isFinite(value)
|
||||||
|
? new Date(value)
|
||||||
|
: null
|
||||||
|
: typeof value === "string" && value.trim().length > 0
|
||||||
|
? new Date(value)
|
||||||
|
: null
|
||||||
|
if (!date || Number.isNaN(date.getTime())) return "—"
|
||||||
|
return format(date, "dd/MM/yyyy", { locale: ptBR })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === "") return "—"
|
||||||
|
const numeric = typeof value === "number" ? value : Number(String(value).replace(",", "."))
|
||||||
|
if (!Number.isFinite(numeric)) return String(value)
|
||||||
|
return new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 2 }).format(numeric)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFallback(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "—"
|
||||||
|
if (typeof value === "string" && value.trim().length === 0) return "—"
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTicketCustomFieldValue(entry: {
|
||||||
|
type: string
|
||||||
|
value?: unknown
|
||||||
|
displayValue?: string
|
||||||
|
}): string {
|
||||||
|
if (entry.displayValue && entry.displayValue.trim().length > 0) {
|
||||||
|
return entry.displayValue
|
||||||
|
}
|
||||||
|
switch ((entry.type ?? "").toLowerCase()) {
|
||||||
|
case "boolean":
|
||||||
|
return formatBoolean(entry.value)
|
||||||
|
case "date":
|
||||||
|
return formatDate(entry.value)
|
||||||
|
case "number":
|
||||||
|
return formatNumber(entry.value)
|
||||||
|
default:
|
||||||
|
return formatFallback(entry.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapTicketCustomFields(
|
||||||
|
record: TicketCustomFieldRecord | undefined | null
|
||||||
|
): TicketCustomFieldEntry[] {
|
||||||
|
if (!record) return []
|
||||||
|
return Object.entries(record)
|
||||||
|
.map(([key, field]) => ({
|
||||||
|
key,
|
||||||
|
label: field.label,
|
||||||
|
type: field.type,
|
||||||
|
value: field.value ?? null,
|
||||||
|
displayValue: field.displayValue,
|
||||||
|
formattedValue: formatTicketCustomFieldValue(field),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
|
||||||
|
}
|
||||||
|
|
@ -18,4 +18,5 @@ export const TICKET_TIMELINE_LABELS: Record<string, string> = {
|
||||||
CSAT_RECEIVED: "CSAT recebido",
|
CSAT_RECEIVED: "CSAT recebido",
|
||||||
CSAT_RATED: "CSAT avaliado",
|
CSAT_RATED: "CSAT avaliado",
|
||||||
TICKET_LINKED: "Chamado vinculado",
|
TICKET_LINKED: "Chamado vinculado",
|
||||||
|
CUSTOM_FIELDS_UPDATED: "Campos personalizados atualizados",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue