diff --git a/convex/deviceFields.ts b/convex/deviceFields.ts index d49aa60..b919f22 100644 --- a/convex/deviceFields.ts +++ b/convex/deviceFields.ts @@ -5,7 +5,7 @@ import type { Id } from "./_generated/dataModel" import { requireAdmin, requireUser } from "./rbac" -const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const +const FIELD_TYPES = ["text", "number", "select", "multiselect", "date", "boolean"] as const type FieldType = (typeof FIELD_TYPES)[number] type AnyCtx = MutationCtx | QueryCtx @@ -31,7 +31,7 @@ async function ensureUniqueKey(ctx: AnyCtx, tenantId: string, key: string, exclu } function validateOptions(type: FieldType, options: { value: string; label: string }[] | undefined) { - if (type === "select" && (!options || options.length === 0)) { + if ((type === "select" || type === "multiselect") && (!options || options.length === 0)) { throw new ConvexError("Campos de seleção precisam de pelo menos uma opção") } } diff --git a/convex/machines.ts b/convex/machines.ts index 972cbcb..3802358 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -138,6 +138,19 @@ function formatDeviceCustomFieldDisplay( const option = options?.find((opt) => opt.value === raw || opt.label === raw) return option?.label ?? raw } + case "multiselect": { + const arr = Array.isArray(value) + ? value + : typeof value === "string" + ? value.split(",").map((s) => s.trim()).filter(Boolean) + : [] + if (arr.length === 0) return null + const labels = arr.map((raw) => { + const opt = options?.find((o) => o.value === raw || o.label === raw) + return opt?.label ?? String(raw) + }) + return labels.join(", ") + } default: try { return JSON.stringify(value) diff --git a/convex/tickets.ts b/convex/tickets.ts index 53f835f..e494c65 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -1236,6 +1236,11 @@ export const list = query({ priority: t.priority, channel: t.channel, queue: queueName, + csatScore: typeof t.csatScore === "number" ? t.csatScore : null, + csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null, + csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null, + csatRatedAt: t.csatRatedAt ?? null, + csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null, formTemplate: t.formTemplate ?? null, company: company ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false } diff --git a/src/components/admin/devices/admin-devices-overview.tsx b/src/components/admin/devices/admin-devices-overview.tsx index 56c167b..a90c64f 100644 --- a/src/components/admin/devices/admin-devices-overview.tsx +++ b/src/components/admin/devices/admin-devices-overview.tsx @@ -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>({}) + const saveCustomFields = useMutation(api.devices.saveDeviceCustomFields) + const createDeviceField = useMutation(api.deviceFields.create) + + useEffect(() => { + const current: Record = {} + ;(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("text") + const [newFieldOptions, setNewFieldOptions] = useState>([]) + + 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 ( @@ -3446,8 +3517,19 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { Renomear dispositivo -

- {device.authEmail ?? "E-mail não definido"} +

+ {device.authEmail ?? "E-mail não definido"} + {device.authEmail ? ( + + ) : null}

@@ -3457,6 +3539,36 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { ))} + {/* Campos personalizados (posicionado logo após métricas) */} +
+
+
+

Campos personalizados

+ + {(device.customFields ?? []).length} + +
+
+ +
+
+ {device.customFields && device.customFields.length > 0 ? ( +
+ {(device.customFields ?? []).map((f, idx) => ( +
+

{f.label}

+

{(f.displayValue ?? f.value ?? "—") as string}

+
+ ))} +
+ ) : ( +

Nenhum campo personalizado definido para este dispositivo.

+ )} +
+
@@ -3519,13 +3631,8 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
) : null}
+
- {device.authEmail ? ( - - ) : null} +
+
+ {newFieldOpen ? ( +
+
+
+ + setNewFieldLabel(e.target.value)} placeholder="Ex.: Patrimônio" /> +
+
+ + +
+
+ {(newFieldType === "select" || newFieldType === "multiselect") ? ( +
+ +
+ {newFieldOptions.map((opt, idx) => ( +
+ setNewFieldOptions((prev) => prev.map((o, i) => i === idx ? { ...o, label: e.target.value } : o))} + /> + setNewFieldOptions((prev) => prev.map((o, i) => i === idx ? { ...o, value: e.target.value } : o))} + /> +
+ ))} +
+
+ + {newFieldOptions.length > 0 ? ( + + ) : null} +
+
+ ) : null} +
+ +
+
+ ) : null} + + {editableFields.length === 0 ? ( +
Nenhum campo disponível para este tipo de dispositivo.
+ ) : ( +
+ {editableFields.map((field) => { + const value = customFieldValues[field.id] ?? customFieldValues[field.key] ?? null + const setValue = (v: unknown) => setCustomFieldValues((prev) => ({ ...prev, [field.id]: v })) + return ( +
+ + {field.type === "text" ? ( + setValue(e.target.value)} /> + ) : field.type === "number" ? ( + setValue(e.target.value === "" ? null : Number(e.target.value))} /> + ) : field.type === "date" ? ( + setValue(e.target.value || null)} /> + ) : field.type === "boolean" ? ( + + ) : field.type === "select" ? ( + + ) : field.type === "multiselect" ? ( +
+ {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 ( + + ) + })} +
+ ) : null} +
+ ) + })} +
+ )} + + + + + + + + { if (!open) setDeleting(false); setDeleteDialog(open) }}> diff --git a/src/components/portal/portal-ticket-detail.tsx b/src/components/portal/portal-ticket-detail.tsx index 84c8ca4..756f8df 100644 --- a/src/components/portal/portal-ticket-detail.tsx +++ b/src/components/portal/portal-ticket-detail.tsx @@ -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 = { LOW: "Baixa", @@ -318,6 +319,11 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
+ {ticket ? ( +
+ +
+ ) : null}
@@ -692,4 +698,3 @@ function PortalCommentAttachmentCard({ - diff --git a/src/components/portal/portal-ticket-list.tsx b/src/components/portal/portal-ticket-list.tsx index 3ae286c..6bed983 100644 --- a/src/components/portal/portal-ticket-list.tsx +++ b/src/components/portal/portal-ticket-list.tsx @@ -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 (
@@ -92,6 +99,14 @@ export function PortalTicketList() {

Acompanhe seus tickets e veja as últimas atualizações.

+ {lastResolvedNoCsat ? ( +
+ Como foi seu último atendimento? Avalie o chamado #{lastResolvedNoCsat.reference}.{' '} + + Avaliar agora + +
+ ) : null}
{(tickets as Ticket[]).map((ticket) => ( diff --git a/src/components/tickets/ticket-csat-card.tsx b/src/components/tickets/ticket-csat-card.tsx index 6b4e91e..c1e2163 100644 --- a/src/components/tickets/ticket-csat-card.tsx +++ b/src/components/tickets/ticket-csat-card.tsx @@ -164,7 +164,7 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) { const stars = Array.from({ length: maxScore }, (_, index) => index + 1) return ( - +