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:
parent
06deb99bcd
commit
c2c5707a97
7 changed files with 299 additions and 14 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue