feat(devices,custom-fields,csat,portal):\n- Editor de campos personalizados (inclui multiselect) e exibição no detalhe\n- Campos personalizados disponíveis nas colunas/templates de exportação\n- Move cópia de e-mail para ícone inline abaixo do nome do dispositivo\n- Portal: banner para avaliar último chamado e CSAT no detalhe\n- Tickets list inclui campos de CSAT para detectar pendências

This commit is contained in:
codex-bot 2025-11-04 14:12:21 -03:00
parent 06deb99bcd
commit c2c5707a97
7 changed files with 299 additions and 14 deletions

View file

@ -3317,8 +3317,10 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
const { convexUserId } = useAuth()
const deviceFieldDefs = useQuery(
api.deviceFields.listForTenant,
convexUserId && device ? { tenantId: device.tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: string; key: string; label: string }> | undefined
convexUserId && device
? { tenantId: device.tenantId, viewerId: convexUserId as Id<"users">, scope: (device.deviceType ?? "all") as string }
: "skip"
) as Array<{ id: string; key: string; label: string; type?: string; options?: Array<{ value: string; label: string }>; required?: boolean }> | undefined
const baseColumnOptionsSingle = useMemo(
() => DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({ key: meta.key, label: meta.label })),
@ -3398,6 +3400,75 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
}
}, [device, singleColumns, singleCustomOrder])
// Editor de campos personalizados
const [customFieldsEditorOpen, setCustomFieldsEditorOpen] = useState(false)
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
const saveCustomFields = useMutation(api.devices.saveDeviceCustomFields)
const createDeviceField = useMutation(api.deviceFields.create)
useEffect(() => {
const current: Record<string, unknown> = {}
;(device?.customFields ?? []).forEach((f) => {
current[String((f as { fieldId?: string }).fieldId ?? f.fieldKey)] = f.value ?? f.displayValue ?? null
})
setCustomFieldValues(current)
}, [device?.customFields])
const editableFields = useMemo(
() => (deviceFieldDefs ?? []).map((f) => ({ id: f.id, key: f.key, label: f.label, type: (f as any).type ?? "text", options: (f as any).options ?? [] })),
[deviceFieldDefs]
)
const handleSaveCustomFields = useCallback(async () => {
if (!device || !convexUserId) return
try {
const fields = editableFields
.map((def) => {
const value = customFieldValues[def.id] ?? customFieldValues[def.key]
return { fieldId: def.id as Id<"deviceFields">, value }
})
.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.")
setCustomFieldsEditorOpen(false)
} catch (error) {
console.error(error)
toast.error("Não foi possível salvar os campos.")
}
}, [device, convexUserId, editableFields, customFieldValues, saveCustomFields])
const [newFieldOpen, setNewFieldOpen] = useState(false)
const [newFieldLabel, setNewFieldLabel] = useState("")
const [newFieldType, setNewFieldType] = useState<string>("text")
const [newFieldOptions, setNewFieldOptions] = useState<Array<{ label: string; value: string }>>([])
const handleCreateNewField = useCallback(async () => {
if (!device || !convexUserId) return
const label = newFieldLabel.trim()
if (label.length < 2) {
toast.error("Informe o rótulo do campo")
return
}
try {
await createDeviceField({
tenantId: device.tenantId,
actorId: convexUserId as Id<"users">,
label,
type: newFieldType,
required: false,
options: (newFieldType === "select" || newFieldType === "multiselect") ? newFieldOptions : undefined,
scope: (device.deviceType ?? "all") as string,
})
toast.success("Campo criado")
setNewFieldLabel("")
setNewFieldOptions([])
setNewFieldOpen(false)
} catch (error) {
console.error(error)
toast.error("Não foi possível criar o campo")
}
}, [device, convexUserId, newFieldLabel, newFieldType, newFieldOptions, createDeviceField])
return (
<Card className="border-slate-200">
<CardHeader className="gap-1">
@ -3446,8 +3517,19 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
<span className="sr-only">Renomear dispositivo</span>
</Button>
</div>
<p className="text-xs text-muted-foreground">
{device.authEmail ?? "E-mail não definido"}
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{device.authEmail ?? "E-mail não definido"}</span>
{device.authEmail ? (
<button
type="button"
onClick={copyEmail}
className="inline-flex items-center rounded p-1 text-neutral-500 transition hover:bg-slate-100 hover:text-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300"
title="Copiar e-mail do dispositivo"
aria-label="Copiar e-mail do dispositivo"
>
<ClipboardCopy className="size-3.5" />
</button>
) : null}
</p>
</div>
</div>
@ -3457,6 +3539,36 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
<InfoChip key={chip.key} label={chip.label} value={chip.value} icon={chip.icon} tone={chip.tone} />
))}
</div>
{/* Campos personalizados (posicionado logo após métricas) */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-neutral-900">Campos personalizados</h4>
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold">
{(device.customFields ?? []).length}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="gap-2" onClick={() => setCustomFieldsEditorOpen(true)}>
<Pencil className="size-4" />
Editar
</Button>
</div>
</div>
{device.customFields && device.customFields.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
{(device.customFields ?? []).map((f, idx) => (
<div key={`${f.fieldKey}-${idx}`} className="rounded-lg border border-slate-200 bg-white p-3 text-sm">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{f.label}</p>
<p className="mt-1 text-neutral-800">{(f.displayValue ?? f.value ?? "—") as string}</p>
</div>
))}
</div>
) : (
<p className="text-xs text-neutral-500">Nenhum campo personalizado definido para este dispositivo.</p>
)}
</div>
<div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
@ -3519,13 +3631,8 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{device.authEmail ? (
<Button size="sm" variant="outline" onClick={copyEmail} className="gap-2 border-dashed">
<ClipboardCopy className="size-4" />
Copiar e-mail
</Button>
) : null}
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
<ShieldCheck className="size-4" />
Ajustar acesso
@ -5190,6 +5297,146 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</DialogContent>
</Dialog>
{/* Editor de campos personalizados */}
<Dialog open={customFieldsEditorOpen} onOpenChange={(open) => setCustomFieldsEditorOpen(open)}>
<DialogContent className="max-w-2xl space-y-4">
<DialogHeader>
<DialogTitle>Campos personalizados {device.displayName ?? device.hostname ?? "Dispositivo"}</DialogTitle>
<DialogDescription>Adicione e ajuste informações complementares deste dispositivo.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900">Valores</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setNewFieldOpen((v) => !v)}>
{newFieldOpen ? "Cancelar" : "Novo campo"}
</Button>
</div>
</div>
{newFieldOpen ? (
<div className="space-y-3 rounded-lg border border-slate-200 p-3">
<div className="grid gap-2 sm:grid-cols-[1fr_160px] sm:gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-slate-700">Rótulo</label>
<Input value={newFieldLabel} onChange={(e) => setNewFieldLabel(e.target.value)} placeholder="Ex.: Patrimônio" />
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-slate-700">Tipo</label>
<Select value={newFieldType} onValueChange={setNewFieldType}>
<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</SelectItem>
<SelectItem value="multiselect">Seleção múltipla</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{(newFieldType === "select" || newFieldType === "multiselect") ? (
<div className="space-y-2">
<label className="text-xs font-medium text-slate-700">Opções</label>
<div className="space-y-2">
{newFieldOptions.map((opt, idx) => (
<div key={idx} className="grid gap-2 sm:grid-cols-2">
<Input
placeholder="Rótulo"
value={opt.label}
onChange={(e) => setNewFieldOptions((prev) => prev.map((o, i) => i === idx ? { ...o, label: e.target.value } : o))}
/>
<Input
placeholder="Valor"
value={opt.value}
onChange={(e) => setNewFieldOptions((prev) => prev.map((o, i) => i === idx ? { ...o, value: e.target.value } : o))}
/>
</div>
))}
</div>
<div className="flex items-center gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => setNewFieldOptions((prev) => [...prev, { label: "", value: "" }])}>Adicionar opção</Button>
{newFieldOptions.length > 0 ? (
<Button type="button" size="sm" variant="ghost" onClick={() => setNewFieldOptions((prev) => prev.slice(0, -1))}>Remover última</Button>
) : null}
</div>
</div>
) : null}
<div className="flex justify-end gap-2">
<Button type="button" size="sm" onClick={handleCreateNewField}>Criar</Button>
</div>
</div>
) : null}
{editableFields.length === 0 ? (
<div className="rounded-md border border-dashed border-slate-200 p-4 text-sm text-muted-foreground">Nenhum campo disponível para este tipo de dispositivo.</div>
) : (
<div className="grid gap-3">
{editableFields.map((field) => {
const value = customFieldValues[field.id] ?? customFieldValues[field.key] ?? null
const setValue = (v: unknown) => setCustomFieldValues((prev) => ({ ...prev, [field.id]: v }))
return (
<div key={field.id} className="grid gap-1">
<label className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{field.label}</label>
{field.type === "text" ? (
<Input value={(value as string) ?? ""} onChange={(e) => setValue(e.target.value)} />
) : field.type === "number" ? (
<Input type="number" value={value == null ? "" : String(value)} onChange={(e) => setValue(e.target.value === "" ? null : Number(e.target.value))} />
) : field.type === "date" ? (
<Input type="date" value={value ? String(value).slice(0, 10) : ""} onChange={(e) => setValue(e.target.value || null)} />
) : field.type === "boolean" ? (
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
<Checkbox checked={Boolean(value)} onCheckedChange={(v) => setValue(Boolean(v))} />
<span>Ativo</span>
</label>
) : field.type === "select" ? (
<Select value={value ? String(value) : undefined} onValueChange={(v) => setValue(v)}>
<SelectTrigger><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{field.options?.map((opt: { value: string; label: string }) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
) : field.type === "multiselect" ? (
<div className="grid gap-1 sm:grid-cols-2">
{field.options?.map((opt: { value: string; label: string }) => {
const arr = Array.isArray(value) ? (value as unknown[]) : []
const checked = arr.some((v) => String(v) === opt.value)
return (
<label key={opt.value} className="flex items-center gap-2 text-sm text-slate-700">
<Checkbox
checked={checked}
onCheckedChange={(c) => {
const base = Array.isArray(value) ? [...(value as unknown[])] : []
if (c) {
if (!base.some((v) => String(v) === opt.value)) base.push(opt.value)
} else {
const idx = base.findIndex((v) => String(v) === opt.value)
if (idx >= 0) base.splice(idx, 1)
}
setValue(base)
}}
/>
<span>{opt.label}</span>
</label>
)
})}
</div>
) : null}
</div>
)
})}
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCustomFieldsEditorOpen(false)}>Cancelar</Button>
<Button type="button" onClick={handleSaveCustomFields}>Salvar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteDialog} onOpenChange={(open) => { if (!open) setDeleting(false); setDeleteDialog(open) }}>
<DialogContent>
<DialogHeader>

View file

@ -24,6 +24,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { sanitizeEditorHtml, RichTextEditor } from "@/components/ui/rich-text-editor"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Spinner } from "@/components/ui/spinner"
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card"
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
LOW: "Baixa",
@ -318,6 +319,11 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
</CardContent>
</Card>
{ticket ? (
<div className="space-y-4">
<TicketCsatCard ticket={ticket} />
</div>
) : null}
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="flex flex-row items-center justify-between px-5 py-4">
@ -692,4 +698,3 @@ function PortalCommentAttachmentCard({

View file

@ -84,6 +84,13 @@ export function PortalTicketList() {
)
}
const lastResolvedNoCsat = useMemo(() => {
const resolved = (tickets as Ticket[])
.filter((t) => t.status === "RESOLVED" && (t.csatScore == null))
.sort((a, b) => (b.resolvedAt?.getTime?.() ?? 0) - (a.resolvedAt?.getTime?.() ?? 0))
return resolved[0] ?? null
}, [tickets])
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@ -92,6 +99,14 @@ export function PortalTicketList() {
<p className="text-sm text-neutral-600">Acompanhe seus tickets e veja as últimas atualizações.</p>
</div>
</div>
{lastResolvedNoCsat ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Como foi seu último atendimento? Avalie o chamado #{lastResolvedNoCsat.reference}.{' '}
<Link href={`/portal/tickets/${lastResolvedNoCsat.id}#csat`} className="font-semibold underline">
Avaliar agora
</Link>
</div>
) : null}
<div className="grid gap-4">
{(tickets as Ticket[]).map((ticket) => (
<PortalTicketCard key={ticket.id} ticket={ticket} />

View file

@ -164,7 +164,7 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
const stars = Array.from({ length: maxScore }, (_, index) => index + 1)
return (
<Card className="rounded-2xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-50 shadow-sm">
<Card id="csat" className="rounded-2xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-50 shadow-sm">
<CardHeader className="px-4 pt-5 pb-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">