feat: custom fields improvements
This commit is contained in:
parent
9495b54a28
commit
0f0f367b3a
11 changed files with 1290 additions and 12 deletions
|
|
@ -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<string, unknown> | 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<boolean>(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) {
|
|||
<Pencil className="size-4" />
|
||||
Editar
|
||||
</Button>
|
||||
{canManageFieldCatalog && device ? (
|
||||
<DeviceCustomFieldManager
|
||||
tenantId={device.tenantId}
|
||||
defaultScope={device.deviceType ?? "all"}
|
||||
defaultCompanyId={device.companyId ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{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"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue