feat: dispositivos e ajustes de csat e relatórios

This commit is contained in:
codex-bot 2025-11-03 19:29:50 -03:00
parent 25d2a9b062
commit e0ef66555d
86 changed files with 5811 additions and 992 deletions

View file

@ -68,13 +68,13 @@ Para fluxos detalhados de desenvolvimento — banco de dados local (SQLite/Prism
- `pnpm prisma migrate deploy` — aplica migrações ao banco SQLite local.
- `pnpm convex:dev` — roda o Convex em modo desenvolvimento, gerando tipos em `convex/_generated`.
## Transferir máquina entre colaboradores
## Transferir dispositivo entre colaboradores
Quando uma máquina trocar de responsável:
Quando uma dispositivo trocar de responsável:
1. Abra `Admin > Máquinas`, selecione o equipamento e clique em **Resetar agente**.
1. Abra `Admin > Dispositivos`, selecione o equipamento e clique em **Resetar agente**.
2. No equipamento, execute o reset local do agente (`rever-agent reset` ou reinstale o serviço) e reprovisione com o código da empresa.
3. Após o agente gerar um novo token, associe a máquina ao novo colaborador no painel.
3. Após o agente gerar um novo token, associe a dispositivo ao novo colaborador no painel.
Sem o reset de agente, o Convex reaproveita o token anterior e o inventário continua vinculado ao usuário antigo.
@ -98,7 +98,7 @@ Consulte `PROXIMOS_PASSOS.md` para acompanhar o backlog funcional e o progresso
<!-- ci: smoke test 3 -->
## Diagnóstico de sessão da máquina (Desktop)
## Diagnóstico de sessão da dispositivo (Desktop)
- Quando o portal for aberto via app desktop, use a página `https://seu-app/portal/debug` para validar cookies e contexto:
- `/api/auth/get-session` deve idealmente mostrar `user.role = "machine"` (em alguns ambientes WebView pode retornar `null`, o que não é bloqueante).

View file

@ -67,19 +67,19 @@ pnpm build
- `pnpm -C apps/desktop tauri dev` — desenvolvimento (porta 1420).
- `pnpm -C apps/desktop tauri build` — gera instaladores.
- **Fluxo do agente**:
1. Coleta perfil da máquina (hostname, OS, MAC, seriais, métricas).
1. Coleta perfil da dispositivo (hostname, OS, MAC, seriais, métricas).
2. Provisiona via `POST /api/machines/register` usando `MACHINE_PROVISIONING_SECRET`, informando perfil de acesso (Colaborador/Gestor) + dados do colaborador.
3. Envia heartbeats periódicos (`/api/machines/heartbeat`) com inventário básico + estendido (discos SMART, GPUs, serviços, softwares, CPU window).
4. Realiza handshake em `APP_URL/machines/handshake?token=...&redirect=...` para receber cookies Better Auth + sessão (colaborador → `/portal`, gestor → `/dashboard`).
5. Token persistido no cofre do SO (Keyring); store guarda apenas metadados.
6. Envio manual de inventário via botão (POST `/api/machines/inventory`).
7. Updates automáticos: plugin `@tauri-apps/plugin-updater` consulta `latest.json` publicado nos releases do GitHub.
- **Admin ▸ Máquinas**: permite ajustar perfil/email associado, visualizar inventário completo e remover máquina.
- **Admin ▸ Dispositivos**: permite ajustar perfil/email associado, visualizar inventário completo e remover dispositivo.
### Sessão "machine" no frontend
- Ao autenticar como máquina, o front chama `/api/machines/session`, popula `machineContext` (assignedUser*, persona) e deriva role/`viewerId`.
- Ao autenticar como dispositivo, o front chama `/api/machines/session`, popula `machineContext` (assignedUser*, persona) e deriva role/`viewerId`.
- Mesmo quando `get-session` é `null` na WebView, o portal utiliza `machineContext` para saber o colaborador/gestor logado.
- UI remove opção "Sair" no menu do usuário quando detecta sessão de máquina.
- UI remove opção "Sair" no menu do usuário quando detecta sessão de dispositivo.
- `/portal/debug` exibe JSON de `get-session` e `machines/session` (útil para diagnosticar cookies/bearer).
### Observações adicionais
@ -116,7 +116,7 @@ pnpm build
## Estado do portal / app web
- Autenticação Better Auth com `AuthGuard`.
- Sidebar inferior agrega avatar, link para `/settings` e logout (oculto em sessões de máquina).
- Sidebar inferior agrega avatar, link para `/settings` e logout (oculto em sessões de dispositivo).
- Formulários de ticket (novo/editar/comentários) usam editor rico + anexos; placeholders e validação PT-BR.
- Relatórios e painéis utilizam `AppShell` + `SiteHeader`.
- `usePersistentCompanyFilter` mantém filtro global de empresa em relatórios/admin.
@ -126,7 +126,7 @@ pnpm build
- Admin > Empresas: cadastro + “Cliente avulso?”, horas contratadas, vínculos de usuários.
- Admin > Usuários/Equipe:
- Abas separadas: "Equipe" (administradores e agentes) e "Usuários" (gestores e colaboradores).
- Multiseleção + ações em massa: excluir usuários, remover agentes de máquina e revogar convites pendentes.
- Multiseleção + ações em massa: excluir usuários, remover agentes de dispositivo e revogar convites pendentes.
- Filtros por papel, empresa e espaço (tenant) quando aplicável; busca unificada.
- Convites: campo "Espaço (ID interno)" removido da UI de geração.
- Admin > Usuários: vincular colaborador à empresa.
@ -137,7 +137,7 @@ pnpm build
- **Equipe interna** (`admin`, `agent`, `collaborator`): cria/acompanha tickets, comenta, altera status/fila, gera relatórios.
- **Gestores** (`manager`): visualizam tickets da empresa, comentam publicamente, acessam dashboards.
- **Colaboradores** (`collaborator`): portal (`/portal`), tickets próprios, comentários públicos, editor rico, anexos.
- **Sessão Máquina**: desktop registra heartbeat/inventário e redireciona colaborador/gestor ao portal apropriado com cookies válidos.
- **Sessão Dispositivo**: desktop registra heartbeat/inventário e redireciona colaborador/gestor ao portal apropriado com cookies válidos.
### Correções recentes
- Temporizador do ticket (atendimento em andamento): a UI passa a aplicar atualização otimista na abertura/pausa da sessão para que o tempo corrente não "salte" para minutos indevidos. O backend continua a fonte da verdade (total acumulado é reconciliado ao pausar).
@ -160,7 +160,7 @@ pnpm build
- CSAT: `/api/reports/csat.xlsx?...`
- SLA: `/api/reports/sla.xlsx?...`
- Horas: `/api/reports/hours-by-client.xlsx?...`
- Inventário de máquinas: `/api/reports/machines-inventory.xlsx?[companyId=...]`
- Inventário de dispositivos: `/api/reports/machines-inventory.xlsx?[companyId=...]`
- **Docs complementares**:
- `docs/DEV.md` — guia diário atualizado.
- `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog.

View file

@ -1,11 +1,11 @@
# Sistema de Chamados — App Desktop (Tauri)
Cliente desktop (Tauri v2 + Vite) que:
- Coleta perfil/métricas da máquina via comandos Rust.
- Registra a máquina com um código de provisionamento.
- Coleta perfil/métricas da dispositivo via comandos Rust.
- Registra a dispositivo com um código de provisionamento.
- Envia heartbeat periódico ao backend (`/api/machines/heartbeat`).
- Redireciona para a UI web do sistema após provisionamento.
- Armazena o token da máquina com segurança no cofre do SO (Keyring).
- Armazena o token da dispositivo com segurança no cofre do SO (Keyring).
- Exibe abas de Resumo, Inventário, Diagnóstico e Configurações; permite “Enviar inventário agora”.
## URLs e ambiente
@ -65,7 +65,7 @@ pnpm -C apps/desktop tauri build --bundles nsis
Consulte https://tauri.app/start/prerequisites/
## Fluxo (resumo)
1) Ao abrir, o app coleta o perfil da máquina e exibe um resumo.
1) Ao abrir, o app coleta o perfil da dispositivo e exibe um resumo.
2) Informe o “código de provisionamento” (chave definida no servidor) e confirme.
3) O servidor retorna um `machineToken`; o app salva e inicia o heartbeat.
4) O app abre `APP_URL/machines/handshake?token=...` no WebView para autenticar a sessão na UI.

View file

@ -159,7 +159,7 @@
## 5. Gerar chaves do updater Tauri
1. Em qualquer máquina com Node/pnpm (pode ser seu computador local):
1. Em qualquer dispositivo com Node/pnpm (pode ser seu computador local):
```bash
pnpm install
pnpm --filter appsdesktop tauri signer generate
@ -267,10 +267,10 @@
.\svc start
```
6. Confirme no GitHub que o runner aparece como `online`.
7. Mantenha a máquina ligada e conectada durante o período em que o workflow precisa rodar:
7. Mantenha a dispositivo ligada e conectada durante o período em que o workflow precisa rodar:
- Para releases desktop, o runner só precisa estar ligado enquanto o job `desktop_release` estiver em execução (crie a tag e aguarde o workflow terminar).
- Após a conclusão, você pode desligar o computador até a próxima release.
8. Observação importante: o runner Windows pode ser sua máquina pessoal. Garanta apenas que:
8. Observação importante: o runner Windows pode ser sua dispositivo pessoal. Garanta apenas que:
- Você confia no código que será executado (o runner processa os jobs do repositório).
- O serviço do runner esteja ativo enquanto o workflow rodar (caso desligue o PC, as releases ficam na fila).
- Há espaço em disco suficiente e nenhuma política corporativa bloqueando a instalação dos pré-requisitos.
@ -429,7 +429,7 @@
- Garanta que o certificado TLS usado pelo Nginx é renovado (p. ex. `certbot renew`).
4. Manter runners:
- VPS: monitore serviço `actions.runner.*`. Reinicie se necessário (`sudo ./svc.sh restart`).
- Windows: mantenha máquina ligada e atualizada. Se o serviço parar, abra `services.msc``GitHub Actions Runner` → Start.
- Windows: mantenha dispositivo ligada e atualizada. Se o serviço parar, abra `services.msc``GitHub Actions Runner` → Start.
---
@ -451,7 +451,7 @@
| Job `desktop_release` falha na etapa `tauri-action` | Toolchain incompleto no Windows | Reinstale Rust, WebView2 e componentes C++ do Visual Studio. |
| Artefatos não chegam à VPS | Caminho incorreto ou chave SSH inválida | Verifique `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY` e se a pasta `/var/www/updates` existe. |
| App não encontra update | URL ou chave pública divergente no `tauri.conf.json` | Confirme que `endpoints` bate com o domínio HTTPS e que `pubkey` é exatamente a chave pública gerada. |
| Runner aparece offline no GitHub | Serviço parado ou máquina desligada | VPS: `sudo ./svc.sh status`; Windows: abra `Services` e reinicie o `GitHub Actions Runner`. |
| Runner aparece offline no GitHub | Serviço parado ou dispositivo desligada | VPS: `sudo ./svc.sh status`; Windows: abra `Services` e reinicie o `GitHub Actions Runner`. |
---

View file

@ -13,7 +13,7 @@ use tokio::sync::Notify;
#[derive(thiserror::Error, Debug)]
pub enum AgentError {
#[error("Falha ao obter hostname da máquina")]
#[error("Falha ao obter hostname da dispositivo")]
Hostname,
#[error("Nenhum identificador de hardware disponível (MAC/serial)")]
MissingIdentifiers,

View file

@ -8,9 +8,9 @@ export function DeactivationScreen({ companyName }: { companyName?: string | nul
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold text-rose-700">
<ShieldAlert className="size-4" /> Acesso bloqueado
</span>
<h1 className="text-2xl font-semibold text-neutral-900">Máquina desativada</h1>
<h1 className="text-2xl font-semibold text-neutral-900">Dispositivo desativada</h1>
<p className="max-w-md text-sm text-neutral-600">
Esta máquina foi desativada temporariamente pelos administradores. Enquanto isso, o acesso ao portal e o
Esta dispositivo foi desativada temporariamente pelos administradores. Enquanto isso, o acesso ao portal e o
envio de informações ficam indisponíveis.
</p>
{companyName ? (

View file

@ -273,9 +273,9 @@ function App() {
const text = await res.text()
const msg = text.toLowerCase()
const isInvalid =
msg.includes("token de máquina inválido") ||
msg.includes("token de máquina revogado") ||
msg.includes("token de máquina expirado")
msg.includes("token de dispositivo inválido") ||
msg.includes("token de dispositivo revogado") ||
msg.includes("token de dispositivo expirado")
if (isInvalid) {
try {
await store.delete("token"); await store.delete("config"); await store.save()
@ -293,7 +293,7 @@ function App() {
} catch {}
} else {
// Não limpa token em falhas genéricas (ex.: rede); apenas informa
setError("Falha ao validar sessão da máquina. Tente novamente.")
setError("Falha ao validar sessão da dispositivo. Tente novamente.")
tokenVerifiedRef.current = true
setTokenValidationTick((tick) => tick + 1)
}
@ -443,12 +443,12 @@ function App() {
return
}
if (!validatedCompany) {
setError("Valide o código de provisionamento antes de registrar a máquina.")
setError("Valide o código de provisionamento antes de registrar a dispositivo.")
return
}
const normalizedEmail = collabEmail.trim().toLowerCase()
if (!normalizedEmail) {
setError("Informe o e-mail do colaborador vinculado a esta máquina.")
setError("Informe o e-mail do colaborador vinculado a esta dispositivo.")
return
}
if (!emailRegex.current.test(normalizedEmail)) {
@ -575,7 +575,7 @@ function App() {
setError(null)
}
if (!currentActive) {
setError("Esta máquina está desativada. Entre em contato com o suporte da Rever para reativar o acesso.")
setError("Esta dispositivo está desativada. Entre em contato com o suporte da Rever para reativar o acesso.")
setIsLaunchingSystem(false)
return
}
@ -590,7 +590,7 @@ function App() {
: ""
setIsMachineActive(false)
setIsLaunchingSystem(false)
setError(message.length > 0 ? message : "Esta máquina está desativada. Entre em contato com o suporte da Rever.")
setError(message.length > 0 ? message : "Esta dispositivo está desativada. Entre em contato com o suporte da Rever.")
return
}
// Se sessão falhar, tenta identificar token inválido/expirado
@ -604,9 +604,9 @@ function App() {
const text = await hb.text()
const low = text.toLowerCase()
const invalid =
low.includes("token de máquina inválido") ||
low.includes("token de máquina revogado") ||
low.includes("token de máquina expirado")
low.includes("token de dispositivo inválido") ||
low.includes("token de dispositivo revogado") ||
low.includes("token de dispositivo expirado")
if (invalid) {
// Força onboarding
await store?.delete("token"); await store?.delete("config"); await store?.save()
@ -616,7 +616,7 @@ function App() {
setConfig(null)
setStatus(null)
setIsMachineActive(true)
setError("Sessão expirada. Reprovisione a máquina para continuar.")
setError("Sessão expirada. Reprovisione a dispositivo para continuar.")
setIsLaunchingSystem(false)
const p = await invoke<MachineProfile>("collect_machine_profile")
setProfile(p)
@ -787,7 +787,7 @@ function App() {
{error ? <p className="mt-3 rounded-md bg-rose-50 p-2 text-sm text-rose-700">{error}</p> : null}
{!token ? (
<div className="mt-4 space-y-3">
<p className="text-sm text-slate-600">Informe os dados para registrar esta máquina.</p>
<p className="text-sm text-slate-600">Informe os dados para registrar esta dispositivo.</p>
<div className="grid gap-2">
<label className="text-sm font-medium">Código de provisionamento</label>
<div className="relative">
@ -822,7 +822,7 @@ function App() {
</p>
) : (
<p className="text-xs text-slate-500">
Informe o código único fornecido pela equipe para vincular esta máquina a uma empresa.
Informe o código único fornecido pela equipe para vincular esta dispositivo a uma empresa.
</p>
)}
</div>
@ -832,7 +832,7 @@ function App() {
<div className="space-y-1">
<span className="block text-sm font-semibold text-emerald-800">{validatedCompany.name}</span>
<span className="text-xs text-emerald-700/80">
Código reconhecido. Esta máquina será vinculada automaticamente à empresa informada.
Código reconhecido. Esta dispositivo será vinculada automaticamente à empresa informada.
</span>
</div>
</div>
@ -884,7 +884,7 @@ function App() {
</div>
) : null}
<div className="mt-2 flex gap-2">
<button disabled={busy || !validatedCompany || !isEmailValid || !collabName.trim() || provisioningCode.trim().length < 32} onClick={register} className="rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white hover:bg-black/90 disabled:opacity-60">Registrar máquina</button>
<button disabled={busy || !validatedCompany || !isEmailValid || !collabName.trim() || provisioningCode.trim().length < 32} onClick={register} className="rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white hover:bg-black/90 disabled:opacity-60">Registrar dispositivo</button>
</div>
</div>
) : (

View file

@ -15,6 +15,9 @@ import type * as categories from "../categories.js";
import type * as commentTemplates from "../commentTemplates.js";
import type * as companies from "../companies.js";
import type * as crons from "../crons.js";
import type * as deviceExportTemplates from "../deviceExportTemplates.js";
import type * as deviceFields from "../deviceFields.js";
import type * as devices from "../devices.js";
import type * as fields from "../fields.js";
import type * as files from "../files.js";
import type * as invites from "../invites.js";
@ -27,6 +30,7 @@ import type * as revision from "../revision.js";
import type * as seed from "../seed.js";
import type * as slas from "../slas.js";
import type * as teams from "../teams.js";
import type * as ticketFormSettings from "../ticketFormSettings.js";
import type * as tickets from "../tickets.js";
import type * as users from "../users.js";
@ -52,6 +56,9 @@ declare const fullApi: ApiFromModules<{
commentTemplates: typeof commentTemplates;
companies: typeof companies;
crons: typeof crons;
deviceExportTemplates: typeof deviceExportTemplates;
deviceFields: typeof deviceFields;
devices: typeof devices;
fields: typeof fields;
files: typeof files;
invites: typeof invites;
@ -64,6 +71,7 @@ declare const fullApi: ApiFromModules<{
seed: typeof seed;
slas: typeof slas;
teams: typeof teams;
ticketFormSettings: typeof ticketFormSettings;
tickets: typeof tickets;
users: typeof users;
}>;

View file

@ -0,0 +1,347 @@
import { mutation, query } from "./_generated/server"
import type { MutationCtx, QueryCtx } from "./_generated/server"
import { ConvexError, v } from "convex/values"
import type { Id } from "./_generated/dataModel"
import { requireAdmin, requireUser } from "./rbac"
type AnyCtx = MutationCtx | QueryCtx
function normalizeSlug(input: string) {
return input
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
}
async function ensureUniqueSlug(ctx: AnyCtx, tenantId: string, slug: string, excludeId?: Id<"deviceExportTemplates">) {
const existing = await ctx.db
.query("deviceExportTemplates")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first()
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe um template com este identificador")
}
}
async function unsetDefaults(
ctx: MutationCtx,
tenantId: string,
companyId: Id<"companies"> | undefined | null,
excludeId?: Id<"deviceExportTemplates">
) {
const templates = await ctx.db
.query("deviceExportTemplates")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
await Promise.all(
templates
.filter((tpl) => tpl._id !== excludeId)
.filter((tpl) => {
if (companyId) {
return tpl.companyId === companyId
}
return !tpl.companyId
})
.map((tpl) => ctx.db.patch(tpl._id, { isDefault: false }))
)
}
function normalizeColumns(columns: { key: string; label?: string | null }[]) {
return columns
.map((col) => ({
key: col.key.trim(),
label: col.label?.trim() || undefined,
}))
.filter((col) => col.key.length > 0)
}
export const list = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
companyId: v.optional(v.id("companies")),
includeInactive: v.optional(v.boolean()),
},
handler: async (ctx, { tenantId, viewerId, companyId, includeInactive }) => {
await requireAdmin(ctx, viewerId, tenantId)
const templates = await ctx.db
.query("deviceExportTemplates")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
return templates
.filter((tpl) => {
if (!includeInactive && tpl.isActive === false) return false
if (!companyId) return true
if (!tpl.companyId) return true
return tpl.companyId === companyId
})
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
.map((tpl) => ({
id: tpl._id,
slug: tpl.slug,
name: tpl.name,
description: tpl.description ?? "",
columns: tpl.columns ?? [],
filters: tpl.filters ?? null,
companyId: tpl.companyId ?? null,
isDefault: Boolean(tpl.isDefault),
isActive: tpl.isActive ?? true,
createdAt: tpl.createdAt,
updatedAt: tpl.updatedAt,
createdBy: tpl.createdBy ?? null,
updatedBy: tpl.updatedBy ?? null,
}))
},
})
export const listForTenant = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
companyId: v.optional(v.id("companies")),
},
handler: async (ctx, { tenantId, viewerId, companyId }) => {
await requireUser(ctx, viewerId, tenantId)
const templates = await ctx.db
.query("deviceExportTemplates")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
return templates
.filter((tpl) => tpl.isActive !== false)
.filter((tpl) => {
if (!companyId) return !tpl.companyId
return !tpl.companyId || tpl.companyId === companyId
})
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
.map((tpl) => ({
id: tpl._id,
slug: tpl.slug,
name: tpl.name,
description: tpl.description ?? "",
columns: tpl.columns ?? [],
filters: tpl.filters ?? null,
companyId: tpl.companyId ?? null,
isDefault: Boolean(tpl.isDefault),
isActive: tpl.isActive ?? true,
}))
},
})
export const getDefault = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
companyId: v.optional(v.id("companies")),
},
handler: async (ctx, { tenantId, viewerId, companyId }) => {
await requireUser(ctx, viewerId, tenantId)
const indexQuery = companyId
? ctx.db
.query("deviceExportTemplates")
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
: ctx.db.query("deviceExportTemplates").withIndex("by_tenant_default", (q) => q.eq("tenantId", tenantId).eq("isDefault", true))
const templates = await indexQuery.collect()
const candidate = templates.find((tpl) => tpl.isDefault) ?? null
if (candidate) {
return {
id: candidate._id,
slug: candidate.slug,
name: candidate.name,
description: candidate.description ?? "",
columns: candidate.columns ?? [],
filters: candidate.filters ?? null,
companyId: candidate.companyId ?? null,
isDefault: Boolean(candidate.isDefault),
isActive: candidate.isActive ?? true,
}
}
if (companyId) {
const globalDefault = await ctx.db
.query("deviceExportTemplates")
.withIndex("by_tenant_default", (q) => q.eq("tenantId", tenantId).eq("isDefault", true))
.first()
if (globalDefault) {
return {
id: globalDefault._id,
slug: globalDefault.slug,
name: globalDefault.name,
description: globalDefault.description ?? "",
columns: globalDefault.columns ?? [],
filters: globalDefault.filters ?? null,
companyId: globalDefault.companyId ?? null,
isDefault: Boolean(globalDefault.isDefault),
isActive: globalDefault.isActive ?? true,
}
}
}
return null
},
})
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
name: v.string(),
description: v.optional(v.string()),
columns: v.array(
v.object({
key: v.string(),
label: v.optional(v.string()),
})
),
filters: v.optional(v.any()),
companyId: v.optional(v.id("companies")),
isDefault: v.optional(v.boolean()),
isActive: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
await requireAdmin(ctx, args.actorId, args.tenantId)
const normalizedName = args.name.trim()
if (normalizedName.length < 3) {
throw new ConvexError("Informe um nome para o template")
}
const slug = normalizeSlug(normalizedName)
if (!slug) {
throw new ConvexError("Não foi possível gerar um identificador para o template")
}
await ensureUniqueSlug(ctx, args.tenantId, slug)
const columns = normalizeColumns(args.columns)
if (columns.length === 0) {
throw new ConvexError("Selecione ao menos uma coluna")
}
const now = Date.now()
const templateId = await ctx.db.insert("deviceExportTemplates", {
tenantId: args.tenantId,
name: normalizedName,
slug,
description: args.description ?? undefined,
columns,
filters: args.filters ?? undefined,
companyId: args.companyId ?? undefined,
isDefault: Boolean(args.isDefault),
isActive: args.isActive ?? true,
createdBy: args.actorId,
updatedBy: args.actorId,
createdAt: now,
updatedAt: now,
})
if (args.isDefault) {
await unsetDefaults(ctx, args.tenantId, args.companyId ?? null, templateId)
}
return templateId
},
})
export const update = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
templateId: v.id("deviceExportTemplates"),
name: v.string(),
description: v.optional(v.string()),
columns: v.array(
v.object({
key: v.string(),
label: v.optional(v.string()),
})
),
filters: v.optional(v.any()),
companyId: v.optional(v.id("companies")),
isDefault: v.optional(v.boolean()),
isActive: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
await requireAdmin(ctx, args.actorId, args.tenantId)
const template = await ctx.db.get(args.templateId)
if (!template || template.tenantId !== args.tenantId) {
throw new ConvexError("Template não encontrado")
}
const normalizedName = args.name.trim()
if (normalizedName.length < 3) {
throw new ConvexError("Informe um nome para o template")
}
let slug = template.slug
if (template.name !== normalizedName) {
slug = normalizeSlug(normalizedName)
if (!slug) throw new ConvexError("Não foi possível gerar um identificador para o template")
await ensureUniqueSlug(ctx, args.tenantId, slug, args.templateId)
}
const columns = normalizeColumns(args.columns)
if (columns.length === 0) {
throw new ConvexError("Selecione ao menos uma coluna")
}
const isDefault = Boolean(args.isDefault)
await ctx.db.patch(args.templateId, {
name: normalizedName,
slug,
description: args.description ?? undefined,
columns,
filters: args.filters ?? undefined,
companyId: args.companyId ?? undefined,
isDefault,
isActive: args.isActive ?? true,
updatedAt: Date.now(),
updatedBy: args.actorId,
})
if (isDefault) {
await unsetDefaults(ctx, args.tenantId, args.companyId ?? null, args.templateId)
}
},
})
export const remove = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
templateId: v.id("deviceExportTemplates"),
},
handler: async (ctx, args) => {
await requireAdmin(ctx, args.actorId, args.tenantId)
const template = await ctx.db.get(args.templateId)
if (!template || template.tenantId !== args.tenantId) {
throw new ConvexError("Template não encontrado")
}
await ctx.db.delete(args.templateId)
},
})
export const setDefault = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
templateId: v.id("deviceExportTemplates"),
},
handler: async (ctx, args) => {
await requireAdmin(ctx, args.actorId, args.tenantId)
const template = await ctx.db.get(args.templateId)
if (!template || template.tenantId !== args.tenantId) {
throw new ConvexError("Template não encontrado")
}
await unsetDefaults(ctx, args.tenantId, template.companyId ?? null, args.templateId)
await ctx.db.patch(args.templateId, {
isDefault: true,
updatedAt: Date.now(),
updatedBy: args.actorId,
})
},
})

271
convex/deviceFields.ts Normal file
View file

@ -0,0 +1,271 @@
import { mutation, query } from "./_generated/server"
import type { MutationCtx, QueryCtx } from "./_generated/server"
import { ConvexError, v } from "convex/values"
import type { Id } from "./_generated/dataModel"
import { requireAdmin, requireUser } from "./rbac"
const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const
type FieldType = (typeof FIELD_TYPES)[number]
type AnyCtx = MutationCtx | QueryCtx
function normalizeKey(label: string) {
return label
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "_")
.replace(/_+/g, "_")
}
async function ensureUniqueKey(ctx: AnyCtx, tenantId: string, key: string, excludeId?: Id<"deviceFields">) {
const existing = await ctx.db
.query("deviceFields")
.withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key))
.first()
if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe um campo com este identificador")
}
}
function validateOptions(type: FieldType, options: { value: string; label: string }[] | undefined) {
if (type === "select" && (!options || options.length === 0)) {
throw new ConvexError("Campos de seleção precisam de pelo menos uma opção")
}
}
function matchesScope(fieldScope: string | undefined, scope: string | undefined) {
if (!scope || scope === "all") return true
if (!fieldScope || fieldScope === "all") return true
return fieldScope === scope
}
function matchesCompany(fieldCompanyId: Id<"companies"> | undefined, companyId: Id<"companies"> | undefined, includeScoped?: boolean) {
if (!companyId) {
if (includeScoped) return true
return fieldCompanyId ? false : true
}
return !fieldCompanyId || fieldCompanyId === companyId
}
export const list = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
companyId: v.optional(v.id("companies")),
scope: v.optional(v.string()),
},
handler: async (ctx, { tenantId, viewerId, companyId, scope }) => {
await requireAdmin(ctx, viewerId, tenantId)
const fieldsQuery = ctx.db
.query("deviceFields")
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
const fields = await fieldsQuery.collect()
return fields
.filter((field) => matchesCompany(field.companyId, companyId, true))
.filter((field) => matchesScope(field.scope, scope))
.sort((a, b) => a.order - b.order)
.map((field) => ({
id: field._id,
key: field.key,
label: field.label,
description: field.description ?? "",
type: field.type as FieldType,
required: Boolean(field.required),
options: field.options ?? [],
order: field.order,
scope: field.scope ?? "all",
companyId: field.companyId ?? null,
}))
},
})
export const listForTenant = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
companyId: v.optional(v.id("companies")),
scope: v.optional(v.string()),
},
handler: async (ctx, { tenantId, viewerId, companyId, scope }) => {
await requireUser(ctx, viewerId, tenantId)
const fields = await ctx.db
.query("deviceFields")
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
.collect()
return fields
.filter((field) => matchesCompany(field.companyId, companyId, false))
.filter((field) => matchesScope(field.scope, scope))
.sort((a, b) => a.order - b.order)
.map((field) => ({
id: field._id,
key: field.key,
label: field.label,
description: field.description ?? "",
type: field.type as FieldType,
required: Boolean(field.required),
options: field.options ?? [],
order: field.order,
scope: field.scope ?? "all",
companyId: field.companyId ?? null,
}))
},
})
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
label: v.string(),
description: v.optional(v.string()),
type: v.string(),
required: v.optional(v.boolean()),
options: v.optional(
v.array(
v.object({
value: v.string(),
label: v.string(),
})
)
),
scope: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
},
handler: async (ctx, args) => {
await requireAdmin(ctx, args.actorId, args.tenantId)
const normalizedLabel = args.label.trim()
if (normalizedLabel.length < 2) {
throw new ConvexError("Informe um rótulo para o campo")
}
if (!FIELD_TYPES.includes(args.type as FieldType)) {
throw new ConvexError("Tipo de campo inválido")
}
validateOptions(args.type as FieldType, args.options ?? undefined)
const key = normalizeKey(normalizedLabel)
await ensureUniqueKey(ctx, args.tenantId, key)
const existing = await ctx.db
.query("deviceFields")
.withIndex("by_tenant_order", (q) => q.eq("tenantId", args.tenantId))
.collect()
const maxOrder = existing.reduce((acc, item) => Math.max(acc, item.order ?? 0), 0)
const now = Date.now()
const id = await ctx.db.insert("deviceFields", {
tenantId: args.tenantId,
key,
label: normalizedLabel,
description: args.description ?? undefined,
type: args.type,
required: Boolean(args.required),
options: args.options ?? undefined,
scope: args.scope ?? "all",
companyId: args.companyId ?? undefined,
order: maxOrder + 1,
createdAt: now,
updatedAt: now,
createdBy: args.actorId,
updatedBy: args.actorId,
})
return id
},
})
export const update = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
fieldId: v.id("deviceFields"),
label: v.string(),
description: v.optional(v.string()),
type: v.string(),
required: v.optional(v.boolean()),
options: v.optional(
v.array(
v.object({
value: v.string(),
label: v.string(),
})
)
),
scope: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
},
handler: async (ctx, args) => {
await requireAdmin(ctx, args.actorId, args.tenantId)
const field = await ctx.db.get(args.fieldId)
if (!field || field.tenantId !== args.tenantId) {
throw new ConvexError("Campo não encontrado")
}
if (!FIELD_TYPES.includes(args.type as FieldType)) {
throw new ConvexError("Tipo de campo inválido")
}
const normalizedLabel = args.label.trim()
if (normalizedLabel.length < 2) {
throw new ConvexError("Informe um rótulo para o campo")
}
validateOptions(args.type as FieldType, args.options ?? undefined)
let key = field.key
if (field.label !== normalizedLabel) {
key = normalizeKey(normalizedLabel)
await ensureUniqueKey(ctx, args.tenantId, key, args.fieldId)
}
await ctx.db.patch(args.fieldId, {
key,
label: normalizedLabel,
description: args.description ?? undefined,
type: args.type,
required: Boolean(args.required),
options: args.options ?? undefined,
scope: args.scope ?? "all",
companyId: args.companyId ?? undefined,
updatedAt: Date.now(),
updatedBy: args.actorId,
})
},
})
export const remove = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
fieldId: v.id("deviceFields"),
},
handler: async (ctx, args) => {
await requireAdmin(ctx, args.actorId, args.tenantId)
const field = await ctx.db.get(args.fieldId)
if (!field || field.tenantId !== args.tenantId) {
throw new ConvexError("Campo não encontrado")
}
await ctx.db.delete(args.fieldId)
},
})
export const reorder = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
orderedIds: v.array(v.id("deviceFields")),
},
handler: async (ctx, args) => {
await requireAdmin(ctx, args.actorId, args.tenantId)
const now = Date.now()
await Promise.all(
args.orderedIds.map((fieldId, index) =>
ctx.db.patch(fieldId, {
order: index + 1,
updatedAt: now,
updatedBy: args.actorId,
})
)
)
},
})

1
convex/devices.ts Normal file
View file

@ -0,0 +1 @@
export * from "./machines"

View file

@ -38,8 +38,8 @@ function validateOptions(type: FieldType, options: { value: string; label: strin
}
export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) },
handler: async (ctx, { tenantId, viewerId, scope }) => {
await requireAdmin(ctx, viewerId, tenantId);
const fields = await ctx.db
.query("ticketFields")
@ -47,6 +47,12 @@ export const list = query({
.collect();
return fields
.filter((field) => {
if (!scope) return true;
const fieldScope = (field.scope ?? "all").trim();
if (fieldScope === "all" || fieldScope.length === 0) return true;
return fieldScope === scope;
})
.sort((a, b) => a.order - b.order)
.map((field) => ({
id: field._id,
@ -57,6 +63,7 @@ export const list = query({
required: field.required,
options: field.options ?? [],
order: field.order,
scope: field.scope ?? "all",
createdAt: field.createdAt,
updatedAt: field.updatedAt,
}));
@ -64,8 +71,8 @@ export const list = query({
});
export const listForTenant = query({
args: { tenantId: v.string(), viewerId: v.id("users") },
handler: async (ctx, { tenantId, viewerId }) => {
args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) },
handler: async (ctx, { tenantId, viewerId, scope }) => {
await requireUser(ctx, viewerId, tenantId);
const fields = await ctx.db
.query("ticketFields")
@ -73,6 +80,12 @@ export const listForTenant = query({
.collect();
return fields
.filter((field) => {
if (!scope) return true;
const fieldScope = (field.scope ?? "all").trim();
if (fieldScope === "all" || fieldScope.length === 0) return true;
return fieldScope === scope;
})
.sort((a, b) => a.order - b.order)
.map((field) => ({
id: field._id,
@ -83,6 +96,7 @@ export const listForTenant = query({
required: field.required,
options: field.options ?? [],
order: field.order,
scope: field.scope ?? "all",
}));
},
});
@ -103,8 +117,9 @@ export const create = mutation({
})
)
),
scope: v.optional(v.string()),
},
handler: async (ctx, { tenantId, actorId, label, description, type, required, options }) => {
handler: async (ctx, { tenantId, actorId, label, description, type, required, options, scope }) => {
await requireAdmin(ctx, actorId, tenantId);
const normalizedLabel = label.trim();
if (normalizedLabel.length < 2) {
@ -116,6 +131,15 @@ export const create = mutation({
validateOptions(type as FieldType, options ?? undefined);
const key = normalizeKey(normalizedLabel);
await ensureUniqueKey(ctx, tenantId, key);
const normalizedScope = (() => {
const raw = scope?.trim();
if (!raw || raw.length === 0) return "all";
const safe = raw.toLowerCase();
if (!/^[a-z0-9_\-]+$/i.test(safe)) {
throw new ConvexError("Escopo inválido para o campo");
}
return safe;
})();
const existing = await ctx.db
.query("ticketFields")
@ -133,6 +157,7 @@ export const create = mutation({
required,
options,
order: maxOrder + 1,
scope: normalizedScope,
createdAt: now,
updatedAt: now,
});
@ -157,8 +182,9 @@ export const update = mutation({
})
)
),
scope: v.optional(v.string()),
},
handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options }) => {
handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options, scope }) => {
await requireAdmin(ctx, actorId, tenantId);
const field = await ctx.db.get(fieldId);
if (!field || field.tenantId !== tenantId) {
@ -173,6 +199,16 @@ export const update = mutation({
}
validateOptions(type as FieldType, options ?? undefined);
const normalizedScope = (() => {
const raw = scope?.trim();
if (!raw || raw.length === 0) return "all";
const safe = raw.toLowerCase();
if (!/^[a-z0-9_\-]+$/i.test(safe)) {
throw new ConvexError("Escopo inválido para o campo");
}
return safe;
})();
let key = field.key;
if (field.label !== normalizedLabel) {
key = normalizeKey(normalizedLabel);
@ -186,6 +222,7 @@ export const update = mutation({
type,
required,
options,
scope: normalizedScope,
updatedAt: Date.now(),
});
},

View file

@ -8,6 +8,7 @@ import { randomBytes } from "@noble/hashes/utils"
import type { Doc, Id } from "./_generated/dataModel"
import type { MutationCtx, QueryCtx } from "./_generated/server"
import { normalizeStatus } from "./tickets"
import { requireAdmin } from "./rbac"
const DEFAULT_TENANT_ID = "tenant-atlas"
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
@ -65,6 +66,14 @@ function normalizeIdentifiers(macAddresses: string[], serialNumbers: string[]):
return { macs, serials }
}
function normalizeOptionalIdentifiers(macAddresses?: string[] | null, serialNumbers?: string[] | null): NormalizedIdentifiers {
const normalizeMac = (value: string) => value.replace(/[^a-f0-9]/gi, "").toLowerCase()
const normalizeSerial = (value: string) => value.trim().toLowerCase()
const macs = Array.from(new Set((macAddresses ?? []).map(normalizeMac).filter(Boolean))).sort()
const serials = Array.from(new Set((serialNumbers ?? []).map(normalizeSerial).filter(Boolean))).sort()
return { macs, serials }
}
async function findActiveMachineToken(ctx: QueryCtx, machineId: Id<"machines">, now: number) {
const tokens = await ctx.db
.query("machineTokens")
@ -93,6 +102,51 @@ function computeFingerprint(tenantId: string, companySlug: string | undefined, h
return toHex(sha256(payload))
}
function generateManualFingerprint(tenantId: string, displayName: string) {
const payload = JSON.stringify({
tenantId,
displayName: displayName.trim().toLowerCase(),
nonce: toHex(randomBytes(16)),
createdAt: Date.now(),
})
return toHex(sha256(payload))
}
function formatDeviceCustomFieldDisplay(
type: string,
value: unknown,
options?: Array<{ value: string; label: string }>
): string | null {
if (value === null || value === undefined) return null
switch (type) {
case "text":
return String(value).trim()
case "number": {
const num = typeof value === "number" ? value : Number(value)
if (!Number.isFinite(num)) return null
return String(num)
}
case "boolean":
return value ? "Sim" : "Não"
case "date": {
const date = value instanceof Date ? value : new Date(String(value))
if (Number.isNaN(date.getTime())) return null
return date.toISOString().slice(0, 10)
}
case "select": {
const raw = String(value)
const option = options?.find((opt) => opt.value === raw || opt.label === raw)
return option?.label ?? raw
}
default:
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
}
function extractCollaboratorEmail(metadata: unknown): string | null {
if (!metadata || typeof metadata !== "object") return null
const record = metadata as Record<string, unknown>
@ -126,18 +180,18 @@ async function getActiveToken(
.unique()
if (!token) {
throw new ConvexError("Token de máquina inválido")
throw new ConvexError("Token de dispositivo inválido")
}
if (token.revoked) {
throw new ConvexError("Token de máquina revogado")
throw new ConvexError("Token de dispositivo revogado")
}
if (token.expiresAt < Date.now()) {
throw new ConvexError("Token de máquina expirado")
throw new ConvexError("Token de dispositivo expirado")
}
const machine = await ctx.db.get(token.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada para o token fornecido")
throw new ConvexError("Dispositivo não encontrada para o token fornecido")
}
return { token, machine }
@ -381,7 +435,7 @@ async function evaluatePostureAndMaybeRaise(
if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "false").toLowerCase() !== "true") return
if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return
const subject = `Alerta de máquina: ${machine.hostname}`
const subject = `Alerta de dispositivo: ${machine.hostname}`
const summary = findings.map((f) => `${f.severity.toUpperCase()}: ${f.message}`).join(" | ")
await createTicketForAlert(ctx, machine.tenantId, machine.companyId, subject, summary)
}
@ -445,6 +499,7 @@ export const register = mutation({
companyId: companyId ?? existing.companyId,
companySlug: companySlug ?? existing.companySlug,
hostname: args.hostname,
displayName: existing.displayName ?? args.hostname,
osName: args.os.name,
osVersion: args.os.version,
architecture: args.os.architecture,
@ -457,6 +512,9 @@ export const register = mutation({
status: "online",
isActive: true,
registeredBy: args.registeredBy ?? existing.registeredBy,
deviceType: existing.deviceType ?? "desktop",
devicePlatform: args.os.name ?? existing.devicePlatform,
managementMode: existing.managementMode ?? "agent",
persona: existing.persona,
assignedUserId: existing.assignedUserId,
assignedUserEmail: existing.assignedUserEmail,
@ -470,6 +528,7 @@ export const register = mutation({
companyId,
companySlug,
hostname: args.hostname,
displayName: args.hostname,
osName: args.os.name,
osVersion: args.os.version,
architecture: args.os.architecture,
@ -483,6 +542,9 @@ export const register = mutation({
createdAt: now,
updatedAt: now,
registeredBy: args.registeredBy,
deviceType: "desktop",
devicePlatform: args.os.name,
managementMode: "agent",
persona: undefined,
assignedUserId: undefined,
assignedUserEmail: undefined,
@ -582,6 +644,7 @@ export const upsertInventory = mutation({
companyId: companyId ?? existing.companyId,
companySlug: companySlug ?? existing.companySlug,
hostname: args.hostname,
displayName: existing.displayName ?? args.hostname,
osName: args.os.name,
osVersion: args.os.version,
architecture: args.os.architecture,
@ -592,6 +655,9 @@ export const upsertInventory = mutation({
updatedAt: now,
status: args.metrics ? "online" : existing.status ?? "unknown",
registeredBy: args.registeredBy ?? existing.registeredBy,
deviceType: existing.deviceType ?? "desktop",
devicePlatform: args.os.name ?? existing.devicePlatform,
managementMode: existing.managementMode ?? "agent",
persona: existing.persona,
assignedUserId: existing.assignedUserId,
assignedUserEmail: existing.assignedUserEmail,
@ -605,6 +671,7 @@ export const upsertInventory = mutation({
companyId,
companySlug,
hostname: args.hostname,
displayName: args.hostname,
osName: args.os.name,
osVersion: args.os.version,
architecture: args.os.architecture,
@ -617,6 +684,9 @@ export const upsertInventory = mutation({
createdAt: now,
updatedAt: now,
registeredBy: args.registeredBy,
deviceType: "desktop",
devicePlatform: args.os.name,
managementMode: "agent",
persona: undefined,
assignedUserId: undefined,
assignedUserEmail: undefined,
@ -673,9 +743,13 @@ export const heartbeat = mutation({
await ctx.db.patch(machine._id, {
hostname: args.hostname ?? machine.hostname,
displayName: machine.displayName ?? args.hostname ?? machine.hostname,
osName: args.os?.name ?? machine.osName,
osVersion: args.os?.version ?? machine.osVersion,
architecture: args.os?.architecture ?? machine.architecture,
devicePlatform: args.os?.name ?? machine.devicePlatform,
deviceType: machine.deviceType ?? "desktop",
managementMode: machine.managementMode ?? "agent",
lastHeartbeatAt: now,
updatedAt: now,
status: args.status ?? "online",
@ -839,6 +913,11 @@ export const listByTenant = query({
id: machine._id,
tenantId: machine.tenantId,
hostname: machine.hostname,
displayName: machine.displayName ?? null,
deviceType: machine.deviceType ?? "desktop",
devicePlatform: machine.devicePlatform ?? null,
deviceProfile: machine.deviceProfile ?? null,
managementMode: machine.managementMode ?? "agent",
companyId: machine.companyId ?? null,
companySlug: machine.companySlug ?? companyFromId?.slug ?? companyFromSlug?.slug ?? null,
companyName: resolvedCompany?.name ?? null,
@ -873,6 +952,7 @@ export const listByTenant = query({
inventory,
postureAlerts,
lastPostureAt,
customFields: machine.customFields ?? [],
}
})
)
@ -957,6 +1037,11 @@ export async function getByIdHandler(
id: machine._id,
tenantId: machine.tenantId,
hostname: machine.hostname,
displayName: machine.displayName ?? null,
deviceType: machine.deviceType ?? "desktop",
devicePlatform: machine.devicePlatform ?? null,
deviceProfile: machine.deviceProfile ?? null,
managementMode: machine.managementMode ?? "agent",
companyId: machine.companyId ?? null,
companySlug: machine.companySlug ?? resolvedCompany?.slug ?? null,
companyName: resolvedCompany?.name ?? null,
@ -992,6 +1077,7 @@ export async function getByIdHandler(
postureAlerts,
lastPostureAt,
remoteAccess: machine.remoteAccess ?? null,
customFields: machine.customFields ?? [],
}
}
@ -1333,7 +1419,7 @@ export async function updatePersonaHandler(
) {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
let nextPersona = machine.persona ?? undefined
@ -1343,7 +1429,7 @@ export async function updatePersonaHandler(
if (!trimmed) {
nextPersona = undefined
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) {
throw new ConvexError("Perfil inválido para a máquina")
throw new ConvexError("Perfil inválido para a dispositivo")
} else {
nextPersona = trimmed
}
@ -1380,7 +1466,7 @@ export async function updatePersonaHandler(
}
if (nextPersona && !nextAssignedUserId) {
throw new ConvexError("Associe um usuário ao definir a persona da máquina")
throw new ConvexError("Associe um usuário ao definir a persona da dispositivo")
}
if (nextPersona && nextAssignedUserId) {
@ -1435,6 +1521,196 @@ export async function updatePersonaHandler(
return { ok: true, persona: nextPersona ?? null }
}
export const saveDeviceCustomFields = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
machineId: v.id("machines"),
fields: v.array(
v.object({
fieldId: v.id("deviceFields"),
value: v.any(),
})
),
},
handler: async (ctx, args) => {
await requireAdmin(ctx, args.actorId, args.tenantId)
const machine = await ctx.db.get(args.machineId)
if (!machine || machine.tenantId !== args.tenantId) {
throw new ConvexError("Dispositivo não encontrado")
}
const companyId = machine.companyId ?? null
const deviceType = (machine.deviceType ?? "desktop").toLowerCase()
const entries = await Promise.all(
args.fields.map(async ({ fieldId, value }) => {
const definition = await ctx.db.get(fieldId)
if (!definition || definition.tenantId !== args.tenantId) {
return null
}
if (companyId && definition.companyId && definition.companyId !== companyId) {
return null
}
if (!companyId && definition.companyId) {
return null
}
const scope = (definition.scope ?? "all").toLowerCase()
if (scope !== "all" && scope !== deviceType) {
return null
}
const displayValue =
value === null || value === undefined
? null
: formatDeviceCustomFieldDisplay(definition.type, value, definition.options ?? undefined)
return {
fieldId: definition._id,
fieldKey: definition.key,
label: definition.label,
type: definition.type,
value: value ?? null,
displayValue: displayValue ?? undefined,
}
})
)
const customFields = entries.filter(Boolean) as Array<{
fieldId: Id<"deviceFields">
fieldKey: string
label: string
type: string
value: unknown
displayValue?: string
}>
await ctx.db.patch(args.machineId, {
customFields,
updatedAt: Date.now(),
})
},
})
export const saveDeviceProfile = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
machineId: v.optional(v.id("machines")),
companyId: v.optional(v.id("companies")),
companySlug: v.optional(v.string()),
displayName: v.string(),
hostname: v.optional(v.string()),
deviceType: v.string(),
devicePlatform: v.optional(v.string()),
osName: v.optional(v.string()),
osVersion: v.optional(v.string()),
macAddresses: v.optional(v.array(v.string())),
serialNumbers: v.optional(v.array(v.string())),
profile: v.optional(v.any()),
status: v.optional(v.string()),
isActive: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
await requireAdmin(ctx, args.actorId, args.tenantId)
const displayName = args.displayName.trim()
if (!displayName) {
throw new ConvexError("Informe o nome do dispositivo")
}
const hostname = (args.hostname ?? displayName).trim()
if (!hostname) {
throw new ConvexError("Informe o identificador do dispositivo")
}
const normalizedType = (() => {
const candidate = args.deviceType.trim().toLowerCase()
if (["desktop", "mobile", "tablet"].includes(candidate)) return candidate
return "desktop"
})()
const normalizedPlatform = args.devicePlatform?.trim() || args.osName?.trim() || null
const normalizedStatus = (args.status ?? "unknown").trim() || "unknown"
const normalizedSlug = args.companySlug?.trim() || undefined
const osNameInput = args.osName === undefined ? undefined : args.osName.trim()
const osVersionInput = args.osVersion === undefined ? undefined : args.osVersion.trim()
const now = Date.now()
if (args.machineId) {
const machine = await ctx.db.get(args.machineId)
if (!machine || machine.tenantId !== args.tenantId) {
throw new ConvexError("Dispositivo não encontrado para atualização")
}
if (machine.managementMode && machine.managementMode !== "manual") {
throw new ConvexError("Somente dispositivos manuais podem ser editados por esta ação")
}
const normalizedIdentifiers = normalizeOptionalIdentifiers(args.macAddresses, args.serialNumbers)
const macAddresses =
args.macAddresses === undefined ? machine.macAddresses : normalizedIdentifiers.macs
const serialNumbers =
args.serialNumbers === undefined ? machine.serialNumbers : normalizedIdentifiers.serials
const deviceProfilePatch = args.profile === undefined ? undefined : args.profile ?? null
const osNameValue = osNameInput === undefined ? machine.osName : osNameInput || machine.osName
const osVersionValue =
osVersionInput === undefined ? machine.osVersion ?? undefined : osVersionInput || undefined
await ctx.db.patch(args.machineId, {
companyId: args.companyId ?? machine.companyId ?? undefined,
companySlug: normalizedSlug ?? machine.companySlug ?? undefined,
hostname,
displayName,
osName: osNameValue,
osVersion: osVersionValue,
macAddresses,
serialNumbers,
deviceType: normalizedType,
devicePlatform: normalizedPlatform ?? machine.devicePlatform ?? undefined,
deviceProfile: deviceProfilePatch,
managementMode: "manual",
status: normalizedStatus,
isActive: args.isActive ?? machine.isActive ?? true,
updatedAt: now,
})
return { machineId: args.machineId }
}
const normalizedIdentifiers = normalizeOptionalIdentifiers(args.macAddresses, args.serialNumbers)
const fingerprint = generateManualFingerprint(args.tenantId, displayName)
const deviceProfile = args.profile ?? undefined
const osNameValue = osNameInput || normalizedPlatform || "Desconhecido"
const osVersionValue = osVersionInput || undefined
const machineId = await ctx.db.insert("machines", {
tenantId: args.tenantId,
companyId: args.companyId ?? undefined,
companySlug: normalizedSlug ?? undefined,
hostname,
displayName,
osName: osNameValue,
osVersion: osVersionValue,
macAddresses: normalizedIdentifiers.macs,
serialNumbers: normalizedIdentifiers.serials,
fingerprint,
metadata: undefined,
deviceType: normalizedType,
devicePlatform: normalizedPlatform ?? undefined,
deviceProfile,
managementMode: "manual",
status: normalizedStatus,
isActive: args.isActive ?? true,
createdAt: now,
updatedAt: now,
registeredBy: "manual",
persona: undefined,
assignedUserId: undefined,
assignedUserEmail: undefined,
assignedUserName: undefined,
assignedUserRole: undefined,
})
return { machineId }
},
})
export const updatePersona = mutation({
args: {
machineId: v.id("machines"),
@ -1454,7 +1730,7 @@ export const getContext = query({
handler: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
const linkedUserIds = machine.linkedUserIds ?? []
@ -1515,7 +1791,7 @@ export const linkAuthAccount = mutation({
handler: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
await ctx.db.patch(machine._id, {
@ -1535,7 +1811,7 @@ export const linkUser = mutation({
},
handler: async (ctx, { machineId, email }) => {
const machine = await ctx.db.get(machineId)
if (!machine) throw new ConvexError("Máquina não encontrada")
if (!machine) throw new ConvexError("Dispositivo não encontrada")
const tenantId = machine.tenantId
const normalized = email.trim().toLowerCase()
@ -1546,7 +1822,7 @@ export const linkUser = mutation({
if (!user) throw new ConvexError("Usuário não encontrado")
const role = (user.role ?? "").toUpperCase()
if (role === 'ADMIN' || role === 'AGENT') {
throw new ConvexError('Usuários administrativos não podem ser vinculados a máquinas')
throw new ConvexError('Usuários administrativos não podem ser vinculados a dispositivos')
}
const current = new Set<Id<"users">>(machine.linkedUserIds ?? [])
@ -1563,7 +1839,7 @@ export const unlinkUser = mutation({
},
handler: async (ctx, { machineId, userId }) => {
const machine = await ctx.db.get(machineId)
if (!machine) throw new ConvexError("Máquina não encontrada")
if (!machine) throw new ConvexError("Dispositivo não encontrada")
const next = (machine.linkedUserIds ?? []).filter((id) => id !== userId)
await ctx.db.patch(machine._id, { linkedUserIds: next, updatedAt: Date.now() })
return { ok: true }
@ -1580,25 +1856,29 @@ export const rename = mutation({
// Reutiliza requireStaff através de tickets.ts helpers
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
// Verifica permissão no tenant da máquina
// Verifica permissão no tenant da dispositivo
const viewer = await ctx.db.get(actorId)
if (!viewer || viewer.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
throw new ConvexError("Acesso negado ao tenant da dispositivo")
}
const normalizedRole = (viewer.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(normalizedRole)) {
throw new ConvexError("Apenas equipe interna pode renomear máquinas")
throw new ConvexError("Apenas equipe interna pode renomear dispositivos")
}
const nextName = hostname.trim()
if (nextName.length < 2) {
throw new ConvexError("Informe um nome válido para a máquina")
throw new ConvexError("Informe um nome válido para a dispositivo")
}
await ctx.db.patch(machineId, { hostname: nextName, updatedAt: Date.now() })
await ctx.db.patch(machineId, {
hostname: nextName,
displayName: nextName,
updatedAt: Date.now(),
})
return { ok: true }
},
})
@ -1612,17 +1892,17 @@ export const toggleActive = mutation({
handler: async (ctx, { machineId, actorId, active }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
throw new ConvexError("Acesso negado ao tenant da dispositivo")
}
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(normalizedRole)) {
throw new ConvexError("Apenas equipe interna pode atualizar o status da máquina")
throw new ConvexError("Apenas equipe interna pode atualizar o status da dispositivo")
}
await ctx.db.patch(machineId, {
@ -1642,17 +1922,17 @@ export const resetAgent = mutation({
handler: async (ctx, { machineId, actorId }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
throw new ConvexError("Acesso negado ao tenant da dispositivo")
}
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(normalizedRole)) {
throw new ConvexError("Apenas equipe interna pode resetar o agente da máquina")
throw new ConvexError("Apenas equipe interna pode resetar o agente da dispositivo")
}
const tokens = await ctx.db
@ -1808,12 +2088,12 @@ export const updateRemoteAccess = mutation({
handler: async (ctx, { machineId, actorId, provider, identifier, url, notes, action, entryId, clear }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
throw new ConvexError("Acesso negado ao tenant da dispositivo")
}
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
@ -1937,17 +2217,17 @@ export const remove = mutation({
handler: async (ctx, { machineId, actorId }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
throw new ConvexError("Acesso negado ao tenant da dispositivo")
}
const role = (actor.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(role)) {
throw new ConvexError("Apenas equipe interna pode excluir máquinas")
throw new ConvexError("Apenas equipe interna pode excluir dispositivos")
}
const tokens = await ctx.db

View file

@ -51,6 +51,39 @@ function extractScore(payload: unknown): number | null {
return null;
}
function extractMaxScore(payload: unknown): number | null {
if (payload && typeof payload === "object" && "maxScore" in payload) {
const value = (payload as { maxScore: unknown }).maxScore;
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value;
}
}
return null;
}
function extractComment(payload: unknown): string | null {
if (payload && typeof payload === "object" && "comment" in payload) {
const value = (payload as { comment: unknown }).comment;
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
}
return null;
}
function extractAssignee(payload: unknown): { id: string | null; name: string | null } {
if (!payload || typeof payload !== "object") {
return { id: null, name: null }
}
const record = payload as Record<string, unknown>
const rawId = record["assigneeId"]
const rawName = record["assigneeName"]
const id = typeof rawId === "string" && rawId.trim().length > 0 ? rawId.trim() : null
const name = typeof rawName === "string" && rawName.trim().length > 0 ? rawName.trim() : null
return { id, name }
}
function isNotNull<T>(value: T | null): value is T {
return value !== null;
}
@ -119,6 +152,44 @@ async function fetchScopedTicketsByCreatedRange(
.collect();
}
async function fetchScopedTicketsByResolvedRange(
ctx: QueryCtx,
tenantId: string,
viewer: Awaited<ReturnType<typeof requireStaff>>,
startMs: number,
endMs: number,
companyId?: Id<"companies">,
) {
if (viewer.role === "MANAGER") {
if (!viewer.user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada");
}
return ctx.db
.query("tickets")
.withIndex("by_tenant_company_resolved", (q) =>
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("resolvedAt", startMs),
)
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
.collect();
}
if (companyId) {
return ctx.db
.query("tickets")
.withIndex("by_tenant_company_resolved", (q) =>
q.eq("tenantId", tenantId).eq("companyId", companyId).gte("resolvedAt", startMs),
)
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
.collect();
}
return ctx.db
.query("tickets")
.withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", startMs))
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
.collect();
}
async function fetchQueues(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("queues")
@ -159,12 +230,47 @@ type CsatSurvey = {
ticketId: Id<"tickets">;
reference: number;
score: number;
maxScore: number;
comment: string | null;
receivedAt: number;
assigneeId: string | null;
assigneeName: string | null;
};
async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise<CsatSurvey[]> {
const perTicket = await Promise.all(
tickets.map(async (ticket) => {
if (typeof ticket.csatScore === "number") {
const snapshot = (ticket.csatAssigneeSnapshot ?? null) as {
name?: string
email?: string
} | null
const assigneeId =
ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string"
? ticket.csatAssigneeId
: ticket.csatAssigneeId
? String(ticket.csatAssigneeId)
: null
const assigneeName =
snapshot && typeof snapshot.name === "string" && snapshot.name.trim().length > 0
? snapshot.name.trim()
: null
return [
{
ticketId: ticket._id,
reference: ticket.reference,
score: ticket.csatScore,
maxScore: ticket.csatMaxScore && Number.isFinite(ticket.csatMaxScore) ? (ticket.csatMaxScore as number) : 5,
comment:
typeof ticket.csatComment === "string" && ticket.csatComment.trim().length > 0
? ticket.csatComment.trim()
: null,
receivedAt: ticket.csatRatedAt ?? ticket.updatedAt ?? ticket.createdAt,
assigneeId,
assigneeName,
} satisfies CsatSurvey,
];
}
const events = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
@ -174,11 +280,16 @@ async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Pro
.map((event) => {
const score = extractScore(event.payload);
if (score === null) return null;
const assignee = extractAssignee(event.payload)
return {
ticketId: ticket._id,
reference: ticket.reference,
score,
maxScore: extractMaxScore(event.payload) ?? 5,
comment: extractComment(event.payload),
receivedAt: event.createdAt,
assigneeId: assignee.id,
assigneeName: assignee.name,
} as CsatSurvey;
})
.filter(isNotNull);
@ -275,12 +386,61 @@ export async function csatOverviewHandler(
const startMs = endMs - days * ONE_DAY_MS;
const surveys = surveysAll.filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs);
const averageScore = average(surveys.map((item) => item.score));
const normalizeToFive = (value: CsatSurvey) => {
if (!value.maxScore || value.maxScore <= 0) return value.score;
return Math.min(5, Math.max(1, (value.score / value.maxScore) * 5));
};
const averageScore = average(surveys.map((item) => normalizeToFive(item)));
const distribution = [1, 2, 3, 4, 5].map((score) => ({
score,
total: surveys.filter((item) => item.score === score).length,
total: surveys.filter((item) => Math.round(normalizeToFive(item)) === score).length,
}));
const positiveThreshold = 4;
const positiveCount = surveys.filter((item) => normalizeToFive(item) >= positiveThreshold).length;
const positiveRate = surveys.length > 0 ? positiveCount / surveys.length : null;
const agentStats = new Map<
string,
{ id: string; name: string; total: number; sum: number; positive: number }
>();
for (const survey of surveys) {
const normalizedScore = normalizeToFive(survey);
const key = survey.assigneeId ?? "unassigned";
const existing = agentStats.get(key) ?? {
id: key,
name: survey.assigneeName ?? "Sem responsável",
total: 0,
sum: 0,
positive: 0,
};
existing.total += 1;
existing.sum += normalizedScore;
if (normalizedScore >= positiveThreshold) {
existing.positive += 1;
}
if (survey.assigneeName && survey.assigneeName.trim().length > 0) {
existing.name = survey.assigneeName.trim();
}
agentStats.set(key, existing);
}
const byAgent = Array.from(agentStats.values())
.map((entry) => ({
agentId: entry.id === "unassigned" ? null : entry.id,
agentName: entry.id === "unassigned" ? "Sem responsável" : entry.name,
totalResponses: entry.total,
averageScore: entry.total > 0 ? entry.sum / entry.total : null,
positiveRate: entry.total > 0 ? entry.positive / entry.total : null,
}))
.sort((a, b) => {
const diff = (b.averageScore ?? 0) - (a.averageScore ?? 0);
if (Math.abs(diff) > 0.0001) return diff;
return (b.totalResponses ?? 0) - (a.totalResponses ?? 0);
});
return {
totalSurveys: surveys.length,
averageScore,
@ -293,9 +453,15 @@ export async function csatOverviewHandler(
ticketId: item.ticketId,
reference: item.reference,
score: item.score,
maxScore: item.maxScore,
comment: item.comment,
receivedAt: item.receivedAt,
assigneeId: item.assigneeId,
assigneeName: item.assigneeName,
})),
rangeDays: days,
positiveRate,
byAgent,
};
}
@ -309,15 +475,15 @@ export async function openedResolvedByDayHandler(
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
) {
const viewer = await requireStaff(ctx, viewerId, tenantId);
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const end = new Date();
end.setUTCHours(0, 0, 0, 0);
const endMs = end.getTime() + ONE_DAY_MS;
const startMs = endMs - days * ONE_DAY_MS;
const openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
const opened: Record<string, number> = {}
const resolved: Record<string, number> = {}
@ -328,15 +494,19 @@ export async function openedResolvedByDayHandler(
resolved[key] = 0
}
for (const t of tickets) {
if (t.createdAt >= startMs && t.createdAt < endMs) {
const key = formatDateKey(t.createdAt)
for (const ticket of openedTickets) {
if (ticket.createdAt >= startMs && ticket.createdAt < endMs) {
const key = formatDateKey(ticket.createdAt)
opened[key] = (opened[key] ?? 0) + 1
}
if (t.resolvedAt && t.resolvedAt >= startMs && t.resolvedAt < endMs) {
const key = formatDateKey(t.resolvedAt)
resolved[key] = (resolved[key] ?? 0) + 1
}
for (const ticket of resolvedTickets) {
if (typeof ticket.resolvedAt !== "number") {
continue
}
const key = formatDateKey(ticket.resolvedAt)
resolved[key] = (resolved[key] ?? 0) + 1
}
const series: Array<{ date: string; opened: number; resolved: number }> = []

View file

@ -169,6 +169,26 @@ export default defineSchema({
})
)
),
csatScore: v.optional(v.number()),
csatMaxScore: v.optional(v.number()),
csatComment: v.optional(v.string()),
csatRatedAt: v.optional(v.number()),
csatRatedBy: v.optional(v.id("users")),
csatAssigneeId: v.optional(v.id("users")),
csatAssigneeSnapshot: v.optional(
v.object({
name: v.string(),
email: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
})
),
formTemplate: v.optional(v.string()),
relatedTicketIds: v.optional(v.array(v.id("tickets"))),
resolvedWithTicketId: v.optional(v.id("tickets")),
reopenDeadline: v.optional(v.number()),
reopenedAt: v.optional(v.number()),
chatEnabled: v.optional(v.boolean()),
totalWorkedMs: v.optional(v.number()),
internalWorkedMs: v.optional(v.number()),
externalWorkedMs: v.optional(v.number()),
@ -183,7 +203,9 @@ export default defineSchema({
.index("by_tenant_machine", ["tenantId", "machineId"])
.index("by_tenant", ["tenantId"])
.index("by_tenant_created", ["tenantId", "createdAt"])
.index("by_tenant_company_created", ["tenantId", "companyId", "createdAt"]),
.index("by_tenant_resolved", ["tenantId", "resolvedAt"])
.index("by_tenant_company_created", ["tenantId", "companyId", "createdAt"])
.index("by_tenant_company_resolved", ["tenantId", "companyId", "resolvedAt"]),
ticketComments: defineTable({
ticketId: v.id("tickets"),
@ -221,6 +243,45 @@ export default defineSchema({
createdAt: v.number(),
}).index("by_ticket", ["ticketId"]),
ticketChatMessages: defineTable({
ticketId: v.id("tickets"),
authorId: v.id("users"),
authorSnapshot: v.optional(
v.object({
name: v.string(),
email: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
})
),
body: v.string(),
attachments: v.optional(
v.array(
v.object({
storageId: v.id("_storage"),
name: v.string(),
size: v.optional(v.number()),
type: v.optional(v.string()),
})
)
),
notifiedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
tenantId: v.string(),
companyId: v.optional(v.id("companies")),
readBy: v.optional(
v.array(
v.object({
userId: v.id("users"),
readAt: v.number(),
})
)
),
})
.index("by_ticket_created", ["ticketId", "createdAt"])
.index("by_tenant_created", ["tenantId", "createdAt"]),
commentTemplates: defineTable({
tenantId: v.string(),
kind: v.optional(v.string()),
@ -291,11 +352,29 @@ export default defineSchema({
})
)
),
scope: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_tenant_key", ["tenantId", "key"])
.index("by_tenant_order", ["tenantId", "order"])
.index("by_tenant_scope", ["tenantId", "scope"])
.index("by_tenant", ["tenantId"]),
ticketFormSettings: defineTable({
tenantId: v.string(),
template: v.string(),
scope: v.string(), // tenant | company | user
companyId: v.optional(v.id("companies")),
userId: v.optional(v.id("users")),
enabled: v.boolean(),
createdAt: v.number(),
updatedAt: v.number(),
actorId: v.optional(v.id("users")),
})
.index("by_tenant_template_scope", ["tenantId", "template", "scope"])
.index("by_tenant_template_company", ["tenantId", "template", "companyId"])
.index("by_tenant_template_user", ["tenantId", "template", "userId"])
.index("by_tenant", ["tenantId"]),
userInvites: defineTable({
@ -339,6 +418,23 @@ export default defineSchema({
serialNumbers: v.array(v.string()),
fingerprint: v.string(),
metadata: v.optional(v.any()),
displayName: v.optional(v.string()),
deviceType: v.optional(v.string()),
devicePlatform: v.optional(v.string()),
deviceProfile: v.optional(v.any()),
managementMode: v.optional(v.string()),
customFields: v.optional(
v.array(
v.object({
fieldId: v.id("deviceFields"),
fieldKey: v.string(),
label: v.string(),
type: v.string(),
value: v.any(),
displayValue: v.optional(v.string()),
})
)
),
lastHeartbeatAt: v.optional(v.number()),
status: v.optional(v.string()),
isActive: v.optional(v.boolean()),
@ -382,4 +478,58 @@ export default defineSchema({
.index("by_tenant_machine", ["tenantId", "machineId"])
.index("by_machine_created", ["machineId", "createdAt"])
.index("by_machine_revoked_expires", ["machineId", "revoked", "expiresAt"]),
deviceFields: defineTable({
tenantId: v.string(),
key: v.string(),
label: v.string(),
description: v.optional(v.string()),
type: v.string(),
required: v.optional(v.boolean()),
options: v.optional(
v.array(
v.object({
value: v.string(),
label: v.string(),
})
)
),
scope: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
order: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
createdBy: v.optional(v.id("users")),
updatedBy: v.optional(v.id("users")),
})
.index("by_tenant_order", ["tenantId", "order"])
.index("by_tenant_key", ["tenantId", "key"])
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant_scope", ["tenantId", "scope"])
.index("by_tenant", ["tenantId"]),
deviceExportTemplates: defineTable({
tenantId: v.string(),
name: v.string(),
slug: v.string(),
description: v.optional(v.string()),
columns: v.array(
v.object({
key: v.string(),
label: v.optional(v.string()),
})
),
filters: v.optional(v.any()),
companyId: v.optional(v.id("companies")),
isDefault: v.optional(v.boolean()),
isActive: v.optional(v.boolean()),
createdBy: v.optional(v.id("users")),
updatedBy: v.optional(v.id("users")),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_tenant_slug", ["tenantId", "slug"])
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant_default", ["tenantId", "isDefault"])
.index("by_tenant", ["tenantId"]),
});

View file

@ -0,0 +1,149 @@
import { mutation, query } from "./_generated/server"
import type { MutationCtx, QueryCtx } from "./_generated/server"
import { ConvexError, v } from "convex/values"
import type { Id } from "./_generated/dataModel"
import { requireAdmin } from "./rbac"
const KNOWN_TEMPLATES = new Set(["admissao", "desligamento"])
const VALID_SCOPES = new Set(["tenant", "company", "user"])
function normalizeTemplate(input: string) {
const normalized = input.trim().toLowerCase()
if (!KNOWN_TEMPLATES.has(normalized)) {
throw new ConvexError("Template desconhecido")
}
return normalized
}
function normalizeScope(input: string) {
const normalized = input.trim().toLowerCase()
if (!VALID_SCOPES.has(normalized)) {
throw new ConvexError("Escopo inválido")
}
return normalized
}
export const list = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
template: v.optional(v.string()),
},
handler: async (ctx, { tenantId, viewerId, template }) => {
await requireAdmin(ctx, viewerId, tenantId)
const normalizedTemplate = template ? normalizeTemplate(template) : null
const settings = await ctx.db
.query("ticketFormSettings")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
return settings
.filter((setting) => !normalizedTemplate || setting.template === normalizedTemplate)
.map((setting) => ({
id: setting._id,
template: setting.template,
scope: setting.scope,
companyId: setting.companyId ?? null,
userId: setting.userId ?? null,
enabled: setting.enabled,
createdAt: setting.createdAt,
updatedAt: setting.updatedAt,
actorId: setting.actorId ?? null,
}))
},
})
export const upsert = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
template: v.string(),
scope: v.string(),
companyId: v.optional(v.id("companies")),
userId: v.optional(v.id("users")),
enabled: v.boolean(),
},
handler: async (ctx, { tenantId, actorId, template, scope, companyId, userId, enabled }) => {
await requireAdmin(ctx, actorId, tenantId)
const normalizedTemplate = normalizeTemplate(template)
const normalizedScope = normalizeScope(scope)
if (normalizedScope === "company" && !companyId) {
throw new ConvexError("Informe a empresa para configurar o template")
}
if (normalizedScope === "user" && !userId) {
throw new ConvexError("Informe o usuário para configurar o template")
}
if (normalizedScope === "tenant") {
if (companyId || userId) {
throw new ConvexError("Escopo global não aceita empresa ou usuário")
}
}
const existing = await findExisting(ctx, tenantId, normalizedTemplate, normalizedScope, companyId, userId)
const now = Date.now()
if (existing) {
await ctx.db.patch(existing._id, {
enabled,
updatedAt: now,
actorId,
})
return existing._id
}
const id = await ctx.db.insert("ticketFormSettings", {
tenantId,
template: normalizedTemplate,
scope: normalizedScope,
companyId: normalizedScope === "company" ? (companyId as Id<"companies">) : undefined,
userId: normalizedScope === "user" ? (userId as Id<"users">) : undefined,
enabled,
createdAt: now,
updatedAt: now,
actorId,
})
return id
},
})
export const remove = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
settingId: v.id("ticketFormSettings"),
},
handler: async (ctx, { tenantId, actorId, settingId }) => {
await requireAdmin(ctx, actorId, tenantId)
const setting = await ctx.db.get(settingId)
if (!setting || setting.tenantId !== tenantId) {
throw new ConvexError("Configuração não encontrada")
}
await ctx.db.delete(settingId)
return { ok: true }
},
})
async function findExisting(
ctx: MutationCtx | QueryCtx,
tenantId: string,
template: string,
scope: string,
companyId?: Id<"companies">,
userId?: Id<"users">,
) {
const candidates = await ctx.db
.query("ticketFormSettings")
.withIndex("by_tenant_template_scope", (q) => q.eq("tenantId", tenantId).eq("template", template).eq("scope", scope))
.collect()
return candidates.find((setting) => {
if (scope === "tenant") return true
if (scope === "company") {
return setting.companyId && companyId && String(setting.companyId) === String(companyId)
}
if (scope === "user") {
return setting.userId && userId && String(setting.userId) === String(userId)
}
return false
}) ?? null
}

File diff suppressed because it is too large Load diff

View file

@ -13,11 +13,11 @@ Nota: este documento foi substituído por `docs/operations.md` e permanece aqui
- Seeds prontos (Better Auth e dados demo Convex).
- Seeds Better Auth automáticos: o container do web executa `pnpm auth:seed` após `prisma migrate deploy`, garantindo usuários padrão em toda inicialização (sem resetar senha existente por padrão).
### Sessão de máquina (Desktop/Tauri)
### Sessão de dispositivo (Desktop/Tauri)
- A rota `GET /machines/handshake?token=...&redirect=/portal|/dashboard` é pública no middleware para permitir a criação da sessão "machine" a partir do agente desktop, mesmo sem login prévio.
- Após o handshake, o servidor grava cookies de sessão (Better Auth) e um cookie `machine_ctx` com o vínculo (persona, assignedUser*, etc.). Em seguida, o App carrega `/api/machines/session` para preencher o contexto no cliente (`machineContext`).
- Com esse contexto, o Portal exibe corretamente o nome/email do colaborador/gestor no cabeçalho e permite abrir chamados em nome do usuário vinculado.
- Em sessões de máquina, o botão "Encerrar sessão" no menu do usuário é ocultado por padrão na UI interna.
- Em sessões de dispositivo, o botão "Encerrar sessão" no menu do usuário é ocultado por padrão na UI interna.
#### Detalhes importantes (aprendidos em produção)
- CORS com credenciais: as rotas `POST /api/machines/sessions` e `GET /machines/handshake` precisam enviar `Access-Control-Allow-Credentials: true` para que os cookies do Better Auth sejam aceitos na WebView.
@ -72,7 +72,7 @@ SMTP_ENABLE_STARTTLS_AUTO=false
SMTP_TLS=true
MAILER_SENDER_EMAIL="Nome <no-reply@seu-dominio.com>"
# Máquina/inventário
# Dispositivo/inventário
MACHINE_PROVISIONING_SECRET=<hex forte>
MACHINE_TOKEN_TTL_MS=2592000000
FLEET_SYNC_SECRET=<hex forte ou igual ao de provisionamento>
@ -239,7 +239,7 @@ docker run --rm -it \
### Smoke test pósdeploy (CI)
O pipeline executa um teste rápido após o deploy do Web:
- Registra uma máquina fake usando `MACHINE_PROVISIONING_SECRET` do `/srv/apps/sistema/.env`
- Registra uma dispositivo fake usando `MACHINE_PROVISIONING_SECRET` do `/srv/apps/sistema/.env`
- Espera `HTTP 201` e extrai `machineToken`
- Envia `heartbeat` e espera `HTTP 200`
- Se falhar, o job é marcado como erro (evita regressões silenciosas)

View file

@ -59,28 +59,28 @@ Este documento consolida as mudanças recentes, o racional por trás delas e o p
- “Bring convex.json from live app if present” (usa o arquivo de link do projeto em `/srv/apps/sistema`).
- “convex env list” e “convex deploy” com `CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY`.
## 4) Troca de colaborador / reaproveitamento de máquina
## 4) Troca de colaborador / reaproveitamento de dispositivo
Quando um computador muda de dono (ex.: João entrega o equipamento antigo para Maria e recebe uma máquina nova), siga este checklist para manter o inventário consistente:
Quando um computador muda de dono (ex.: João entrega o equipamento antigo para Maria e recebe uma dispositivo nova), siga este checklist para manter o inventário consistente:
1. **No painel (Admin → Máquinas)**
- Abra os detalhes da máquina que será reaproveitada (ex.: a “amarela” que passará da TI/João para a Maria).
1. **No painel (Admin → Dispositivos)**
- Abra os detalhes da dispositivo que será reaproveitada (ex.: a “amarela” que passará da TI/João para a Maria).
- Clique em **Resetar agente**. Isso revoga todos os tokens gerados para aquele equipamento; ele precisará ser reprovisionado antes de voltar a reportar dados.
- Abra **Ajustar acesso** e altere o e-mail para o do novo usuário (Maria). Assim, quando o agente se registrar novamente, o painel já mostrará a responsável correta.
2. **Na máquina física que ficará com o novo colaborador**
2. **Na dispositivo física que ficará com o novo colaborador**
- Desinstale o desktop agent (Painel de Controle → remover programas).
- Instale novamente o desktop agent. Use o mesmo **código da empresa/tenant** e informe o **e-mail do novo usuário** (Maria). O backend emite um token novo e reaproveita o registro da máquina, mantendo o histórico.
- Instale novamente o desktop agent. Use o mesmo **código da empresa/tenant** e informe o **e-mail do novo usuário** (Maria). O backend emite um token novo e reaproveita o registro da dispositivo, mantendo o histórico.
3. **Máquina nova para o colaborador antigo**
- Instale o desktop agent do zero na máquina que o João vai usar (ex.: a “azul”). Utilize o mesmo código da empresa e o e-mail do João.
- A máquina azul aparecerá como um **novo registro** no painel (inventário/tickets começarão do zero). Renomeie/associe conforme necessário.
3. **Dispositivo nova para o colaborador antigo**
- Instale o desktop agent do zero na dispositivo que o João vai usar (ex.: a “azul”). Utilize o mesmo código da empresa e o e-mail do João.
- A dispositivo azul aparecerá como um **novo registro** no painel (inventário/tickets começarão do zero). Renomeie/associe conforme necessário.
4. **Verificação final**
- A máquina antiga (amarela) continua listada, agora vinculada à Maria, com seus tickets históricos.
- A máquina nova (azul) aparece como um segundo registro para o João. Ajuste hostname/descrição para facilitar a identificação.
- A dispositivo antiga (amarela) continua listada, agora vinculada à Maria, com seus tickets históricos.
- A dispositivo nova (azul) aparece como um segundo registro para o João. Ajuste hostname/descrição para facilitar a identificação.
> Não é necessário excluir registros. Cada máquina mantém seu histórico; o reset garante apenas que o token antigo não volte a sobrescrever dados quando o hardware mudar de mãos.
> Não é necessário excluir registros. Cada dispositivo mantém seu histórico; o reset garante apenas que o token antigo não volte a sobrescrever dados quando o hardware mudar de mãos.
- Importante: não usar `CONVEX_DEPLOYMENT` em conjunto com URL + ADMIN_KEY.
- Como forçar o deploy do Convex
@ -161,7 +161,7 @@ Depois disso, o job “Deploy Convex functions” funciona em modo não interati
- Front envia `assigneeId` e o backend Convex deve aceitar esse parâmetro (função `tickets.list`).
- Se necessário, forçar redeploy das funções (`convex/**`).
- Admin ▸ Máquinas travado em skeleton infinito
- Admin ▸ Dispositivos travado em skeleton infinito
- Abra o DevTools (console) e filtre por `admin-machine-details`. Se o log mostrar `machineId: undefined`, o componente não recebeu o parâmetro da rota.
- Verifique se o `page.tsx` está passando `params.id` corretamente ou se o componente client-side usa `useParams()` / `machineId` opcional.
- Deploys antigos antes de `fix(machines): derive machine id from router params` precisam desse patch; sem ele o fallback nunca dispara e o skeleton permanece.
@ -170,30 +170,30 @@ Depois disso, o job “Deploy Convex functions” funciona em modo não interati
Última atualização: sincronizado após o deploy bemsucedido do Convex e do Front (20/10/2025).
## 9) Admin ▸ Usuários e Máquinas — Unificação e UX
## 9) Admin ▸ Usuários e Dispositivos — Unificação e UX
Resumo das mudanças aplicadas no painel administrativo para simplificar “Usuários” e “Agentes de máquina” e melhorar o filtro em Máquinas:
Resumo das mudanças aplicadas no painel administrativo para simplificar “Usuários” e “Agentes de dispositivo” e melhorar o filtro em Dispositivos:
- Unificação de “Usuários” e “Agentes de máquina
- Antes: abas separadas “Usuários” (pessoas) e “Agentes de máquina”.
- Agora: uma só aba “Usuários” com filtro de tipo (Todos | Pessoas | Máquinas).
- Unificação de “Usuários” e “Agentes de dispositivo
- Antes: abas separadas “Usuários” (pessoas) e “Agentes de dispositivo”.
- Agora: uma só aba “Usuários” com filtro de tipo (Todos | Pessoas | Dispositivos).
- Onde: `src/components/admin/admin-users-manager.tsx:923`, aba `value="users"` em `:1147`.
- Motivo: evitar confusão entre “usuário” e “agente”; agentes são um tipo especial de usuário (role=machine). A unificação torna “Convites e Acessos” mais direta.
- Máquinas ▸ Filtro por Empresa com busca e remoção do filtro de SO
- Dispositivos ▸ Filtro por Empresa com busca e remoção do filtro de SO
- Adicionado dropdown de “Empresa” com busca (Popover + Input) e removido o filtro por Sistema Operacional.
- Onde: `src/components/admin/machines/admin-machines-overview.tsx:1038` e `:1084`.
- Onde: `src/components/admin/devices/admin-devices-overview.tsx:1038` e `:1084`.
- Motivo: fluxo real usa empresas com mais frequência; filtro por SO era menos útil agora.
- Windows ▸ Rótulo do sistema sem duplicidade do “major”
- Exemplo: “Windows 11 Pro (26100)” em vez de “Windows 11 Pro 11 (26100)”.
- Onde: `src/components/admin/machines/admin-machines-overview.tsx` (função `formatOsVersionDisplay`).
- Onde: `src/components/admin/devices/admin-devices-overview.tsx` (função `formatOsVersionDisplay`).
- Motivo: legibilidade e padronização em chips/cartões.
- Vínculos visuais entre máquinas e pessoas
- Cards de máquinas mostram “Usuário vinculado” quando disponível (assignment/metadata): `src/components/admin/machines/admin-machines-overview.tsx:3198`.
- Editor de usuário exibe “Máquinas vinculadas” (derivado de assign/metadata): `src/components/admin/admin-users-manager.tsx` (seção “Máquinas vinculadas” no sheet de edição).
- Observação: por ora é leitura; ajustes detalhados de vínculo permanecem em Admin ▸ Máquinas.
- Vínculos visuais entre dispositivos e pessoas
- Cards de dispositivos mostram “Usuário vinculado” quando disponível (assignment/metadata): `src/components/admin/devices/admin-devices-overview.tsx:3198`.
- Editor de usuário exibe “Dispositivos vinculadas” (derivado de assign/metadata): `src/components/admin/admin-users-manager.tsx` (seção “Dispositivos vinculadas” no sheet de edição).
- Observação: por ora é leitura; ajustes detalhados de vínculo permanecem em Admin ▸ Dispositivos.
### Identidade, email e histórico (reinstalação)
@ -202,20 +202,20 @@ Resumo das mudanças aplicadas no painel administrativo para simplificar “Usu
- Novo email como nova conta: se criar um usuário novo (novo `userId`), será considerado um colaborador distinto e não herdará o histórico.
- Caso precise migrar histórico entre contas diferentes (merge), recomendamos endpoint/rotina de “fusão de contas” (remapear `userId` antigo → novo). Não é necessário para a troca de email da mesma conta.
### Vínculos múltiplos de usuários por máquina (Fase 2)
### Vínculos múltiplos de usuários por dispositivo (Fase 2)
- Estrutura (Convex):
- `machines.linkedUserIds: Id<"users">[]` — lista de vínculos adicionais além do `assignedUserId` (principal).
- Mutations: `machines.linkUser(machineId, email)`, `machines.unlinkUser(machineId, userId)`.
- APIs admin: `POST /api/admin/machines/links` (body: `{ machineId, email }`), `DELETE /api/admin/machines/links?machineId=..&userId=..`.
- APIs admin: `POST /api/admin/devices/links` (body: `{ machineId, email }`), `DELETE /api/admin/devices/links?machineId=..&userId=..`.
- UI:
- Detalhes da máquina mostram “Usuários vinculados” com remoção por item e campo para adicionar por email.
- Editor de usuário mostra “Máquinas vinculadas” consolidando assignment, metadata e `linkedUserIds`.
- Racional: permitir que uma máquina tenha mais de um colaborador/gestor associado, mantendo um “principal” (persona) para políticas e contexto.
- Detalhes da dispositivo mostram “Usuários vinculados” com remoção por item e campo para adicionar por email.
- Editor de usuário mostra “Dispositivos vinculadas” consolidando assignment, metadata e `linkedUserIds`.
- Racional: permitir que uma dispositivo tenha mais de um colaborador/gestor associado, mantendo um “principal” (persona) para políticas e contexto.
### Onde editar
- Usuários (pessoas): editar nome, email, papel, tenant e empresa; redefinir senha pelo painel. Arquivo: `src/components/admin/admin-users-manager.tsx`.
- Agentes (máquinas): provisionamento automático; edição detalhada/vínculo principal em Admin ▸ Máquinas. Arquivo: `src/components/admin/machines/admin-machines-overview.tsx`.
- Agentes (dispositivos): provisionamento automático; edição detalhada/vínculo principal em Admin ▸ Dispositivos. Arquivo: `src/components/admin/devices/admin-devices-overview.tsx`.
> Observação operacional: mantivemos o provisionamento de máquinas inalterado (token/email técnico), e o acesso web segue apenas para pessoas. A unificação é de UX/gestão.
> Observação operacional: mantivemos o provisionamento de dispositivos inalterado (token/email técnico), e o acesso web segue apenas para pessoas. A unificação é de UX/gestão.

View file

@ -1,6 +1,6 @@
# Admin UI — Inventário por máquina
# Admin UI — Inventário por dispositivo
A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável do parque, com filtros e exportação.
A página Admin > Dispositivos agora exibe um inventário detalhado e pesquisável do parque, com filtros e exportação.
## Filtros e busca
- Busca livre por hostname, e-mail, MAC e número de série.
@ -19,15 +19,15 @@ A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável
- Windows: informações de SO (edição, versão, build, data de instalação, experiência, ativação), resumo de hardware (CPU/Memória/GPU/Discos físicos com suporte a payloads únicos), serviços, lista completa de softwares com ação “Ver todos”, Defender.
- macOS: pacotes (`pkgutil`), serviços (`launchctl`).
- Postura/Alertas: CPU alta, serviço parado, SMART em falha com severidade e última avaliação.
- Zona perigosa: ação para excluir a máquina (revoga tokens e remove inventário).
- Ação administrativa extra: botão “Ajustar acesso” permite trocar colaborador/gestor e e-mail vinculados sem re-provisionar a máquina.
- Zona perigosa: ação para excluir a dispositivo (revoga tokens e remove inventário).
- Ação administrativa extra: botão “Ajustar acesso” permite trocar colaborador/gestor e e-mail vinculados sem re-provisionar a dispositivo.
## Exportação
- Exportar CSV de softwares ou serviços diretamente da seção detalhada (quando disponíveis).
- Exportar planilha XLSX completa (`/admin/machines/:id/inventory.xlsx`). A partir de 31/10/2025 a planilha contém:
- Exportar planilha XLSX completa (`/admin/devices/:id/inventory.xlsx`). A partir de 31/10/2025 a planilha contém:
- **Resumo**: data de geração, filtros aplicados, contagem por status e total de acessos remotos/alertas.
- **Inventário**: colunas principais exibidas na UI (status, persona, hardware, token, build/licença do SO, domínio, colaborador, Fleet, etc.).
- **Vínculos**: usuários associados à máquina.
- **Vínculos**: usuários associados à dispositivo.
- **Softwares**: lista deduplicada (nome + versão + origem/publisher). A coluna “Softwares instalados” no inventário bate com o total desta aba.
- **Partições**: nome/mount/FS/capacidade/livre, convertendo unidades (ex.: 447 GB → bytes).
- **Discos físicos**: modelo, tamanho, interface, tipo e serial de cada drive.
@ -36,7 +36,7 @@ A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável
- **Serviços**: serviços coletados (Windows/Linux) com nome, display name e status.
- **Alertas**: postura recente (tipo, mensagem, severidade, criado em).
- **Métricas**: CPU/Memória/Disco/GPU com timestamp coletado.
- **Labels**: tags aplicadas à máquina.
- **Labels**: tags aplicadas à dispositivo.
- **Sistema**: visão categorizada (Sistema, Dispositivo, Hardware, Acesso, Token, Fleet) contendo build, licença, domínio, fabricante, serial, colaborador, contagem de acessos, etc.
## Notas

View file

@ -24,7 +24,7 @@ Este documento resume a ampliação do cadastro de empresas (dados fiscais, cont
- **Nova UI de Empresas** (`AdminCompaniesManager`): substituir pelo layout com listagem filtrável (lista + quadro) e formulário seccional ligado a `companyFormSchema` / `sanitizeCompanyInput`.
- **Form dinâmico**: montar o formulário com React Hook Form + zod resolver usando os schemas/validações já prontos no backend.
- **Área de Clientes → Usuários**: renomear a seção, carregar os novos campos (contatos, localizações, contratos) e reaproveitar as transformações do serviço.
- **Máquinas**: expor o novo identificador de acesso remoto previsto no schema Convex/Prisma.
- **Dispositivos**: expor o novo identificador de acesso remoto previsto no schema Convex/Prisma.
- **Qualidade**: ajustar lint/testes após a nova UI e cobrir o fluxo de criação/edição com testes de integração.
> Até que a nova interface seja publicada, a API já aceita todos os campos e qualquer cliente (front, automação, seed) deve usar `company-service.ts` para converter dados de/para Prisma, evitando divergências.

View file

@ -0,0 +1,27 @@
# Alterações — 03/11/2025
## Concluído
- [x] Estruturado backend para `Dispositivos`: novos campos no Convex (`deviceType`, `deviceProfile`, custom fields), mutations (`saveDeviceProfile`, `saveDeviceCustomFields`) e tabelas auxiliares (`deviceFields`, `deviceExportTemplates`).
- [x] Refatorado gerador de inventário XLSX para suportar seleção dinâmica de colunas, campos personalizados e nomenclatura de dispositivos.
- [x] Renomeado "Máquinas" → "Dispositivos" em toda a navegação, rotas, botões (incluindo destaque superior) e mensagens de erro.
- [x] UI do painel ajustada com criação manual de dispositivos, gerenciamento de campos personalizados, templates de exportação e inclusão de dispositivos móveis.
- [x] Fluxo de CSAT revisado: mutation dedicada, timeline enriquecida, formulário de estrelas apenas para solicitante e dashboards com novos filtros/combobox.
- [x] Diálogo de encerramento de ticket com vínculo opcional a outro ticket, prazo configurável de reabertura (7 ou 14 dias) e mensagem pré-visualizada.
- [x] Botão de reabrir disponível para solicitante/equipe até o fim do prazo; timeline registra `TICKET_REOPENED`.
- [x] Chat em tempo real incorporado ao detalhe do ticket (listagem live, envio, leitura automática, bloqueio pós-prazo).
- [x] Formulários dinâmicos para admissão/desligamento com escopo e permissões por empresa/usuário; `create` envia `formTemplate` e `customFields`.
- [x] Corrigidos mocks/tipagens das rotinas de resolução e reabertura (`resolveTicketHandler`, `reopenTicketHandler`) garantindo `pnpm lint`, `pnpm test` e `pnpm build` verdes.
- [x] Atualizado schema/tipagens (`TicketWithDetails`, `ChartTooltipContent`) e dashboards CSAT para suportar reabertura com prazos e tooltips formatados.
- [x] Reatribuição de chamado sem motivo obrigatório; comentário interno só é criado quando o motivo é preenchido.
- [x] Botão “Novo dispositivo” reutiliza o mesmo primário padrão do shadcn usado em “Nova empresa”, mantendo a identidade visual.
- [x] Cartão de CSAT respeita a role normalizada (inclusive em sessões de dispositivos), só aparece para a equipe após o início do atendimento e mostra aviso quando ainda não há avaliações.
- [x] Dashboard de abertos x resolvidos usa buscas indexadas por data, evitando timeouts no Convex.
## Riscos
- Necessário validar migração dos dados existentes (máquinas → dispositivos) antes de entrar em produção.
- Testes de SMTP/entregabilidade precisam ser executados para garantir que notificações sigam as novas regras de pausa/comentário.
## Pendências
- [ ] Validar comportamento de notificações (pausa/comentário) com infraestrutura de e-mail real.
- [ ] Executar migração de dados existente antes do deploy (mapear máquinas → dispositivos e revisar templates legados).
- [ ] Cobertura de testes automatizados para chat e formulários dinâmicos (resolve/reopen já cobertos).

View file

@ -1,11 +1,11 @@
# Plano Integrado App Desktop & Inventário por Máquina (Arquivo)
# Plano Integrado App Desktop & Inventário por Dispositivo (Arquivo)
> Documento vivo. Atualize após cada marco relevante.
## Contexto
- **Objetivo:** Expandir o Sistema de Chamados (Next.js + Convex + Better Auth) para suportar:
- Cliente desktop nativo (Tauri) mantendo UI web e realtime.
- Autenticação máquina-a-máquina usando tokens derivados do inventário.
- Autenticação dispositivo-a-dispositivo usando tokens derivados do inventário.
- Integração com agente de inventário (osquery/FleetDM) para registrar hardware, software e heartbeats.
- Pipeline de distribuição para Windows/macOS/Linux.
- **Escopo inicial:** Focar no fluxo mínimo viável com inventário básico (hostname, OS, identificadores, carga resumida). Métricas avançadas e distribuição automatizada ficam para iteração seguinte.
@ -21,12 +21,12 @@
| --- | --- | --- |
| Documento de arquitetura e roadmap | 🔄 Em andamento | Estrutura criada, aguardando detalhamento incremental a cada etapa. |
| Projeto Tauri inicial apontando para UI Next | 🔄 Em andamento | Estrutura `apps/desktop` criada; pendente testar build após instalar toolchain Rust. |
| Schema Convex + tokens de máquina | ✅ Concluído | Tabelas `machines` / `machineTokens` criadas com TTL e fingerprint. |
| Schema Convex + tokens de dispositivo | ✅ Concluído | Tabelas `machines` / `machineTokens` criadas com TTL e fingerprint. |
| API de registro/heartbeat e exchange Better Auth | 🔄 Em andamento | Endpoints `/api/machines/*` disponíveis; falta testar fluxo end-to-end com app desktop. |
| Endpoint upsert de inventário dedicado | ✅ Concluído | `POST /api/machines/inventory` (modo por token ou provisioningSecret). |
| Integração FleetDM → Convex (inventário básico) | 🔄 Em andamento | Endpoint `/api/integrations/fleet/hosts` criado; falta validar payload real e ajustes de métricas/empresa. |
| Admin > Máquinas (listagem, detalhes, métricas) | ✅ Concluído | Página `/admin/machines` exibe parque completo com status ao vivo, inventário e métricas. |
| Ajustes na UI/Next para sessão por máquina | ⏳ A fazer | Detectar token e exibir info da máquina em tickets. |
| Admin > Dispositivos (listagem, detalhes, métricas) | ✅ Concluído | Página `/admin/devices` exibe parque completo com status ao vivo, inventário e métricas. |
| Ajustes na UI/Next para sessão por dispositivo | ⏳ A fazer | Detectar token e exibir info da dispositivo em tickets. |
| Pipeline de build/distribuição Tauri | ⏳ A fazer | Definir estratégia CI/CD + auto-update. |
| Guia operacional (instalação, uso, suporte) | ⏳ A fazer | Gerar instruções finais com casos de uso. |
@ -48,23 +48,23 @@ Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer.
## Notas de Implementação (Atual)
- Criada pasta `apps/desktop` via `create-tauri-app` com template `vanilla-ts`.
- O agente desktop agora possui fluxo próprio: coleta inventário local via comandos Rust, solicita o código de provisionamento, registra a máquina e inicia heartbeats periódicos (`src-tauri/src/agent.rs` + `src/main.ts`).
- O agente desktop agora possui fluxo próprio: coleta inventário local via comandos Rust, solicita o código de provisionamento, registra a dispositivo e inicia heartbeats periódicos (`src-tauri/src/agent.rs` + `src/main.ts`).
- Formulário inicial exibe resumo de hardware/OS e salva o token em `~/.config/Sistema de Chamados Desktop/machine-agent.json` (ou equivalente por SO) para reaproveitamento em relançamentos.
- URLs configuráveis via `.env` do app desktop:
- `VITE_APP_URL` → aponta para a interface Next (padrao produção: `https://tickets.esdrasrenan.com.br`).
- `VITE_API_BASE_URL` → base usada nas chamadas REST (`/api/machines/*`), normalmente igual ao `APP_URL`.
- Após provisionar ou encontrar token válido, o agente dispara `/machines/handshake?token=...` que autentica a máquina no Better Auth, devolve cookies e redireciona para a UI. Em produção, mantemos a navegação toplevel pelo handshake para garantir a aceitação de cookies na WebView (mesmo quando `POST /api/machines/sessions` é tentado antes).
- Após provisionar ou encontrar token válido, o agente dispara `/machines/handshake?token=...` que autentica a dispositivo no Better Auth, devolve cookies e redireciona para a UI. Em produção, mantemos a navegação toplevel pelo handshake para garantir a aceitação de cookies na WebView (mesmo quando `POST /api/machines/sessions` é tentado antes).
- `apps/desktop/src-tauri/tauri.conf.json` ajustado para rodar `pnpm run dev/build`, servir `dist/` e abrir janela 1100x720.
- Novas tabelas Convex: `machines` (fingerprint, heartbeat, vínculo com AuthUser) e `machineTokens` (hash + TTL).
- Novos endpoints Next:
- `POST /api/machines/register` — provisiona máquina, gera token e usuário Better Auth (role `machine`).
- `POST /api/machines/register` — provisiona dispositivo, gera token e usuário Better Auth (role `machine`).
- `POST /api/machines/heartbeat` — atualiza estado, métricas e renova TTL.
- `POST /api/machines/sessions` — troca `machineToken` por sessão Better Auth e devolve cookies.
- As rotas `/api/machines/*` respondem a preflight `OPTIONS` com CORS liberado para o agente (`https://tickets.esdrasrenan.com.br`, `tauri://localhost`, `http://localhost:1420`).
- Rota `GET /machines/handshake` realiza o login automático da máquina (seta cookies e redireciona).
- Rota `GET /machines/handshake` realiza o login automático da dispositivo (seta cookies e redireciona).
- As rotas `sessions/handshake` foram ajustadas para usar `NextResponse.cookies.set(...)`, aplicando cada cookie da Better Auth (sessão e assinatura) individualmente.
- CORS: as respostas incluem `Access-Control-Allow-Credentials: true` para origens permitidas (Tauri WebView e app).
- Página de diagnóstico: `/portal/debug` exibe `get-session` e `machines/session` com os mesmos cookies da aba — útil para validar se o desktop está autenticado como máquina.
- Página de diagnóstico: `/portal/debug` exibe `get-session` e `machines/session` com os mesmos cookies da aba — útil para validar se o desktop está autenticado como dispositivo.
- O desktop pode redirecionar automaticamente para essa página durante os testes.
- Webhook FleetDM: `POST /api/integrations/fleet/hosts` (header `x-fleet-secret`) sincroniza inventário/métricas utilizando `machines.upsertInventory`.
- Script `ensureMachineAccount` garante usuário `AuthUser` e senha sincronizada com o token atual.
@ -73,7 +73,7 @@ Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer.
- O agente coleta `extended.windows.osInfo` via PowerShell/WMI. Caso o script falhe (política ou permissão), aplicamos um fallback com `sysinfo` para preencher ao menos `ProductName`, `Version` e `BuildNumber` — evitando bloco vazio no inventário exibido.
### Portal do Cliente (UX)
- Quando autenticado como máquina (colaborador/gestor):
- Quando autenticado como dispositivo (colaborador/gestor):
- O portal deriva o papel a partir do `machineContext` (mesmo se `/api/auth/get-session` vier `null`).
- A listagem exibe apenas os tickets onde o colaborador é o solicitante.
- Informações internas (Fila, Prioridade) são ocultadas; o responsável aparece quando definido.
@ -81,7 +81,7 @@ Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer.
- O botão “Sair” é ocultado no desktop (faz sentido apenas fechar o app).
- Variáveis `.env` novas: `MACHINE_PROVISIONING_SECRET` (obrigatória) e `MACHINE_TOKEN_TTL_MS` (opcional, padrão 30 dias).
- Variável adicional `FLEET_SYNC_SECRET` (opcional) para autenticar webhook do Fleet; se ausente, reutiliza `MACHINE_PROVISIONING_SECRET`.
- Dashboard administrativo: `/admin/machines` usa `AdminMachinesOverview` com dados em tempo real (status, heartbeat, token, inventário enviado pelo agente/Fleet).
- Dashboard administrativo: `/admin/devices` usa `AdminMachinesOverview` com dados em tempo real (status, heartbeat, token, inventário enviado pelo agente/Fleet).
### Checklist de dependências Tauri (Linux)
```bash

View file

@ -13,8 +13,8 @@ Documento de referência sobre o estado atual do sistema (web + desktop), melhor
| Item | Descrição | Impacto |
| --- | --- | --- |
| **Centralização Convex** | Helpers `createConvexClient` e normalização do cookie da máquina (`src/server/convex-client.ts`, `src/server/machines/context.ts`). | Código das rotas `/api/machines/*` ficou mais enxuto e resiliente a erros de configuração. |
| **Auth/Login redirect** | Redirecionamento baseado em role/persona sem uso de `any`, com dependências explícitas (`src/app/login/login-page-client.tsx`). | Evita warnings de hooks e garante rota correta para máquinas/colaboradores. |
| **Centralização Convex** | Helpers `createConvexClient` e normalização do cookie da dispositivo (`src/server/convex-client.ts`, `src/server/machines/context.ts`). | Código das rotas `/api/machines/*` ficou mais enxuto e resiliente a erros de configuração. |
| **Auth/Login redirect** | Redirecionamento baseado em role/persona sem uso de `any`, com dependências explícitas (`src/app/login/login-page-client.tsx`). | Evita warnings de hooks e garante rota correta para dispositivos/colaboradores. |
| **Ticket header** | Sincronização do responsável com dependências completas (`ticket-summary-header.tsx`). | Removeu warning do lint e previne estados inconsistentes. |
| **Upgrade para Next.js 16 beta** | Dependências atualizadas (`next@16.0.0-beta.0`, `eslint-config-next@16.0.0-beta.0`), cache de filesystem do Turbopack habilitado, scripts de lint/test/build ajustados ao novo fluxo. | Projeto pronto para validar as novidades do Next 16 (React Compiler opcional, prefetch incremental, etc.); builds e testes já rodando com sucesso. |
| **Posture / inventário** | Type guards e normalização de métricas SMART/serviços (`convex/machines.ts`). | Reduziu `any`, melhorou detecção de alertas e consistência do metadata. |
@ -39,7 +39,7 @@ Documento de referência sobre o estado atual do sistema (web + desktop), melhor
| Cenário | Sintoma | Como resolver |
| --- | --- | --- |
| Token de máquina revogado | POST `/api/machines/sessions` retorna 401 e desktop volta ao onboarding | Reprovisionar pela UI do agente; garantir que `machineToken` foi atualizado. |
| Token de dispositivo revogado | POST `/api/machines/sessions` retorna 401 e desktop volta ao onboarding | Reprovisionar pela UI do agente; garantir que `machineToken` foi atualizado. |
| Falha de heartbeat | Logs com `Falha ao registrar heartbeat` + status 500 | Verificar `NEXT_PUBLIC_CONVEX_URL` e conectividade. Roda `pnpm convext:dev` em DEV para confirmar schema. |
| Updater sem atualização | Desktop fica em “Procurando atualização” indefinidamente | Confirmar release publicado com `latest.json` apontando para URLs públicas do bundle e assinaturas válidas. |
@ -47,6 +47,6 @@ Documento de referência sobre o estado atual do sistema (web + desktop), melhor
- Monitorar execução do novo workflow de qualidade em PRs.
- Garantir que a equipe esteja ciente do procedimento atualizado de deploy (symlink + service update).
- Revisar backlog acima e priorizar smoke tests para o fluxo da máquina.
- Revisar backlog acima e priorizar smoke tests para o fluxo da dispositivo.
_Última atualização: 16/10/2025 (UTC-3)._

View file

@ -1,9 +1,9 @@
# Desktop (Tauri) — Handshake, Sessão de Máquina e Antivírus
# Desktop (Tauri) — Handshake, Sessão de Dispositivo e Antivírus
Este documento consolida as orientações e diagnósticos sobre o fluxo do agente desktop, handshake na web e possíveis interferências de antivírus.
## Sintomas observados
- Ao clicar em “Registrar máquina”, o antivírus aciona (ex.: ATC.SuspiciousBehavior) e o processo é interrompido.
- Ao clicar em “Registrar dispositivo”, o antivírus aciona (ex.: ATC.SuspiciousBehavior) e o processo é interrompido.
- Após o registro, ao abrir a UI web: cabeçalho mostra “Cliente / Sem email definido” e o Portal não permite abrir chamados.
- No passado, mesmo quando o app “entrava direto”, o Portal não refletia o colaborador/gestor vinculado (sem assignedUser); receio de repetir o problema.
@ -19,11 +19,11 @@ Este documento consolida as orientações e diagnósticos sobre o fluxo do agent
## O que já foi aplicado no projeto
- Middleware permite `GET /machines/handshake` sem exigir login (rota pública).
- Frontend preenche `machineContext` chamando `GET /api/machines/session` (assignedUserId/email/nome/persona) e usa esse ID ao abrir chamados.
- UI oculta “Sair” quando a sessão é de máquina (portal e shell interno).
- UI oculta “Sair” quando a sessão é de dispositivo (portal e shell interno).
- DevTools habilitado no desktop (F12, Ctrl+Shift+I ou botão direito com Ctrl/Shift).
- Desktop salva dados em `C:\\Raven\\data\\machine-agent.json` (ou equivalente ao lado do executável), com fallback para AppData se a pasta do app não permitir escrita.
## Validação rápida (após “Registrar máquina”)
## Validação rápida (após “Registrar dispositivo”)
1) No executável, com DevTools:
```js
fetch('/api/machines/session').then(r => r.status).then(console.log)
@ -33,7 +33,7 @@ Este documento consolida as orientações e diagnósticos sobre o fluxo do agent
```
2) Na UI (Portal/Topo):
- Mostrar nome/email do colaborador/gestor (não “Cliente / Sem email definido”).
- Sem botão “Sair” (sessão de máquina).
- Sem botão “Sair” (sessão de dispositivo).
3) No Portal, o formulário “Abrir chamado” deve habilitar normalmente (usa `machineContext.assignedUserId`).
Se `GET /api/machines/session` retornar 403:
@ -68,4 +68,4 @@ Se `GET /api/machines/session` retornar 403:
---
Última atualização: automatização do handshake no middleware, ocultação de “Sair” em sessão de máquina, dados persistidos junto ao executável e DevTools habilitado.
Última atualização: automatização do handshake no middleware, ocultação de “Sair” em sessão de dispositivo, dados persistidos junto ao executável e DevTools habilitado.

View file

@ -12,7 +12,7 @@ Este guia consolida tudo o que precisa ser feito para que o auto-update do Tauri
```
- Privada: `~/.tauri/raven.key` (nunca compartilhar)
- Pública: `~/.tauri/raven.key.pub` (cole em `tauri.conf.json > plugins.updater.pubkey`)
- Se for buildar em outra máquina (ex.: Windows), copie os dois arquivos para `C:\Users\<usuario>\.tauri\raven.key(.pub)`.
- Se for buildar em outra dispositivo (ex.: Windows), copie os dois arquivos para `C:\Users\<usuario>\.tauri\raven.key(.pub)`.
2. **Verificar o `tauri.conf.json`**
```json

View file

@ -61,8 +61,8 @@ Referências úteis:
- Abrir no navegador padrão com `openUrl` (`apps/desktop/src/main.ts:694`).
- Se necessário, limpar Store via botão "Reprovisionar" (Configurações) ou removendo o arquivo `machine-agent.json` no diretório de dados do app.
- Mensagem de erro genérica no desktop:
- Antes: "Erro desconhecido ao registrar a máquina".
- Agora: exibe `Falha ao registrar máquina (STATUS): mensagem — detalhes` (quando disponíveis), facilitando diagnóstico.
- Antes: "Erro desconhecido ao registrar a dispositivo".
- Agora: exibe `Falha ao registrar dispositivo (STATUS): mensagem — detalhes` (quando disponíveis), facilitando diagnóstico.
## Provisionamento — segredo e boas práticas
- Variável: `MACHINE_PROVISIONING_SECRET` (VPS/Convex backend).
@ -74,7 +74,7 @@ Referências úteis:
docker service update --force sistema_convex_backend
```
3. Validar com `POST /api/machines/register` (esperado 201).
- Máquinas já registradas não são afetadas (token delas continua válido).
- Dispositivos já registradas não são afetadas (token delas continua válido).
## Pendências e próximos passos
- Mapear erros "esperados" para HTTP adequado no web (Next):
@ -90,9 +90,9 @@ Referências úteis:
## Checklist rápido de verificação (QA)
- `.env` do desktop contém apenas `VITE_APP_URL` e `VITE_API_BASE_URL` apontando para produção.
- Primeiro registro sem empresa retorna 201 e aparece "Máquina provisionada" nas abas.
- Primeiro registro sem empresa retorna 201 e aparece "Dispositivo provisionada" nas abas.
- "Ambiente" e "API" em Configurações exibem `https://tickets.esdrasrenan.com.br`.
- "Abrir sistema" abre o navegador com `/machines/handshake?token=...` e loga a máquina.
- "Abrir sistema" abre o navegador com `/machines/handshake?token=...` e loga a dispositivo.
- Reprovisionar limpa a Store e volta ao formulário inicial.
---

View file

@ -0,0 +1,20 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { AdminDeviceDetailsClient } from "@/components/admin/devices/admin-device-details.client"
import { DeviceBreadcrumbs } from "@/components/admin/devices/device-breadcrumbs.client"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export default async function AdminDeviceDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return (
<AppShell header={<SiteHeader title="Detalhe do dispositivo" lead="Inventário e métricas do dispositivo selecionado." />}>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<DeviceBreadcrumbs tenantId={DEFAULT_TENANT_ID} deviceId={id} />
<AdminDeviceDetailsClient tenantId={DEFAULT_TENANT_ID} deviceId={id} />
</div>
</AppShell>
)
}

View file

@ -0,0 +1,26 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { DeviceBreadcrumbs } from "@/components/admin/devices/device-breadcrumbs.client"
import { DeviceTicketsHistoryClient } from "@/components/admin/devices/device-tickets-history.client"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export default async function AdminDeviceTicketsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return (
<AppShell header={<SiteHeader title="Tickets do dispositivo" lead="Histórico completo de chamados vinculados ao dispositivo." />}>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<DeviceBreadcrumbs
tenantId={DEFAULT_TENANT_ID}
deviceId={id}
deviceHref={`/admin/devices/${id}`}
extra={[{ label: "Tickets" }]}
/>
<DeviceTicketsHistoryClient tenantId={DEFAULT_TENANT_ID} deviceId={id} />
</div>
</AppShell>
)
}

View file

@ -1,12 +1,12 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { AdminMachinesOverview } from "@/components/admin/machines/admin-machines-overview"
import { AdminDevicesOverview } from "@/components/admin/devices/admin-devices-overview"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export default async function AdminMachinesPage({
export default async function AdminDevicesPage({
searchParams,
}: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const params = await searchParams
@ -16,13 +16,13 @@ export default async function AdminMachinesPage({
<AppShell
header={
<SiteHeader
title="Parque de máquinas"
lead="Acompanhe quais dispositivos estão ativos, métricas recentes e a sincronização do agente."
title="Parque de dispositivos"
lead="Acompanhe computadores e celulares monitorados, métricas recentes e a sincronização do agente."
/>
}
>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<AdminMachinesOverview tenantId={DEFAULT_TENANT_ID} initialCompanyFilterSlug={company ?? "all"} />
<AdminDevicesOverview tenantId={DEFAULT_TENANT_ID} initialCompanyFilterSlug={company ?? "all"} />
</div>
</AppShell>
)

View file

@ -1,20 +0,0 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { AdminMachineDetailsClient } from "@/components/admin/machines/admin-machine-details.client"
import { MachineBreadcrumbs } from "@/components/admin/machines/machine-breadcrumbs.client"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export default async function AdminMachineDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return (
<AppShell header={<SiteHeader title="Detalhe da máquina" lead="Inventário e métricas da máquina selecionada." />}>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<MachineBreadcrumbs tenantId={DEFAULT_TENANT_ID} machineId={id} />
<AdminMachineDetailsClient tenantId={DEFAULT_TENANT_ID} machineId={id} />
</div>
</AppShell>
)
}

View file

@ -1,26 +0,0 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { MachineBreadcrumbs } from "@/components/admin/machines/machine-breadcrumbs.client"
import { MachineTicketsHistoryClient } from "@/components/admin/machines/machine-tickets-history.client"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export default async function AdminMachineTicketsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return (
<AppShell header={<SiteHeader title="Tickets da máquina" lead="Histórico completo de chamados vinculados à máquina." />}>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<MachineBreadcrumbs
tenantId={DEFAULT_TENANT_ID}
machineId={id}
machineHref={`/admin/machines/${id}`}
extra={[{ label: "Tickets" }]}
/>
<MachineTicketsHistoryClient tenantId={DEFAULT_TENANT_ID} machineId={id} />
</div>
</AppShell>
)
}

View file

@ -10,7 +10,7 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
const client = createConvexClient()
const { id } = await ctx.params
const machineId = id as Id<"machines">
const data = (await client.query(api.machines.getById, { id: machineId, includeMetadata: true })) as unknown
const data = (await client.query(api.devices.getById, { id: machineId, includeMetadata: true })) as unknown
if (!data) return NextResponse.json({ error: "Not found" }, { status: 404 })
return NextResponse.json(data, { status: 200 })
} catch (err) {

View file

@ -36,13 +36,13 @@ export async function GET(_request: Request, context: RouteContext) {
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
try {
const machine = (await client.query(api.machines.getById, {
const machine = (await client.query(api.devices.getById, {
id: machineId,
includeMetadata: true,
})) as MachineInventoryRecord | null
if (!machine || machine.tenantId !== tenantId) {
return NextResponse.json({ error: "Máquina não encontrada" }, { status: 404 })
return NextResponse.json({ error: "Dispositivo não encontrada" }, { status: 404 })
}
const workbook = buildMachinesInventoryWorkbook([machine], {
@ -64,6 +64,6 @@ export async function GET(_request: Request, context: RouteContext) {
})
} catch (error) {
console.error("Failed to export machine inventory", error)
return NextResponse.json({ error: "Falha ao gerar planilha da máquina" }, { status: 500 })
return NextResponse.json({ error: "Falha ao gerar planilha da dispositivo" }, { status: 500 })
}
}

View file

@ -38,7 +38,7 @@ export async function POST(request: Request) {
const client = new ConvexHttpClient(convexUrl)
try {
const machine = (await client.query(api.machines.getContext, {
const machine = (await client.query(api.devices.getContext, {
machineId: parsed.machineId as Id<"machines">,
})) as {
id: string
@ -47,7 +47,7 @@ export async function POST(request: Request) {
} | null
if (!machine) {
return NextResponse.json({ error: "Máquina não encontrada" }, { status: 404 })
return NextResponse.json({ error: "Dispositivo não encontrada" }, { status: 404 })
}
const tenantId = machine.tenantId ?? session.user.tenantId ?? DEFAULT_TENANT_ID
@ -61,7 +61,7 @@ export async function POST(request: Request) {
companyId: machine.companyId ? (machine.companyId as Id<"companies">) : undefined,
})) as { _id?: Id<"users"> } | null
await client.mutation(api.machines.updatePersona, {
await client.mutation(api.devices.updatePersona, {
machineId: parsed.machineId as Id<"machines">,
persona: parsed.persona,
assignedUserId: ensuredUser?._id,
@ -73,6 +73,6 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true })
} catch (error) {
console.error("[machines.access]", error)
return NextResponse.json({ error: "Falha ao atualizar acesso da máquina" }, { status: 500 })
return NextResponse.json({ error: "Falha ao atualizar acesso da dispositivo" }, { status: 500 })
}
}

View file

@ -28,7 +28,7 @@ vi.mock("@/lib/auth-server", () => ({
assertAuthenticatedSession: assertAuthenticatedSession,
}))
describe("POST /api/admin/machines/delete", () => {
describe("POST /api/admin/devices/delete", () => {
const originalEnv = process.env.NEXT_PUBLIC_CONVEX_URL
let restoreConsole: (() => void) | undefined
@ -65,7 +65,7 @@ describe("POST /api/admin/machines/delete", () => {
it("returns ok when the machine removal succeeds", async () => {
const { POST } = await import("./route")
const response = await POST(
new Request("http://localhost/api/admin/machines/delete", {
new Request("http://localhost/api/admin/devices/delete", {
method: "POST",
body: JSON.stringify({ machineId: "jn_machine" }),
})
@ -81,13 +81,13 @@ describe("POST /api/admin/machines/delete", () => {
it("still succeeds when the Convex machine is already missing", async () => {
mutationMock.mockImplementation(async (_ctx, payload) => {
if (payload && typeof payload === "object" && "machineId" in payload) {
throw new Error("Máquina não encontrada")
throw new Error("Dispositivo não encontrada")
}
return { _id: "user_123" }
})
const { POST } = await import("./route")
const response = await POST(
new Request("http://localhost/api/admin/machines/delete", {
new Request("http://localhost/api/admin/devices/delete", {
method: "POST",
body: JSON.stringify({ machineId: "jn_machine" }),
})
@ -107,14 +107,14 @@ describe("POST /api/admin/machines/delete", () => {
})
const { POST } = await import("./route")
const response = await POST(
new Request("http://localhost/api/admin/machines/delete", {
new Request("http://localhost/api/admin/devices/delete", {
method: "POST",
body: JSON.stringify({ machineId: "jn_machine" }),
})
)
expect(response.status).toBe(500)
await expect(response.json()).resolves.toEqual({ error: "Falha ao remover máquina no Convex" })
await expect(response.json()).resolves.toEqual({ error: "Falha ao remover dispositivo no Convex" })
expect(deleteManyMock).not.toHaveBeenCalled()
})
})

View file

@ -50,17 +50,17 @@ export async function POST(request: Request) {
let machineMissing = false
try {
await convex.mutation(api.machines.remove, {
await convex.mutation(api.devices.remove, {
machineId: parsed.data.machineId as Id<"machines">,
actorId,
})
} catch (error) {
const message = error instanceof Error ? error.message : ""
if (message.includes("Máquina não encontrada")) {
if (message.includes("Dispositivo não encontrada")) {
machineMissing = true
} else {
console.error("[machines.delete] Convex failure", error)
return NextResponse.json({ error: "Falha ao remover máquina no Convex" }, { status: 500 })
return NextResponse.json({ error: "Falha ao remover dispositivo no Convex" }, { status: 500 })
}
}
@ -70,6 +70,6 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true, machineMissing })
} catch (error) {
console.error("[machines.delete] Falha ao excluir", error)
return NextResponse.json({ error: "Falha ao excluir máquina" }, { status: 500 })
return NextResponse.json({ error: "Falha ao excluir dispositivo" }, { status: 500 })
}
}

View file

@ -27,7 +27,7 @@ export async function POST(request: Request) {
const client = new ConvexHttpClient(convexUrl)
try {
await client.mutation(api.machines.linkUser, {
await client.mutation(api.devices.linkUser, {
machineId: parsed.machineId as Id<"machines">,
email: parsed.email,
})
@ -53,7 +53,7 @@ export async function DELETE(request: Request) {
const client = new ConvexHttpClient(convexUrl)
try {
await client.mutation(api.machines.unlinkUser, {
await client.mutation(api.devices.unlinkUser, {
machineId: parsed.data.machineId as Id<"machines">,
userId: parsed.data.userId as Id<"users">,
})

View file

@ -59,7 +59,7 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true })
} catch (error) {
console.error("[machines.rename] Falha ao renomear", error)
return NextResponse.json({ error: "Falha ao renomear máquina" }, { status: 500 })
return NextResponse.json({ error: "Falha ao renomear dispositivo" }, { status: 500 })
}
}

View file

@ -54,7 +54,7 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true, revoked: result?.revoked ?? 0 })
} catch (error) {
console.error("[machines.resetAgent] Falha ao resetar agente", error)
return NextResponse.json({ error: "Falha ao resetar agente da máquina" }, { status: 500 })
return NextResponse.json({ error: "Falha ao resetar agente da dispositivo" }, { status: 500 })
}
}

View file

@ -56,6 +56,6 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true })
} catch (error) {
console.error("[machines.toggleActive] Falha ao atualizar status", error)
return NextResponse.json({ error: "Falha ao atualizar status da máquina" }, { status: 500 })
return NextResponse.json({ error: "Falha ao atualizar status da dispositivo" }, { status: 500 })
}
}

View file

@ -41,7 +41,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
}
if (targetRole === "machine") {
return NextResponse.json({ error: "Contas de máquina não possuem senha web" }, { status: 400 })
return NextResponse.json({ error: "Contas de dispositivo não possuem senha web" }, { status: 400 })
}
const body = (await request.json().catch(() => null)) as { password?: string } | null

View file

@ -158,7 +158,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
}
if ((user.role ?? "").toLowerCase() === "machine") {
return NextResponse.json({ error: "Ajustes de máquinas devem ser feitos em Admin ▸ Máquinas" }, { status: 400 })
return NextResponse.json({ error: "Ajustes de dispositivos devem ser feitos em Admin ▸ Dispositivos" }, { status: 400 })
}
if (!sessionIsAdmin && !canManageRole(nextRole)) {
@ -356,7 +356,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
}
if (target.role === "machine") {
return NextResponse.json({ error: "Os agentes de máquina devem ser removidos via módulo de máquinas." }, { status: 400 })
return NextResponse.json({ error: "Os agentes de dispositivo devem ser removidos via módulo de dispositivos." }, { status: 400 })
}
if (target.email === session.user.email) {

View file

@ -162,7 +162,7 @@ export async function POST(request: Request) {
const client = new ConvexHttpClient(convexUrl)
try {
const result = await client.mutation(api.machines.upsertInventory, {
const result = await client.mutation(api.devices.upsertInventory, {
provisioningCode: fleetSecret,
hostname,
os: osInfo,

View file

@ -36,7 +36,7 @@ describe("POST /api/machines/heartbeat", () => {
expect(response.status).toBe(200)
const body = await response.json()
expect(body).toEqual({ ok: true })
expect(mutationMock).toHaveBeenCalledWith(api.machines.heartbeat, payload)
expect(mutationMock).toHaveBeenCalledWith(api.devices.heartbeat, payload)
})
it("rejects an invalid payload", async () => {

View file

@ -56,7 +56,7 @@ export async function POST(request: Request) {
}
try {
const response = await client.mutation(api.machines.heartbeat, payload)
const response = await client.mutation(api.devices.heartbeat, payload)
return jsonWithCors(response, 200, origin, CORS_METHODS)
} catch (error) {
console.error("[machines.heartbeat] Falha ao registrar heartbeat", error)

View file

@ -35,7 +35,7 @@ describe("POST /api/machines/inventory", () => {
expect(response.status).toBe(200)
expect(mutationMock).toHaveBeenCalledWith(
api.machines.heartbeat,
api.devices.heartbeat,
expect.objectContaining({
machineToken: "token-123",
hostname: "machine",
@ -67,7 +67,7 @@ describe("POST /api/machines/inventory", () => {
expect(response.status).toBe(200)
expect(mutationMock).toHaveBeenCalledWith(
api.machines.upsertInventory,
api.devices.upsertInventory,
expect.objectContaining({
provisioningCode: "a".repeat(32),
hostname: "machine",

View file

@ -64,11 +64,11 @@ export async function POST(request: Request) {
)
}
// Modo A: com token da máquina (usa heartbeat para juntar inventário)
// Modo A: com token da dispositivo (usa heartbeat para juntar inventário)
const tokenParsed = tokenModeSchema.safeParse(raw)
if (tokenParsed.success) {
try {
const result = await client.mutation(api.machines.heartbeat, {
const result = await client.mutation(api.devices.heartbeat, {
machineToken: tokenParsed.data.machineToken,
hostname: tokenParsed.data.hostname,
os: tokenParsed.data.os,
@ -87,7 +87,7 @@ export async function POST(request: Request) {
const provParsed = provisioningModeSchema.safeParse(raw)
if (provParsed.success) {
try {
const result = await client.mutation(api.machines.upsertInventory, {
const result = await client.mutation(api.devices.upsertInventory, {
provisioningCode: provParsed.data.provisioningCode.trim().toLowerCase(),
hostname: provParsed.data.hostname,
os: provParsed.data.os,

View file

@ -120,7 +120,7 @@ export async function POST(request: Request) {
provisioningCode: companyRecord.provisioningCode,
})
const registration = await client.mutation(api.machines.register, {
const registration = await client.mutation(api.devices.register, {
provisioningCode,
hostname: payload.hostname,
os: payload.os,
@ -138,7 +138,7 @@ export async function POST(request: Request) {
persona,
})
await client.mutation(api.machines.linkAuthAccount, {
await client.mutation(api.devices.linkAuthAccount, {
machineId: registration.machineId as Id<"machines">,
authUserId: account.authUserId,
authEmail: account.authEmail,
@ -165,7 +165,7 @@ export async function POST(request: Request) {
if (persona) {
assignedUserId = ensuredUser?._id
await client.mutation(api.machines.updatePersona, {
await client.mutation(api.devices.updatePersona, {
machineId: registration.machineId as Id<"machines">,
persona,
...(assignedUserId ? { assignedUserId } : {}),
@ -174,13 +174,13 @@ export async function POST(request: Request) {
assignedUserRole: persona === "manager" ? "MANAGER" : "COLLABORATOR",
})
} else {
await client.mutation(api.machines.updatePersona, {
await client.mutation(api.devices.updatePersona, {
machineId: registration.machineId as Id<"machines">,
persona: "",
})
}
} else {
await client.mutation(api.machines.updatePersona, {
await client.mutation(api.devices.updatePersona, {
machineId: registration.machineId as Id<"machines">,
persona: "",
})
@ -211,7 +211,7 @@ export async function POST(request: Request) {
const isCompanyNotFound = msg.includes("empresa não encontrada")
const isConvexError = msg.includes("convexerror")
const status = isInvalidCode ? 401 : isCompanyNotFound ? 404 : isConvexError ? 400 : 500
const payload = { error: "Falha ao provisionar máquina", details }
const payload = { error: "Falha ao provisionar dispositivo", details }
return jsonWithCors(payload, status, origin, CORS_METHODS)
}
}

View file

@ -43,7 +43,7 @@ describe("GET /api/machines/session", () => {
expect(response.status).toBe(403)
const payload = await response.json()
expect(payload).toEqual({ error: "Sessão de máquina não encontrada." })
expect(payload).toEqual({ error: "Sessão de dispositivo não encontrada." })
expect(mockCreateConvexClient).not.toHaveBeenCalled()
})

View file

@ -21,7 +21,7 @@ export const runtime = "nodejs"
export async function GET(request: NextRequest) {
const session = await assertAuthenticatedSession()
if (!session || session.user?.role !== "machine") {
return NextResponse.json({ error: "Sessão de máquina não encontrada." }, { status: 403 })
return NextResponse.json({ error: "Sessão de dispositivo não encontrada." }, { status: 403 })
}
let client
@ -42,23 +42,23 @@ export async function GET(request: NextRequest) {
if (!machineId) {
try {
const lookup = (await client.query(api.machines.findByAuthEmail, {
const lookup = (await client.query(api.devices.findByAuthEmail, {
authEmail: session.user.email.toLowerCase(),
})) as { id: string } | null
if (!lookup?.id) {
return NextResponse.json({ error: "Máquina não vinculada à sessão atual." }, { status: 404 })
return NextResponse.json({ error: "Dispositivo não vinculada à sessão atual." }, { status: 404 })
}
machineId = lookup.id as Id<"machines">
} catch (error) {
console.error("[machines.session] Falha ao localizar máquina por e-mail", error)
return NextResponse.json({ error: "Não foi possível localizar a máquina." }, { status: 500 })
console.error("[machines.session] Falha ao localizar dispositivo por e-mail", error)
return NextResponse.json({ error: "Não foi possível localizar a dispositivo." }, { status: 500 })
}
}
try {
let context = (await client.query(api.machines.getContext, {
let context = (await client.query(api.devices.getContext, {
machineId,
})) as {
id: string
@ -109,7 +109,7 @@ export async function GET(request: NextRequest) {
ensuredAssignedUserRole = ensuredUser.role ?? ensuredAssignedUserRole ?? assignedRole
ensuredPersona = normalizedPersona
await client.mutation(api.machines.updatePersona, {
await client.mutation(api.devices.updatePersona, {
machineId: machineId as Id<"machines">,
persona: normalizedPersona,
assignedUserId: ensuredUser._id as Id<"users">,
@ -118,7 +118,7 @@ export async function GET(request: NextRequest) {
assignedUserRole: (ensuredAssignedUserRole ?? assignedRole).toUpperCase(),
})
context = (await client.query(api.machines.getContext, {
context = (await client.query(api.devices.getContext, {
machineId,
})) as typeof context
@ -172,7 +172,7 @@ export async function GET(request: NextRequest) {
return response
} catch (error) {
console.error("[machines.session] Falha ao obter contexto da máquina", error)
return NextResponse.json({ error: "Falha ao obter contexto da máquina." }, { status: 500 })
console.error("[machines.session] Falha ao obter contexto da dispositivo", error)
return NextResponse.json({ error: "Falha ao obter contexto da dispositivo." }, { status: 500 })
}
}

View file

@ -127,13 +127,13 @@ export async function POST(request: Request) {
} catch (error) {
if (error instanceof MachineInactiveError) {
return jsonWithCors(
{ error: "Máquina desativada. Entre em contato com o suporte da Rever para reativar o acesso." },
{ error: "Dispositivo desativada. Entre em contato com o suporte da Rever para reativar o acesso." },
423,
origin,
CORS_METHODS
)
}
console.error("[machines.sessions] Falha ao criar sessão", error)
return jsonWithCors({ error: "Falha ao autenticar máquina" }, 500, origin, CORS_METHODS)
return jsonWithCors({ error: "Falha ao autenticar dispositivo" }, 500, origin, CORS_METHODS)
}
}

View file

@ -6,6 +6,7 @@ import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { buildMachinesInventoryWorkbook, type MachineInventoryRecord } from "@/server/machines/inventory-export"
import type { DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns"
export const runtime = "nodejs"
@ -22,6 +23,31 @@ export async function GET(request: Request) {
const companyId = searchParams.get("companyId") ?? undefined
const machineIdParams = searchParams.getAll("machineId").filter(Boolean)
const machineIdFilter = machineIdParams.length > 0 ? new Set(machineIdParams) : null
const columnsParam = searchParams.get("columns")
let columnConfig: DeviceInventoryColumnConfig[] | undefined
if (columnsParam) {
try {
const parsed = JSON.parse(columnsParam)
if (Array.isArray(parsed)) {
columnConfig = parsed
.map((item) => {
if (typeof item === "string") {
return { key: item }
}
if (item && typeof item === "object" && typeof item.key === "string") {
return {
key: item.key,
label: typeof item.label === "string" && item.label.length > 0 ? item.label : undefined,
}
}
return null
})
.filter((item): item is DeviceInventoryColumnConfig => item !== null)
}
} catch (error) {
console.warn("Invalid columns parameter for machines export", error)
}
}
const client = new ConvexHttpClient(convexUrl)
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
@ -46,7 +72,7 @@ export async function GET(request: Request) {
}
try {
const machines = (await client.query(api.machines.listByTenant, {
const machines = (await client.query(api.devices.listByTenant, {
tenantId,
includeMetadata: true,
})) as MachineInventoryRecord[]
@ -77,6 +103,7 @@ export async function GET(request: Request) {
generatedBy: session.user.name ?? session.user.email,
companyFilterLabel,
generatedAt: new Date(),
columns: columnConfig,
})
const body = new Uint8Array(workbook)

View file

@ -9,7 +9,7 @@ const ERROR_TEMPLATE = `
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Falha na autenticação da máquina</title>
<title>Falha na autenticação da dispositivo</title>
<style>
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background-color: #0f172a; color: #e2e8f0; margin: 0; display: grid; place-items: center; height: 100vh; }
main { max-width: 480px; padding: 32px; border-radius: 16px; background-color: rgba(15, 23, 42, 0.65); box-shadow: 0 18px 42px rgba(15, 23, 42, 0.45); text-align: center; backdrop-filter: blur(10px); }
@ -21,8 +21,8 @@ const ERROR_TEMPLATE = `
</head>
<body>
<main>
<h1>Não foi possível autenticar esta máquina</h1>
<p>O token informado é inválido, expirou ou não está mais associado a uma máquina ativa.</p>
<h1>Não foi possível autenticar esta dispositivo</h1>
<p>O token informado é inválido, expirou ou não está mais associado a uma dispositivo ativa.</p>
<p>Volte ao agente desktop, gere um novo token ou realize o provisionamento novamente.</p>
<a href="/">Voltar para o Raven</a>
</main>
@ -36,7 +36,7 @@ const INACTIVE_TEMPLATE = `
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Máquina desativada</title>
<title>Dispositivo desativada</title>
<style>
:root { color-scheme: dark light; }
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background-color: #0f172a; color: #e2e8f0; margin: 0; display: grid; place-items: center; min-height: 100vh; padding: 24px; }
@ -51,9 +51,9 @@ const INACTIVE_TEMPLATE = `
<body>
<main>
<div class="badge">Acesso bloqueado</div>
<h1>Esta máquina está desativada</h1>
<h1>Esta dispositivo está desativada</h1>
<p>O acesso ao portal foi suspenso pelos administradores da Rever. Enquanto isso, você não poderá abrir chamados ou enviar atualizações.</p>
<p>Entre em contato com a equipe da Rever para solicitar a reativação desta máquina.</p>
<p>Entre em contato com a equipe da Rever para solicitar a reativação desta dispositivo.</p>
<a href="mailto:suporte@rever.com.br">Falar com o suporte</a>
</main>
</body>
@ -162,7 +162,7 @@ export async function GET(request: NextRequest) {
},
})
}
console.error("[machines.handshake] Falha ao autenticar máquina", error)
console.error("[machines.handshake] Falha ao autenticar dispositivo", error)
return new NextResponse(ERROR_TEMPLATE, {
status: 500,
headers: {

View file

@ -102,7 +102,7 @@ const ROLE_LABELS: Record<string, string> = {
manager: "Gestor",
agent: "Agente",
collaborator: "Colaborador",
machine: "Agente de máquina",
machine: "Agente de dispositivo",
}
function formatRole(role: string) {
@ -305,7 +305,7 @@ export function AdminUsersManager({
() => [
{ value: "all", label: "Todos" },
{ value: "people", label: "Pessoas" },
{ value: "machines", label: "Máquinas" },
{ value: "machines", label: "Dispositivos" },
],
[],
)
@ -327,8 +327,8 @@ export function AdminUsersManager({
const [teamSelection, setTeamSelection] = useState<Set<string>>(new Set())
const [isBulkDeletingTeam, setIsBulkDeletingTeam] = useState(false)
const [bulkDeleteTeamOpen, setBulkDeleteTeamOpen] = useState(false)
// Removidos filtros antigos de Pessoas/Máquinas (agora unificado)
// Unificado (pessoas + máquinas)
// Removidos filtros antigos de Pessoas/Dispositivos (agora unificado)
// Unificado (pessoas + dispositivos)
const [usersSearch, setUsersSearch] = useState("")
const [usersTypeFilter, setUsersTypeFilter] = useState<"all" | "people" | "machines">("people")
const [usersCompanyFilter, setUsersCompanyFilter] = useState<string>("all")
@ -366,7 +366,7 @@ export function AdminUsersManager({
const cleanupPreview = useMemo(() => Array.from(buildKeepEmailSet()).join(", "), [buildKeepEmailSet])
// Máquinas (para listar vínculos por usuário)
// Dispositivos (para listar vínculos por usuário)
type MachinesListItem = {
id: string
hostname?: string
@ -375,7 +375,7 @@ export function AdminUsersManager({
linkedUsers?: Array<{ id: string; email: string; name: string }>
}
const machinesList = useQuery(
api.machines.listByTenant,
api.devices.listByTenant,
convexUserId ? { tenantId: defaultTenantId, includeMetadata: true } : "skip"
) as MachinesListItem[] | undefined
@ -907,7 +907,7 @@ export function AdminUsersManager({
// Removido: seleção específica de Pessoas (uso substituído pelo unificado)
// Removido: seleção específica de Máquinas (uso substituído pelo unificado)
// Removido: seleção específica de Dispositivos (uso substituído pelo unificado)
const [inviteSelection, setInviteSelection] = useState<Set<string>>(new Set())
const selectedInvites = useMemo(() => invites.filter((i) => inviteSelection.has(i.id)), [invites, inviteSelection])
@ -951,7 +951,7 @@ export function AdminUsersManager({
if (!user) return
const machineId = extractMachineId(user.email)
if (!machineId) return
const response = await fetch(`/api/admin/machines/delete`, {
const response = await fetch(`/api/admin/devices/delete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
@ -1098,9 +1098,9 @@ async function handleDeleteUser() {
if (isMachine) {
const machineId = extractMachineId(deleteTarget.email)
if (!machineId) {
throw new Error("Não foi possível identificar a máquina associada.")
throw new Error("Não foi possível identificar a dispositivo associada.")
}
const response = await fetch("/api/admin/machines/delete", {
const response = await fetch("/api/admin/devices/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId }),
@ -1108,9 +1108,9 @@ async function handleDeleteUser() {
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Falha ao remover agente de máquina")
throw new Error(data.error ?? "Falha ao remover agente de dispositivo")
}
toast.success("Agente de máquina removido")
toast.success("Agente de dispositivo removido")
} else {
const response = await fetch(`/api/admin/users/${deleteTarget.id}`, {
method: "DELETE",
@ -1457,7 +1457,7 @@ async function handleDeleteUser() {
<Input
value={usersSearch}
onChange={(event) => setUsersSearch(event.target.value)}
placeholder="Buscar por nome, e-mail, empresa ou máquina..."
placeholder="Buscar por nome, e-mail, empresa ou dispositivo..."
className="h-9 pl-9"
/>
</div>
@ -1513,7 +1513,7 @@ async function handleDeleteUser() {
<Card>
<CardHeader>
<CardTitle>Usuários</CardTitle>
<CardDescription>Pessoas e máquinas com acesso ao sistema.</CardDescription>
<CardDescription>Pessoas e dispositivos com acesso ao sistema.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="w-full overflow-x-auto">
@ -1564,11 +1564,11 @@ async function handleDeleteUser() {
</div>
</TableCell>
<TableCell className="px-4 font-medium text-neutral-800">
{user.name || (user.role === "machine" ? "Máquina" : "—")}
{user.name || (user.role === "machine" ? "Dispositivo" : "—")}
</TableCell>
<TableCell className="px-4 text-neutral-600">{user.email}</TableCell>
<TableCell className="px-4 text-neutral-600">
{user.role === "machine" ? "Máquina" : "Pessoa"}
{user.role === "machine" ? "Dispositivo" : "Pessoa"}
</TableCell>
<TableCell className="px-4 text-neutral-600">
{user.role === "machine" ? (
@ -1606,11 +1606,11 @@ async function handleDeleteUser() {
<Link
href={
extractMachineId(user.email)
? `/admin/machines/${extractMachineId(user.email)}`
: "/admin/machines"
? `/admin/devices/${extractMachineId(user.email)}`
: "/admin/devices"
}
>
Detalhes da máquina
Detalhes da dispositivo
</Link>
</Button>
) : null}
@ -2113,7 +2113,7 @@ async function handleDeleteUser() {
<DialogContent>
<DialogHeader>
<DialogTitle>Remover usuários selecionados</DialogTitle>
<DialogDescription>Pessoas perderão o acesso e máquinas serão desconectadas.</DialogDescription>
<DialogDescription>Pessoas perderão o acesso e dispositivos serão desconectadas.</DialogDescription>
</DialogHeader>
<div className="max-h-64 space-y-2 overflow-auto">
{Array.from(usersSelection).slice(0, 5).map((id) => {
@ -2240,27 +2240,27 @@ async function handleDeleteUser() {
if (r === 'admin' || r === 'agent') return null
return (
<div className="grid gap-2">
<Label>Máquinas vinculadas</Label>
<Label>Dispositivos vinculadas</Label>
{linkedMachinesForEditUser.length > 0 ? (
<ul className="divide-y divide-slate-200 rounded-md border border-slate-200 bg-slate-50/60">
{linkedMachinesForEditUser.map((m) => (
<li key={`linked-m-${m.id}`} className="flex items-center justify-between px-3 py-2 text-sm">
<span className="truncate">{m.hostname || m.id}</span>
<Button asChild size="sm" variant="ghost">
<Link href={`/admin/machines/${m.id}`}>Abrir</Link>
<Link href={`/admin/devices/${m.id}`}>Abrir</Link>
</Button>
</li>
))}
</ul>
) : (
<p className="text-xs text-neutral-500">Nenhuma máquina vinculada a este usuário.</p>
<p className="text-xs text-neutral-500">Nenhuma dispositivo vinculada a este usuário.</p>
)}
</div>
)
})()}
{isMachineEditing ? (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-3 text-sm text-neutral-600">
Os ajustes detalhados de agentes de máquina são feitos em <Link href="/admin/machines" className="underline underline-offset-4">Admin Máquinas</Link>.
Os ajustes detalhados de agentes de dispositivo são feitos em <Link href="/admin/devices" className="underline underline-offset-4">Admin Dispositivos</Link>.
</div>
) : (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
@ -2314,7 +2314,7 @@ async function handleDeleteUser() {
<DialogContent>
<DialogHeader>
<DialogTitle>
{deleteTarget?.role === "machine" ? "Remover agente de máquina" : "Remover colaborador"}
{deleteTarget?.role === "machine" ? "Remover agente de dispositivo" : "Remover colaborador"}
</DialogTitle>
<DialogDescription>
{deleteTarget?.role === "machine"
@ -2328,7 +2328,7 @@ async function handleDeleteUser() {
</p>
{deleteTarget?.role === "machine" ? (
<p>
A máquina correspondente perderá imediatamente o token ativo e voltará para a tela de provisionamento.
A dispositivo correspondente perderá imediatamente o token ativo e voltará para a tela de provisionamento.
</p>
) : (
<p>Esse usuário não poderá mais acessar o painel até receber um novo convite.</p>

View file

@ -83,7 +83,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import { MultiValueInput } from "@/components/ui/multi-value-input"
import { AdminMachinesOverview } from "@/components/admin/machines/admin-machines-overview"
import { AdminDevicesOverview } from "@/components/admin/devices/admin-devices-overview"
type LastAlertInfo = { createdAt: number; usagePct: number; threshold: number } | null
@ -283,8 +283,8 @@ export function AdminCompaniesManager({ initialCompanies, tenantId }: Props) {
const effectiveTenantId = tenantId ?? companies[0]?.tenantId ?? DEFAULT_TENANT_ID
// Máquinas por empresa para contagem rápida
const machines = useQuery(api.machines.listByTenant, {
// Dispositivos por empresa para contagem rápida
const machines = useQuery(api.devices.listByTenant, {
tenantId: effectiveTenantId,
includeMetadata: false,
}) as unknown[] | undefined
@ -513,10 +513,10 @@ export function AdminCompaniesManager({ initialCompanies, tenantId }: Props) {
type="button"
className="inline-flex items-center gap-1 text-muted-foreground transition hover:text-foreground"
onClick={() => {
window.location.href = `/admin/machines?company=${company.slug}`
window.location.href = `/admin/devices?company=${company.slug}`
}}
>
<IconDeviceDesktop className="size-3.5" /> Máquinas
<IconDeviceDesktop className="size-3.5" /> Dispositivos
</button>
<button
type="button"
@ -754,7 +754,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
<TableHead>Empresa</TableHead>
<TableHead>Contratos ativos</TableHead>
<TableHead>Contatos</TableHead>
<TableHead>Máquinas</TableHead>
<TableHead>Dispositivos</TableHead>
<TableHead className="text-right">Ações</TableHead>
</TableRow>
</TableHeader>
@ -864,9 +864,9 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
<IconCopy className="mr-2 size-3.5" />
Código
</Button>
<Button size="sm" variant="outline" className="whitespace-nowrap" onClick={() => { window.location.href = `/admin/machines?company=${company.slug}` }}>
<Button size="sm" variant="outline" className="whitespace-nowrap" onClick={() => { window.location.href = `/admin/devices?company=${company.slug}` }}>
<IconDeviceDesktop className="mr-2 size-3.5" />
Máquinas
Dispositivos
</Button>
<Button
size="icon"
@ -1687,10 +1687,10 @@ function CompanySheet({ tenantId, editor, onClose, onCreated, onUpdated }: Compa
{editor?.mode === "edit" ? (
<AccordionItem value="machines" className="rounded-lg border border-border/60 bg-muted/20 px-4">
<AccordionTrigger className="py-3 font-semibold">Máquinas vinculadas</AccordionTrigger>
<AccordionTrigger className="py-3 font-semibold">Dispositivos vinculadas</AccordionTrigger>
<AccordionContent className="pb-5">
<div className="rounded-lg border border-border/60 bg-background p-3">
<AdminMachinesOverview tenantId={tenantId} initialCompanyFilterSlug={editor.company.slug} />
<AdminDevicesOverview tenantId={tenantId} initialCompanyFilterSlug={editor.company.slug} />
</div>
</AccordionContent>
</AccordionItem>

View file

@ -5,28 +5,28 @@ import { useQuery } from "convex/react"
import { useParams, useRouter } from "next/navigation"
import { api } from "@/convex/_generated/api"
import {
MachineDetails,
normalizeMachineItem,
type MachinesQueryItem,
} from "@/components/admin/machines/admin-machines-overview"
DeviceDetails,
normalizeDeviceItem,
type DevicesQueryItem,
} from "@/components/admin/devices/admin-devices-overview"
import { Card, CardContent } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Button } from "@/components/ui/button"
import type { Id } from "@/convex/_generated/dataModel"
import { ConvexHttpClient } from "convex/browser"
export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: { tenantId: string; machineId?: string }) {
export function AdminDeviceDetailsClient({ tenantId: _tenantId, deviceId }: { tenantId: string; deviceId?: string }) {
const router = useRouter()
const params = useParams<{ id?: string | string[] }>()
const routeMachineId = Array.isArray(params?.id) ? params?.id[0] : params?.id
const effectiveMachineId = machineId ?? routeMachineId ?? ""
const routeDeviceId = Array.isArray(params?.id) ? params?.id[0] : params?.id
const effectiveDeviceId = deviceId ?? routeDeviceId ?? ""
const canLoadMachine = Boolean(effectiveMachineId)
const canLoadDevice = Boolean(effectiveDeviceId)
const single = useQuery(
api.machines.getById,
canLoadMachine
? ({ id: effectiveMachineId as Id<"machines">, includeMetadata: true } as const)
api.devices.getById,
canLoadDevice
? ({ id: effectiveDeviceId as Id<"machines">, includeMetadata: true } as const)
: "skip"
)
@ -34,7 +34,7 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
const [fallback, setFallback] = useState<Record<string, unknown> | null | undefined>(undefined)
const [loadError, setLoadError] = useState<string | null>(null)
const [retryTick, setRetryTick] = useState(0)
const shouldLoad = fallback === undefined && Boolean(effectiveMachineId)
const shouldLoad = fallback === undefined && Boolean(effectiveDeviceId)
const [isHydrated, setIsHydrated] = useState(false)
useEffect(() => {
@ -51,8 +51,8 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
if (convexUrl) {
try {
const http = new ConvexHttpClient(convexUrl)
const data = (await http.query(api.machines.getById, {
id: effectiveMachineId as Id<"machines">,
const data = (await http.query(api.devices.getById, {
id: effectiveDeviceId as Id<"machines">,
includeMetadata: true,
})) as Record<string, unknown> | null
@ -75,7 +75,7 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
}
try {
const res = await fetch(`/api/admin/machines/${effectiveMachineId}/details`, {
const res = await fetch(`/api/admin/devices/${effectiveDeviceId}/details`, {
credentials: "include",
cache: "no-store",
})
@ -108,29 +108,29 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
}
} catch (err) {
if (!cancelled) {
console.error("[admin-machine-details] API fallback fetch failed", err)
setLoadError("Erro de rede ao carregar os dados da máquina.")
console.error("[admin-device-details] API fallback fetch failed", err)
setLoadError("Erro de rede ao carregar os dados da dispositivo.")
}
}
} catch (err) {
if (!cancelled) {
console.error("[admin-machine-details] Unexpected probe failure", err)
setLoadError("Erro de rede ao carregar os dados da máquina.")
console.error("[admin-device-details] Unexpected probe failure", err)
setLoadError("Erro de rede ao carregar os dados da dispositivo.")
}
}
}
probe().catch((err) => {
if (!cancelled) {
console.error("[admin-machine-details] Probe promise rejected", err)
setLoadError("Erro de rede ao carregar os dados da máquina.")
console.error("[admin-device-details] Probe promise rejected", err)
setLoadError("Erro de rede ao carregar os dados da dispositivo.")
}
})
return () => {
cancelled = true
}
}, [shouldLoad, effectiveMachineId, retryTick])
}, [shouldLoad, effectiveDeviceId, retryTick])
// Timeout de proteção: se depois de X segundos ainda estiver carregando e sem fallback, mostra erro claro
useEffect(() => {
@ -141,12 +141,12 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
)
}, 10_000)
return () => clearTimeout(timeout)
}, [shouldLoad, effectiveMachineId, retryTick])
}, [shouldLoad, effectiveDeviceId, retryTick])
const machine: MachinesQueryItem | null = useMemo(() => {
const device: DevicesQueryItem | null = useMemo(() => {
const source = single ?? (fallback === undefined ? undefined : fallback)
if (source === undefined || source === null) return source as null
return normalizeMachineItem(source)
return normalizeDeviceItem(source)
}, [single, fallback])
const isLoading = single === undefined && fallback === undefined && !loadError
const isNotFound = (single === null || fallback === null) && !loadError
@ -174,11 +174,11 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
// ignore
}
}
if (loadError && !machine) {
if (loadError && !device) {
return (
<Card>
<CardContent className="space-y-3 p-6">
<p className="text-sm font-medium text-red-600">Falha ao carregar os dados da máquina</p>
<p className="text-sm font-medium text-red-600">Falha ao carregar os dados da dispositivo</p>
<p className="text-sm text-muted-foreground">{loadError}</p>
<div className="pt-2 flex items-center gap-2">
<Button size="sm" onClick={onRetry}>Tentar novamente</Button>
@ -204,7 +204,7 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
return (
<Card>
<CardContent className="space-y-3 p-6">
<p className="text-sm font-medium text-red-600">Máquina não encontrada</p>
<p className="text-sm font-medium text-red-600">Dispositivo não encontrada</p>
<p className="text-sm text-muted-foreground">Verifique o identificador e tente novamente.</p>
<div className="pt-2 flex items-center gap-2">
<Button size="sm" onClick={onRetry}>Recarregar</Button>
@ -214,5 +214,5 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
)
}
return <MachineDetails machine={machine} />
return <DeviceDetails device={device} />
}

View file

@ -12,33 +12,33 @@ type BreadcrumbSegment = {
href?: string | null
}
type MachineBreadcrumbsProps = {
type DeviceBreadcrumbsProps = {
tenantId: string
machineId: string
machineHref?: string | null
deviceId: string
deviceHref?: string | null
extra?: BreadcrumbSegment[]
}
export function MachineBreadcrumbs({ tenantId: _tenantId, machineId, machineHref, extra }: MachineBreadcrumbsProps) {
export function DeviceBreadcrumbs({ tenantId: _tenantId, deviceId, deviceHref, extra }: DeviceBreadcrumbsProps) {
const { convexUserId } = useAuth()
const canLoadMachine = Boolean(machineId && convexUserId)
const canLoadDevice = Boolean(deviceId && convexUserId)
const item = useQuery(
api.machines.getById,
canLoadMachine
? ({ id: machineId as Id<"machines">, includeMetadata: false } as const)
api.devices.getById,
canLoadDevice
? ({ id: deviceId as Id<"machines">, includeMetadata: false } as const)
: "skip"
)
const hostname = useMemo(() => item?.hostname ?? "Detalhe", [item])
const segments = useMemo(() => {
const trail: BreadcrumbSegment[] = [
{ label: "Máquinas", href: "/admin/machines" },
{ label: hostname, href: machineHref ?? undefined },
{ label: "Dispositivos", href: "/admin/devices" },
{ label: hostname, href: deviceHref ?? undefined },
]
if (Array.isArray(extra) && extra.length > 0) {
trail.push(...extra.filter((segment): segment is BreadcrumbSegment => Boolean(segment?.label)))
}
return trail
}, [hostname, machineHref, extra])
}, [hostname, deviceHref, extra])
return (
<nav className="mb-4 text-sm text-neutral-600">

View file

@ -27,7 +27,7 @@ import { TicketStatusBadge } from "@/components/tickets/status-badge"
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
import { EmptyIndicator } from "@/components/ui/empty-indicator"
type MachineTicketHistoryItem = {
type DeviceTicketHistoryItem = {
id: string
reference: number
subject: string
@ -40,7 +40,7 @@ type MachineTicketHistoryItem = {
assignee: { name: string | null; email: string | null } | null
}
type MachineTicketsHistoryArgs = {
type DeviceTicketsHistoryArgs = {
machineId: Id<"machines">
status?: "open" | "resolved"
priority?: string
@ -49,7 +49,7 @@ type MachineTicketsHistoryArgs = {
to?: number
}
type MachineTicketsHistoryStats = {
type DeviceTicketsHistoryStats = {
total: number
openCount: number
resolvedCount: number
@ -142,7 +142,7 @@ function getPriorityMeta(priority: TicketPriority | string | null | undefined) {
}
}
export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }: { tenantId: string; machineId: string }) {
export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: { tenantId: string; deviceId: string }) {
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all")
const [priorityFilter, setPriorityFilter] = useState<string>("ALL")
const [periodPreset, setPeriodPreset] = useState<PeriodPreset>("90d")
@ -168,8 +168,8 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
const range = useMemo(() => computeRange(periodPreset, customFrom, customTo), [periodPreset, customFrom, customTo])
const queryArgs = useMemo(() => {
const args: MachineTicketsHistoryArgs = {
machineId: machineId as Id<"machines">,
const args: DeviceTicketsHistoryArgs = {
machineId: deviceId as Id<"machines">,
}
if (statusFilter !== "all") {
args.status = statusFilter
@ -187,15 +187,15 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
args.to = range.to
}
return args
}, [debouncedSearch, machineId, priorityFilter, range.from, range.to, statusFilter])
}, [debouncedSearch, deviceId, priorityFilter, range.from, range.to, statusFilter])
const { results: tickets, status: paginationStatus, loadMore } = usePaginatedQuery(
api.machines.listTicketsHistory,
api.devices.listTicketsHistory,
queryArgs,
{ initialNumItems: 25 }
)
const stats = useQuery(api.machines.getTicketsHistoryStats, queryArgs) as MachineTicketsHistoryStats | undefined
const stats = useQuery(api.devices.getTicketsHistoryStats, queryArgs) as DeviceTicketsHistoryStats | undefined
const totalTickets = stats?.total ?? 0
const openTickets = stats?.openCount ?? 0
const resolvedTickets = stats?.resolvedCount ?? 0
@ -321,7 +321,7 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
<EmptyHeader>
<EmptyTitle>Nenhum chamado encontrado</EmptyTitle>
<EmptyDescription>
Ajuste os filtros ou expanda o período para visualizar o histórico de chamados desta máquina.
Ajuste os filtros ou expanda o período para visualizar o histórico de chamados desta dispositivo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>

View file

@ -109,7 +109,7 @@ const navigation: NavigationGroup[] = [
requiredRole: "admin",
},
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },
{ title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" },
{ title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" },
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
],
},

View file

@ -230,7 +230,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (machineInactive) {
toast.error("Esta máquina está desativada. Reative-a para enviar novas mensagens.")
toast.error("Esta dispositivo está desativada. Reative-a para enviar novas mensagens.")
return
}
if (!convexUserId || !ticket) return
@ -328,7 +328,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
<CardContent className="space-y-6 px-5 pb-6">
{machineInactive ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
Esta máquina está desativada. Ative-a novamente para enviar novas mensagens.
Esta dispositivo está desativada. Ative-a novamente para enviar novas mensagens.
</div>
) : null}
<form onSubmit={handleSubmit} className="space-y-4">
@ -687,10 +687,10 @@ function PortalCommentAttachmentCard({
</div>
)
}

View file

@ -64,13 +64,13 @@ export function PortalTicketForm() {
event.preventDefault()
if (isSubmitting || !isFormValid) return
if (machineInactive) {
toast.error("Esta máquina está desativada no momento. Reative-a para abrir novos chamados.", { id: "portal-new-ticket" })
toast.error("Esta dispositivo está desativada no momento. Reative-a para abrir novos chamados.", { id: "portal-new-ticket" })
return
}
if (!viewerId) {
const detail = viewerErrorMessage ? ` Detalhes: ${viewerErrorMessage}` : ""
toast.error(
`Não foi possível identificar o colaborador vinculado a esta máquina. Tente abrir novamente o portal ou contate o suporte.${detail}`,
`Não foi possível identificar o colaborador vinculado a esta dispositivo. Tente abrir novamente o portal ou contate o suporte.${detail}`,
{ id: "portal-new-ticket" }
)
return
@ -145,12 +145,12 @@ export function PortalTicketForm() {
<CardContent className="space-y-6 px-5 pb-6">
{machineInactive ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
Esta máquina foi desativada pelos administradores e não pode abrir novos chamados até ser reativada.
Esta dispositivo foi desativada pelos administradores e não pode abrir novos chamados até ser reativada.
</div>
) : null}
{!isViewerReady ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
Vincule esta máquina a um colaborador na aplicação desktop para enviar chamados em nome dele.
Vincule esta dispositivo a um colaborador na aplicação desktop para enviar chamados em nome dele.
{machineContextLoading ? (
<p className="mt-2 text-xs text-amber-600">Carregando informa<EFBFBD><EFBFBD>es da m<EFBFBD>quina...</p>
) : null}

View file

@ -188,7 +188,7 @@ export function CompanyReport() {
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Máquinas monitoradas</CardTitle>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Dispositivos monitoradas</CardTitle>
<CardDescription className="text-neutral-600">Inventário registrado nesta empresa.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{report.machines.total}</CardContent>
@ -291,7 +291,7 @@ export function CompanyReport() {
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Sistemas operacionais</CardTitle>
<CardDescription className="text-neutral-600">Inventário das máquinas desta empresa.</CardDescription>
<CardDescription className="text-neutral-600">Inventário das dispositivos desta empresa.</CardDescription>
</CardHeader>
<CardContent className="pb-6">
<ChartContainer config={MACHINE_STATUS_CONFIG} className="mx-auto aspect-square max-h-[240px]">

View file

@ -1,7 +1,7 @@
"use client"
import { useQuery } from "convex/react"
import { IconMoodSmile, IconStars, IconMessageCircle2 } from "@tabler/icons-react"
import { IconMoodSmile, IconStars, IconMessageCircle2, IconTarget } from "@tabler/icons-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
@ -13,7 +13,7 @@ import { useState } from "react"
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
@ -49,53 +49,84 @@ export function CsatReport() {
)
}
const companyOptions = (companies ?? []).map<SearchableComboboxOption>((company) => ({
value: company.id,
label: company.name,
}))
const comboboxOptions: SearchableComboboxOption[] = [
{ value: "all", label: "Todas as empresas" },
...companyOptions,
]
const handleCompanyChange = (value: string | null) => {
setCompanyId(value ?? "all")
}
const selectedCompany = companyId === "all" ? "all" : companyId
const averageScore = typeof data.averageScore === "number" ? data.averageScore : null
const positiveRate = typeof data.positiveRate === "number" ? data.positiveRate : null
const agentStats = Array.isArray(data.byAgent) ? data.byAgent : []
const topAgent = agentStats[0] ?? null
const agentChartData = agentStats.map((agent: { agentName: string; averageScore: number | null; totalResponses: number; positiveRate: number | null }) => ({
agent: agent.agentName ?? "Sem responsável",
average: agent.averageScore ?? 0,
total: agent.totalResponses ?? 0,
positive: agent.positiveRate ? Math.round(agent.positiveRate * 1000) / 10 : 0,
}))
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h2 className="text-xl font-semibold text-neutral-900">CSAT Satisfação dos chamados</h2>
<p className="text-sm text-neutral-600">
Avalie a experiência dos usuários e acompanhe o desempenho da equipe de atendimento.
</p>
</div>
<div className="flex flex-wrap items-center gap-2 md:justify-end">
<SearchableCombobox
value={selectedCompany}
onValueChange={handleCompanyChange}
options={comboboxOptions}
className="w-56"
placeholder="Filtrar por empresa"
/>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={(value) => {
if (value) setTimeRange(value)
}}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/csat.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar XLSX
</a>
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconMoodSmile className="size-4 text-teal-500" /> CSAT médio
</CardTitle>
<CardDescription className="text-neutral-600">Média das respostas recebidas.</CardDescription>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/csat.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar XLSX
</a>
</Button>
</div>
</CardAction>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconMoodSmile className="size-4 text-teal-500" /> CSAT médio
</CardTitle>
<CardDescription className="text-neutral-600">Média das respostas recebidas.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{formatScore(data.averageScore)}
{formatScore(averageScore)} <span className="text-base font-normal text-neutral-500">/ 5</span>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
@ -105,30 +136,104 @@ export function CsatReport() {
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totalSurveys}</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconMessageCircle2 className="size-4 text-sky-500" /> Últimas avaliações
<IconTarget className="size-4 text-amber-500" /> Avaliações positivas
</CardTitle>
<CardDescription className="text-neutral-600">Até 10 registros mais recentes.</CardDescription>
<CardDescription className="text-neutral-600">Notas iguais ou superiores a 4.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{data.recent.length === 0 ? (
<p className="text-sm text-neutral-500">Ainda não coletamos nenhuma avaliação.</p>
<CardContent className="text-3xl font-semibold text-neutral-900">
{positiveRate === null ? "—" : `${(positiveRate * 100).toFixed(1).replace(".0", "")}%`}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconMessageCircle2 className="size-4 text-sky-500" /> Destaque do período
</CardTitle>
<CardDescription className="text-neutral-600">Agente com melhor média no recorte selecionado.</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
{topAgent ? (
<>
<p className="text-base font-semibold text-neutral-900">{topAgent.agentName}</p>
<p className="text-sm text-neutral-600">
{topAgent.averageScore ? `${topAgent.averageScore.toFixed(2)} / 5` : "Sem notas suficientes"}
</p>
<p className="text-xs text-neutral-500">{topAgent.totalResponses} avaliação{topAgent.totalResponses === 1 ? "" : "s"}</p>
</>
) : (
data.recent.map((item: { ticketId: string; reference: number; score: number; receivedAt: number }) => (
<div key={`${item.ticketId}-${item.receivedAt}`} className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm">
<span>#{item.reference}</span>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
Nota {item.score}
</Badge>
</div>
))
<p className="text-sm text-neutral-500">Ainda não avaliações suficientes.</p>
)}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-lg font-semibold text-neutral-900">Últimas avaliações</CardTitle>
<CardDescription className="text-neutral-600">
Até 10 registros mais recentes enviados pelos usuários.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{data.recent.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Ainda não coletamos nenhuma avaliação no período selecionado.
</p>
) : (
data.recent.map(
(item: {
ticketId: string
reference: number
score: number
maxScore?: number | null
comment?: string | null
receivedAt: number
assigneeName?: string | null
}) => {
const normalized =
item.maxScore && item.maxScore > 0
? Math.round(((item.score / item.maxScore) * 5) * 10) / 10
: item.score
const badgeLabel =
item.maxScore && item.maxScore !== 5
? `${item.score}/${item.maxScore}`
: normalized.toFixed(1).replace(/\.0$/, "")
return (
<div
key={`${item.ticketId}-${item.receivedAt}`}
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2 text-sm"
>
<div className="flex flex-col gap-1">
<span className="font-semibold text-neutral-800">#{item.reference}</span>
{item.assigneeName ? (
<Badge variant="outline" className="w-fit rounded-full border-neutral-200 text-xs text-neutral-600">
{item.assigneeName}
</Badge>
) : null}
{item.comment ? (
<span className="text-xs text-neutral-500 line-clamp-2">{item.comment}</span>
) : null}
</div>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
Nota {badgeLabel}
</Badge>
</div>
)
}
)
)}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Distribuição das notas</CardTitle>
@ -140,17 +245,80 @@ export function CsatReport() {
{data.totalSurveys === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem respostas no período.</p>
) : (
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
<ChartContainer config={{ total: { label: "Respostas" } }} className="aspect-auto h-[260px] w-full">
<BarChart data={data.distribution.map((d: { score: number; total: number }) => ({ score: `Nota ${d.score}`, total: d.total }))}>
<CartesianGrid vertical={false} />
<XAxis dataKey="score" tickLine={false} axisLine={false} tickMargin={8} />
<Bar dataKey="total" fill="var(--chart-3)" radius={[4, 4, 0, 0]} />
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="Respostas" />} />
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="total" />} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Desempenho por agente</CardTitle>
<CardDescription className="text-neutral-600">
Média ponderada (1 a 5) e volume de avaliações recebidas por integrante da equipe.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[3fr_2fr]">
{agentChartData.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Ainda não avaliações atreladas a agentes no período selecionado.
</p>
) : (
<>
<ChartContainer
config={{
average: { label: "Média (1-5)", color: "hsl(var(--chart-1))" },
}}
className="aspect-auto h-[280px] w-full"
>
<BarChart data={agentChartData}>
<CartesianGrid vertical={false} />
<XAxis dataKey="agent" tickLine={false} axisLine={false} tickMargin={8} />
<Bar dataKey="average" fill="var(--chart-1)" radius={[4, 4, 0, 0]} />
<ChartTooltip
content={
<ChartTooltipContent
className="w-[220px]"
nameKey="average"
labelFormatter={(label) => `Agente: ${label}`}
valueFormatter={(value) => `${Number(value).toFixed(2)} / 5`}
/>
}
/>
</BarChart>
</ChartContainer>
<div className="space-y-3">
{agentStats.slice(0, 6).map((agent: { agentName: string; averageScore: number | null; totalResponses: number; positiveRate: number | null }) => (
<div
key={`${agent.agentName}-${agent.totalResponses}`}
className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-3 py-2"
>
<div className="flex flex-col">
<span className="font-semibold text-neutral-900">{agent.agentName}</span>
<span className="text-xs text-neutral-500">
{agent.totalResponses} avaliação{agent.totalResponses === 1 ? "" : "s"}
</span>
</div>
<div className="text-right text-sm font-semibold text-neutral-900">
{agent.averageScore ? `${agent.averageScore.toFixed(2)} / 5` : "—"}
<p className="text-xs font-normal text-neutral-500">
{agent.positiveRate === null ? "—" : `${(agent.positiveRate * 100).toFixed(0)}% positivas`}
</p>
</div>
</div>
))}
</div>
</>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -13,6 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
type ClosingTemplate = { id: string; title: string; body: string }
@ -45,7 +46,7 @@ const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
body: sanitizeTemplate(`
<p>Olá {{cliente}},</p>
<p>A equipe da ${DEFAULT_COMPANY_NAME} agradece o contato. Este ticket está sendo encerrado.</p>
<p>Se surgirem novas questões, você pode reabrir o ticket em até 7 dias ou nos contatar pelo número <strong>${DEFAULT_PHONE_NUMBER}</strong>. Obrigado.</p>
<p>Se surgirem novas questões, você pode reabrir o ticket em até 14 dias ou nos contatar pelo número <strong>${DEFAULT_PHONE_NUMBER}</strong>. Obrigado.</p>
<p>{{agente}} · ${DEFAULT_COMPANY_NAME}</p>
`),
},
@ -67,7 +68,7 @@ const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
body: sanitizeTemplate(`
<p>Prezado(a) {{cliente}},</p>
<p>Esse ticket está sendo encerrado pois realizamos 3 tentativas sem retorno.</p>
<p>Você pode reabrir este ticket em até 7 dias ou entrar em contato pelo telefone <strong>${DEFAULT_PHONE_NUMBER}</strong> quando preferir.</p>
<p>Você pode reabrir este ticket em até 14 dias ou entrar em contato pelo telefone <strong>${DEFAULT_PHONE_NUMBER}</strong> quando preferir.</p>
<p>{{agente}} · ${DEFAULT_COMPANY_NAME}</p>
`),
},
@ -105,6 +106,7 @@ export function CloseTicketDialog({
ticketId,
tenantId,
actorId,
ticketReference,
requesterName,
agentName,
onSuccess,
@ -117,6 +119,7 @@ export function CloseTicketDialog({
ticketId: string
tenantId: string
actorId: Id<"users"> | null
ticketReference?: number | null
requesterName?: string | null
agentName?: string | null
onSuccess: () => void
@ -128,7 +131,7 @@ export function CloseTicketDialog({
onWorkSummaryAdjusted?: (result: AdjustWorkSummaryResult) => void
canAdjustTime?: boolean
}) {
const updateStatus = useMutation(api.tickets.updateStatus)
const resolveTicketMutation = useMutation(api.tickets.resolveTicket)
const addComment = useMutation(api.tickets.addComment)
const adjustWorkSummary = useMutation(api.tickets.adjustWorkSummary)
@ -160,6 +163,24 @@ export function CloseTicketDialog({
const [externalMinutes, setExternalMinutes] = useState<string>("0")
const [adjustReason, setAdjustReason] = useState<string>("")
const enableAdjustment = Boolean(canAdjustTime && workSummary)
const [linkedReference, setLinkedReference] = useState<string>("")
const [reopenWindowDays, setReopenWindowDays] = useState<string>("14")
const normalizedReference = useMemo(() => {
const digits = linkedReference.replace(/[^0-9]/g, "").trim()
if (!digits) return null
const parsed = Number(digits)
if (!Number.isFinite(parsed) || parsed <= 0) return null
if (ticketReference && parsed === ticketReference) return null
return parsed
}, [linkedReference, ticketReference])
const linkedTicket = useQuery(
api.tickets.findByReference,
actorId && normalizedReference ? { tenantId, viewerId: actorId, reference: normalizedReference } : "skip"
) as { id: Id<"tickets">; reference: number; subject: string; status: string } | null | undefined
const isLinkLoading = Boolean(actorId && normalizedReference && linkedTicket === undefined)
const linkNotFound = Boolean(normalizedReference && linkedTicket === null && !isLinkLoading)
const hydrateTemplateBody = useCallback((templateHtml: string) => {
const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName)
@ -269,6 +290,21 @@ export function CloseTicketDialog({
setIsSubmitting(true)
toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" })
try {
if (linkedReference.trim().length > 0) {
if (isLinkLoading) {
toast.error("Aguarde carregar o ticket vinculado antes de encerrar.", { id: "close-ticket" })
setIsSubmitting(false)
return
}
if (linkNotFound || !linkedTicket) {
toast.error("Não encontramos o ticket informado para vincular. Verifique o número e tente novamente.", {
id: "close-ticket",
})
setIsSubmitting(false)
return
}
}
if (applyAdjustment) {
const result = (await adjustWorkSummary({
ticketId: ticketId as unknown as Id<"tickets">,
@ -280,7 +316,13 @@ export function CloseTicketDialog({
onWorkSummaryAdjusted?.(result)
}
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: "RESOLVED", actorId })
const reopenDaysNumber = Number(reopenWindowDays)
await resolveTicketMutation({
ticketId: ticketId as unknown as Id<"tickets">,
actorId,
resolvedWithTicketId: linkedTicket ? (linkedTicket.id as Id<"tickets">) : undefined,
reopenWindowDays: Number.isFinite(reopenDaysNumber) ? reopenDaysNumber : undefined,
})
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
const sanitized = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
const hasContent = sanitized.replace(/<[^>]*>/g, "").trim().length > 0
@ -351,6 +393,50 @@ export function CloseTicketDialog({
/>
<p className="text-xs text-neutral-500">Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional.</p>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<Label htmlFor="linked-reference" className="text-sm font-medium text-neutral-800">
Ticket relacionado (opcional)
</Label>
<Input
id="linked-reference"
value={linkedReference}
onChange={(event) => setLinkedReference(event.target.value)}
placeholder="Número do ticket relacionado (ex.: 12345)"
disabled={isSubmitting}
/>
{linkedReference.trim().length === 0 ? (
<p className="text-xs text-neutral-500">Informe o número de outro ticket quando o atendimento estiver relacionado.</p>
) : isLinkLoading ? (
<p className="flex items-center gap-2 text-xs text-neutral-500">
<Spinner className="size-3" /> Procurando ticket #{normalizedReference}...
</p>
) : linkNotFound ? (
<p className="text-xs text-red-500">Ticket não encontrado ou sem acesso permitido. Verifique o número informado.</p>
) : linkedTicket ? (
<p className="text-xs text-emerald-600">
Será registrado vínculo com o ticket #{linkedTicket.reference} {linkedTicket.subject ?? "Sem assunto"}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="reopen-window" className="text-sm font-medium text-neutral-800">
Reabertura permitida
</Label>
<Select value={reopenWindowDays} onValueChange={setReopenWindowDays} disabled={isSubmitting}>
<SelectTrigger id="reopen-window">
<SelectValue placeholder="Escolha o prazo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">7 dias</SelectItem>
<SelectItem value="14">14 dias</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-neutral-500">Após esse período o ticket não poderá ser reaberto automaticamente.</p>
</div>
</div>
</div>
</div>
{enableAdjustment ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">

View file

@ -90,6 +90,23 @@ function RequesterPreview({ customer, company }: RequesterPreviewProps) {
const NO_COMPANY_VALUE = "__no_company__"
type TicketFormFieldDefinition = {
id: string
key: string
label: string
type: string
required: boolean
description: string
options: Array<{ value: string; label: string }>
}
type TicketFormDefinition = {
key: string
label: string
description: string
fields: TicketFormFieldDefinition[]
}
const schema = z.object({
subject: z.string().default(""),
summary: z.string().optional(),
@ -158,6 +175,41 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
[companiesRemote]
)
const formsRemote = useQuery(
api.tickets.listTicketForms,
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
) as TicketFormDefinition[] | undefined
const forms = useMemo<TicketFormDefinition[]>(() => {
const base: TicketFormDefinition = {
key: "default",
label: "Chamado padrão",
description: "Formulário básico para abertura de chamados gerais.",
fields: [],
}
if (Array.isArray(formsRemote) && formsRemote.length > 0) {
return [base, ...formsRemote]
}
return [base]
}, [formsRemote])
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
const selectedForm = useMemo(() => forms.find((formDef) => formDef.key === selectedFormKey) ?? forms[0], [forms, selectedFormKey])
const handleFormSelection = (key: string) => {
setSelectedFormKey(key)
setCustomFieldValues({})
}
const handleCustomFieldChange = (field: TicketFormFieldDefinition, value: unknown) => {
setCustomFieldValues((prev) => ({
...prev,
[field.id]: value,
}))
}
const customersRemote = useQuery(
api.users.listCustomers,
directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
@ -395,6 +447,52 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
return
}
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
for (const field of selectedForm.fields) {
const raw = customFieldValues[field.id]
const isBooleanField = field.type === "boolean"
const isEmpty =
raw === undefined ||
raw === null ||
(typeof raw === "string" && raw.trim().length === 0)
if (isBooleanField) {
const boolValue = Boolean(raw)
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value: boolValue })
continue
}
if (field.required && isEmpty) {
toast.error(`Preencha o campo "${field.label}".`, { id: "new-ticket" })
setLoading(false)
return
}
if (isEmpty) {
continue
}
let value: unknown = raw
if (field.type === "number") {
const parsed = typeof raw === "number" ? raw : Number(raw)
if (!Number.isFinite(parsed)) {
toast.error(`Informe um valor numérico válido para "${field.label}".`, { id: "new-ticket" })
setLoading(false)
return
}
value = parsed
} else if (field.type === "boolean") {
value = Boolean(raw)
} else if (field.type === "date") {
value = String(raw)
} else {
value = String(raw)
}
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value })
}
}
setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" })
try {
@ -413,6 +511,8 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
assigneeId: selectedAssignee ? (selectedAssignee as Id<"users">) : undefined,
categoryId: values.categoryId as Id<"ticketCategories">,
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
formTemplate: selectedFormKey !== "default" ? selectedFormKey : undefined,
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
})
const summaryFallback = values.summary?.trim() ?? ""
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
@ -446,6 +546,8 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
subcategoryId: "",
})
form.clearErrors()
setSelectedFormKey("default")
setCustomFieldValues({})
setAssigneeInitialized(false)
setAttachments([])
// Navegar para o ticket recém-criado
@ -497,6 +599,28 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
</Button>
</div>
</div>
{forms.length > 1 ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<p className="text-sm font-semibold text-neutral-800">Modelo de ticket</p>
<div className="mt-2 flex flex-wrap gap-2">
{forms.map((formDef) => (
<Button
key={formDef.key}
type="button"
variant={selectedFormKey === formDef.key ? "default" : "outline"}
size="sm"
onClick={() => handleFormSelection(formDef.key)}
>
{formDef.label}
</Button>
))}
</div>
{selectedForm?.description ? (
<p className="mt-2 text-xs text-neutral-500">{selectedForm.description}</p>
) : null}
</div>
) : null}
<FieldSet>
<FieldGroup className="lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div className="space-y-4">
@ -811,6 +935,118 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
</Select>
</Field>
</div>
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? (
<div className="space-y-4 rounded-xl border border-slate-200 bg-white px-4 py-4">
<p className="text-sm font-semibold text-neutral-800">Informações adicionais</p>
{selectedForm.fields.map((field) => {
const value = customFieldValues[field.id]
const fieldId = `custom-field-${field.id}`
const labelSuffix = field.required ? <span className="text-destructive">*</span> : null
const helpText = field.description ? (
<p className="text-xs text-neutral-500">{field.description}</p>
) : null
if (field.type === "boolean") {
return (
<div
key={field.id}
className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"
>
<input
id={fieldId}
type="checkbox"
className="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)}
onChange={(event) => handleCustomFieldChange(field, event.target.checked)}
/>
<div className="flex flex-col">
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
{field.label} {labelSuffix}
</label>
{helpText}
</div>
</div>
)
}
if (field.type === "select") {
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(selected) => handleCustomFieldChange(field, selected)}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value} className={selectItemClass}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{helpText}
</Field>
)
}
if (field.type === "number") {
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
type="number"
inputMode="decimal"
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
if (field.type === "date") {
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
type="date"
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
})}
</div>
) : null}
</div>
</FieldGroup>
</FieldSet>

View file

@ -32,6 +32,7 @@ export function StatusSelect({
value,
tenantId,
requesterName,
ticketReference,
showCloseButton = true,
onStatusChange,
}: {
@ -39,6 +40,7 @@ export function StatusSelect({
value: TicketStatus
tenantId: string
requesterName?: string | null
ticketReference?: number | null
showCloseButton?: boolean
onStatusChange?: (next: TicketStatus) => void
}) {
@ -94,6 +96,7 @@ export function StatusSelect({
ticketId={ticketId}
tenantId={tenantId}
actorId={actorId}
ticketReference={ticketReference ?? null}
requesterName={requesterName}
agentName={agentName}
onSuccess={() => {

View file

@ -0,0 +1,203 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
const MAX_MESSAGE_LENGTH = 4000
function formatRelative(timestamp: number) {
try {
return formatDistanceToNowStrict(timestamp, { locale: ptBR, addSuffix: true })
} catch {
return new Date(timestamp).toLocaleString("pt-BR")
}
}
type TicketChatPanelProps = {
ticketId: string
}
export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
const { convexUserId } = useAuth()
const viewerId = convexUserId ?? null
const chat = useQuery(
api.tickets.listChatMessages,
viewerId ? { ticketId: ticketId as Id<"tickets">, viewerId: viewerId as Id<"users"> } : "skip"
) as
| {
ticketId: string
chatEnabled: boolean
status: string
canPost: boolean
reopenDeadline: number | null
messages: Array<{
id: Id<"ticketChatMessages">
body: string
createdAt: number
updatedAt: number
authorId: string
authorName: string | null
authorEmail: string | null
attachments: Array<{ storageId: Id<"_storage">; name: string; size: number | null; type: string | null }>
readBy: Array<{ userId: string; readAt: number }>
}>
}
| null
| undefined
const markChatRead = useMutation(api.tickets.markChatRead)
const postChatMessage = useMutation(api.tickets.postChatMessage)
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const [draft, setDraft] = useState("")
const [isSending, setIsSending] = useState(false)
const messages = chat?.messages ?? []
const canPost = Boolean(chat?.canPost && viewerId)
const chatEnabled = Boolean(chat?.chatEnabled)
useEffect(() => {
if (!viewerId || !chat || !Array.isArray(chat.messages) || chat.messages.length === 0) return
const unreadIds = chat.messages
.filter((message) => {
const alreadyRead = (message.readBy ?? []).some((entry) => entry.userId === viewerId)
return !alreadyRead
})
.map((message) => message.id)
if (unreadIds.length === 0) return
void markChatRead({
ticketId: ticketId as Id<"tickets">,
actorId: viewerId as Id<"users">,
messageIds: unreadIds,
}).catch((error) => {
console.error("Failed to mark chat messages as read", error)
})
}, [markChatRead, chat, ticketId, viewerId])
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [messages.length])
const disabledReason = useMemo(() => {
if (!chatEnabled) return "Chat desativado para este ticket"
if (!canPost) return "Você não tem permissão para enviar mensagens"
return null
}, [canPost, chatEnabled])
const handleSend = async () => {
if (!viewerId || !canPost || draft.trim().length === 0) return
if (draft.length > MAX_MESSAGE_LENGTH) {
toast.error(`Mensagem muito longa (máx. ${MAX_MESSAGE_LENGTH} caracteres).`)
return
}
setIsSending(true)
toast.dismiss("ticket-chat")
toast.loading("Enviando mensagem...", { id: "ticket-chat" })
try {
await postChatMessage({
ticketId: ticketId as Id<"tickets">,
actorId: viewerId as Id<"users">,
body: draft,
})
setDraft("")
toast.success("Mensagem enviada!", { id: "ticket-chat" })
} catch (error) {
console.error(error)
toast.error("Não foi possível enviar a mensagem.", { id: "ticket-chat" })
} finally {
setIsSending(false)
}
}
if (!viewerId) {
return null
}
return (
<Card className="border-slate-200">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-base font-semibold text-neutral-800">Chat do atendimento</CardTitle>
{!chatEnabled ? (
<span className="text-xs font-medium text-neutral-500">Chat desativado</span>
) : null}
</CardHeader>
<CardContent className="space-y-4">
{chat === undefined ? (
<div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-slate-200 py-6 text-sm text-neutral-500">
<Spinner className="size-4" /> Carregando mensagens...
</div>
) : messages.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-neutral-500">
Nenhuma mensagem registrada no chat até o momento.
</div>
) : (
<div className="max-h-72 space-y-3 overflow-y-auto pr-2">
{messages.map((message) => {
const isOwn = String(message.authorId) === String(viewerId)
return (
<div
key={message.id}
className={cn(
"flex flex-col gap-1 rounded-lg border px-3 py-2 text-sm",
isOwn ? "border-slate-300 bg-slate-50" : "border-slate-200 bg-white"
)}
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold text-neutral-800">{message.authorName ?? "Usuário"}</span>
<span className="text-xs text-neutral-500">{formatRelative(message.createdAt)}</span>
</div>
<div
className="prose prose-sm max-w-none text-neutral-700"
dangerouslySetInnerHTML={{ __html: message.body }}
/>
</div>
)
})}
<div ref={messagesEndRef} />
</div>
)}
<div className="space-y-2">
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={disabledReason ?? "Digite uma mensagem"}
rows={3}
disabled={isSending || !canPost || !chatEnabled}
/>
<div className="flex items-center justify-between text-xs text-neutral-500">
<span>{draft.length}/{MAX_MESSAGE_LENGTH}</span>
<div className="inline-flex items-center gap-2">
{!chatEnabled ? (
<span className="text-neutral-500">Chat indisponível</span>
) : null}
<Button
type="button"
size="sm"
onClick={handleSend}
disabled={isSending || !canPost || !chatEnabled || draft.trim().length === 0}
>
{isSending ? <Spinner className="mr-2 size-4" /> : null}
Enviar
</Button>
</div>
</div>
{disabledReason && chatEnabled ? (
<p className="text-xs text-neutral-500">{disabledReason}</p>
) : null}
</div>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,234 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useMutation } from "convex/react"
import { useRouter } from "next/navigation"
import { Star } from "lucide-react"
import { formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { cn } from "@/lib/utils"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
type TicketCsatCardProps = {
ticket: TicketWithDetails
}
function formatRelative(timestamp: Date | null | undefined) {
if (!timestamp) return null
try {
return formatDistanceToNowStrict(timestamp, { locale: ptBR, addSuffix: true })
} catch {
return null
}
}
export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
const router = useRouter()
const { session, convexUserId, role: authRole } = useAuth()
const submitCsat = useMutation(api.tickets.submitCsat)
const viewerRole = (authRole ?? session?.user.role ?? "").toUpperCase()
const viewerEmail = session?.user.email?.trim().toLowerCase() ?? ""
const viewerId = convexUserId as Id<"users"> | undefined
const requesterEmail = ticket.requester.email.trim().toLowerCase()
const isRequesterById = viewerId ? ticket.requester.id === viewerId : false
const isRequesterByEmail = viewerEmail && requesterEmail ? viewerEmail === requesterEmail : false
const isRequester = isRequesterById || isRequesterByEmail
const isResolved = ticket.status === "RESOLVED"
const initialScore = typeof ticket.csatScore === "number" ? ticket.csatScore : 0
const initialComment = ticket.csatComment ?? ""
const maxScore = typeof ticket.csatMaxScore === "number" && ticket.csatMaxScore > 0 ? ticket.csatMaxScore : 5
const [score, setScore] = useState<number>(initialScore)
const [comment, setComment] = useState<string>(initialComment)
const [hasSubmitted, setHasSubmitted] = useState<boolean>(initialScore > 0)
const [ratedAt, setRatedAt] = useState<Date | null>(ticket.csatRatedAt ?? null)
const [hoverScore, setHoverScore] = useState<number | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
setScore(initialScore)
setComment(initialComment)
setRatedAt(ticket.csatRatedAt ?? null)
setHasSubmitted(initialScore > 0)
}, [initialScore, initialComment, ticket.csatRatedAt])
const effectiveScore = hasSubmitted ? score : hoverScore ?? score
const viewerIsStaff = viewerRole === "ADMIN" || viewerRole === "AGENT" || viewerRole === "MANAGER"
const staffCanInspect = viewerIsStaff && ticket.status !== "PENDING"
const canSubmit =
Boolean(viewerId && viewerRole === "COLLABORATOR" && isRequester && isResolved && !hasSubmitted)
const hasRating = hasSubmitted
const showCard = staffCanInspect || isRequester || hasSubmitted
const ratedAtRelative = useMemo(() => formatRelative(ratedAt), [ratedAt])
if (!showCard) {
return null
}
const handleSubmit = async () => {
if (!viewerId) {
toast.error("Sessão não autenticada.")
return
}
if (!canSubmit) {
toast.error("Você não pode avaliar este chamado.")
return
}
if (score < 1) {
toast.error("Selecione uma nota de 1 a 5 estrelas.")
return
}
if (comment.length > 2000) {
toast.error("Reduza o comentário para no máximo 2000 caracteres.")
return
}
try {
setSubmitting(true)
const result = await submitCsat({
ticketId: ticket.id as Id<"tickets">,
actorId: viewerId,
score,
maxScore,
comment: comment.trim() ? comment.trim() : undefined,
})
if (result?.score) {
setScore(result.score)
}
if (typeof result?.comment === "string") {
setComment(result.comment)
}
if (result?.ratedAt) {
const ratedAtDate = new Date(result.ratedAt)
if (!Number.isNaN(ratedAtDate.getTime())) {
setRatedAt(ratedAtDate)
}
}
setHasSubmitted(true)
toast.success("Avaliação registrada. Obrigado pelo feedback!")
router.refresh()
} catch (error) {
console.error("Failed to submit CSAT", error)
toast.error("Não foi possível registrar a avaliação. Tente novamente.")
} finally {
setSubmitting(false)
setHoverScore(null)
}
}
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">
<CardHeader className="px-4 pt-5 pb-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-lg font-semibold text-neutral-900">Avaliação do atendimento</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Conte como foi sua experiência com este chamado.
</CardDescription>
</div>
{hasRating ? (
<div className="flex items-center gap-1 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
Obrigado pelo feedback!
</div>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-2">
<div className="flex flex-wrap items-center gap-2">
{stars.map((value) => {
const filled = value <= effectiveScore
return (
<button
key={value}
type="button"
className={cn(
"flex size-10 items-center justify-center rounded-full border transition",
canSubmit
? filled
? "border-amber-300 bg-amber-50 text-amber-500 hover:border-amber-400 hover:bg-amber-100"
: "border-slate-200 bg-white text-slate-300 hover:border-amber-200 hover:bg-amber-50 hover:text-amber-400"
: filled
? "border-amber-200 bg-amber-50 text-amber-500"
: "border-slate-200 bg-white text-slate-300"
)}
onMouseEnter={() => (canSubmit ? setHoverScore(value) : undefined)}
onMouseLeave={() => (canSubmit ? setHoverScore(null) : undefined)}
onClick={() => (canSubmit ? setScore(value) : undefined)}
disabled={!canSubmit}
aria-label={`${value} estrela${value > 1 ? "s" : ""}`}
>
<Star
className="size-5"
strokeWidth={1.5}
fill={(canSubmit && value <= (hoverScore ?? score)) || (!canSubmit && value <= score) ? "currentColor" : "none"}
/>
</button>
)
})}
</div>
{hasRating ? (
<p className="text-sm text-neutral-600">
Nota final:{" "}
<span className="font-semibold text-neutral-900">
{score}/{maxScore}
</span>
{ratedAtRelative ? `${ratedAtRelative}` : null}
</p>
) : null}
{canSubmit ? (
<div className="space-y-2">
<label htmlFor="csat-comment" className="text-sm font-medium text-neutral-800">
Deixe um comentário (opcional)
</label>
<Textarea
id="csat-comment"
placeholder="O que funcionou bem? Algo poderia ser melhor?"
value={comment}
onChange={(event) => setComment(event.target.value)}
maxLength={2000}
className="min-h-[90px] resize-y"
/>
<div className="flex justify-end text-xs text-neutral-500">{comment.length}/2000</div>
</div>
) : hasRating && comment ? (
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700">
<p className="whitespace-pre-line">{comment}</p>
</div>
) : null}
{viewerIsStaff && !hasRating ? (
<p className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-500">
Nenhuma avaliação registrada para este chamado até o momento.
</p>
) : null}
{!isResolved && viewerRole === "COLLABORATOR" && isRequester && !hasSubmitted ? (
<p className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-500">
Assim que o chamado for encerrado, você poderá registrar sua avaliação aqui.
</p>
) : null}
</CardContent>
{canSubmit ? (
<CardFooter className="flex flex-col gap-2 px-4 pb-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-neutral-500">
Sua avaliação ajuda a equipe a melhorar continuamente o atendimento.
</p>
<Button type="button" onClick={handleSubmit} disabled={submitting || score < 1}>
{submitting ? "Enviando..." : "Enviar avaliação"}
</Button>
</CardFooter>
) : null}
</Card>
)
}

View file

@ -12,6 +12,8 @@ import { TicketComments } from "@/components/tickets/ticket-comments.rich";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card";
import { TicketChatPanel } from "@/components/tickets/ticket-chat-panel";
import { useAuth } from "@/lib/auth-client";
export function TicketDetailView({ id }: { id: string }) {
@ -90,9 +92,11 @@ export function TicketDetailView({ id }: { id: string }) {
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket} />
<TicketCsatCard ticket={ticket} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<TicketComments ticket={ticket} />
<TicketChatPanel ticketId={ticket.id as string} />
<TicketTimeline ticket={ticket} />
</div>
<TicketDetailsPanel ticket={ticket} />

View file

@ -142,6 +142,22 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
: machineAssignedName && machineAssignedName.length > 0
? machineAssignedName
: null
const viewerId = convexUserId ?? null
const viewerRole = (role ?? "").toLowerCase()
const [status, setStatus] = useState<TicketStatus>(ticket.status)
const reopenDeadline = ticket.reopenDeadline ?? null
const isRequester = Boolean(ticket.requester?.id && viewerId && ticket.requester.id === viewerId)
const reopenWindowActive = reopenDeadline ? reopenDeadline > Date.now() : false
const canReopenTicket =
status === "RESOLVED" && reopenWindowActive && (isStaff || viewerRole === "manager" || isRequester)
const reopenDeadlineLabel = useMemo(() => {
if (!reopenDeadline) return null
try {
return new Date(reopenDeadline).toLocaleString("pt-BR")
} catch {
return null
}
}, [reopenDeadline])
const viewerEmail = session?.user?.email ?? machineContext?.assignedUserEmail ?? null
const viewerAvatar = session?.user?.avatarUrl ?? null
const viewerAgentMeta = useMemo(
@ -165,6 +181,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const startWork = useMutation(api.tickets.startWork)
const pauseWork = useMutation(api.tickets.pauseWork)
const updateCategories = useMutation(api.tickets.updateCategories)
const reopenTicket = useMutation(api.tickets.reopenTicket)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queuesEnabled = Boolean(isStaff && convexUserId)
const companiesRemote = useQuery(
@ -227,7 +244,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
| null
| undefined
const [status, setStatus] = useState<TicketStatus>(ticket.status)
const [assigneeState, setAssigneeState] = useState(ticket.assignee ?? null)
const [editing, setEditing] = useState(false)
const [subject, setSubject] = useState(ticket.subject)
@ -242,6 +258,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [assigneeSelection, setAssigneeSelection] = useState(currentAssigneeId)
const [saving, setSaving] = useState(false)
const [pauseDialogOpen, setPauseDialogOpen] = useState(false)
const [isReopening, setIsReopening] = useState(false)
const [pauseReason, setPauseReason] = useState<string>(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
const [pauseNote, setPauseNote] = useState("")
const [pausing, setPausing] = useState(false)
@ -326,8 +343,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id])
const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty
const assigneeReasonRequired = assigneeDirty && !isManager
const assigneeReasonValid = !assigneeReasonRequired || assigneeChangeReason.trim().length >= 5
const normalizedAssigneeReason = assigneeChangeReason.trim()
const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5
const saveDisabled = !formDirty || saving || !assigneeReasonValid
const companyLabel = useMemo(() => {
if (ticket.company?.name) return ticket.company.name
@ -488,9 +505,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
throw new Error("assignee-not-allowed")
}
const reasonValue = assigneeChangeReason.trim()
if (reasonValue.length < 5) {
setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres.")
toast.error("Informe um motivo para registrar a troca do responsável.", { id: "assignee" })
if (reasonValue.length > 0 && reasonValue.length < 5) {
setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres ou deixe em branco.")
toast.error("Informe ao menos 5 caracteres no motivo ou deixe o campo vazio.", { id: "assignee" })
return
}
if (reasonValue.length > 1000) {
@ -505,7 +522,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
ticketId: ticket.id as Id<"tickets">,
assigneeId: assigneeSelection as Id<"users">,
actorId: convexUserId as Id<"users">,
reason: reasonValue,
reason: reasonValue.length > 0 ? reasonValue : undefined,
})
toast.success("Responsável atualizado!", { id: "assignee" })
if (assigneeSelection) {
@ -1008,6 +1025,26 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
}, [ticket.id, ticket.reference])
const handleReopenTicket = useCallback(async () => {
if (!viewerId) {
toast.error("Não foi possível identificar o usuário atual.")
return
}
toast.dismiss("ticket-reopen")
setIsReopening(true)
toast.loading("Reabrindo ticket...", { id: "ticket-reopen" })
try {
await reopenTicket({ ticketId: ticket.id as Id<"tickets">, actorId: viewerId as Id<"users"> })
toast.success("Ticket reaberto com sucesso!", { id: "ticket-reopen" })
setStatus("AWAITING_ATTENDANCE")
} catch (error) {
console.error(error)
toast.error("Não foi possível reabrir o ticket.", { id: "ticket-reopen" })
} finally {
setIsReopening(false)
}
}, [reopenTicket, ticket.id, viewerId])
return (
<div className={cardClass}>
<div className="absolute right-6 top-6 flex items-center gap-3">
@ -1065,6 +1102,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
ticketId={ticket.id as unknown as string}
tenantId={ticket.tenantId}
actorId={convexUserId as Id<"users"> | null}
ticketReference={ticket.reference ?? null}
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
agentName={agentName}
workSummary={
@ -1095,9 +1133,26 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
value={status}
tenantId={ticket.tenantId}
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
ticketReference={ticket.reference ?? null}
showCloseButton={false}
onStatusChange={setStatus}
/>
{canReopenTicket ? (
<Button
type="button"
variant="outline"
size="sm"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white text-sm font-semibold text-neutral-700 hover:bg-slate-50"
onClick={handleReopenTicket}
disabled={isReopening}
>
{isReopening ? <Spinner className="size-4 text-neutral-600" /> : null}
Reabrir
</Button>
) : null}
{canReopenTicket && reopenDeadlineLabel ? (
<p className="text-xs text-neutral-500">Prazo para reabrir: {reopenDeadlineLabel}</p>
) : null}
{isPlaying ? (
<Tooltip>
<TooltipTrigger asChild>
@ -1427,8 +1482,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</p>
{assigneeReasonError ? (
<p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p>
) : assigneeReasonRequired && assigneeChangeReason.trim().length < 5 ? (
<p className="text-xs font-semibold text-rose-600">Descreva o motivo com pelo menos 5 caracteres.</p>
) : normalizedAssigneeReason.length > 0 && normalizedAssigneeReason.length < 5 ? (
<p className="text-xs font-semibold text-rose-600">Descreva o motivo com pelo menos 5 caracteres ou deixe em branco.</p>
) : null}
</div>
) : null}

View file

@ -351,17 +351,55 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
message = "CSAT recebido"
}
if (entry.type === "CSAT_RATED") {
const score = typeof payload.score === "number" ? payload.score : payload.rating
const maxScore =
typeof payload.maxScore === "number"
? payload.maxScore
: typeof payload.max === "number"
? payload.max
const rawScoreSource = (payload as { score?: unknown; rating?: unknown }) ?? {}
const rawScore =
typeof rawScoreSource.score === "number"
? rawScoreSource.score
: typeof rawScoreSource.rating === "number"
? rawScoreSource.rating
: null
const rawMaxSource = (payload as { maxScore?: unknown; max?: unknown }) ?? {}
const rawMax =
typeof rawMaxSource.maxScore === "number"
? rawMaxSource.maxScore
: typeof rawMaxSource.max === "number"
? rawMaxSource.max
: undefined
message =
typeof score === "number"
? `CSAT avaliado: ${score}${typeof maxScore === "number" ? `/${maxScore}` : ""}`
: "CSAT avaliado"
const safeMax = rawMax && Number.isFinite(rawMax) && rawMax > 0 ? Math.round(rawMax) : 5
const safeScore =
typeof rawScore === "number" && Number.isFinite(rawScore)
? Math.max(1, Math.min(safeMax, Math.round(rawScore)))
: null
const rawComment = (payload as { comment?: unknown })?.comment
const comment =
typeof rawComment === "string" && rawComment.trim().length > 0
? rawComment.trim()
: null
message = (
<div className="space-y-1">
<span>
CSAT avaliado:{" "}
<span className="font-semibold text-neutral-900">
{safeScore ?? "—"}/{safeMax}
</span>
</span>
<div className="flex items-center gap-1 text-amber-500">
{Array.from({ length: safeMax }).map((_, index) => (
<IconStar
key={index}
className="size-3.5"
strokeWidth={1.5}
fill={safeScore !== null && index < safeScore ? "currentColor" : "none"}
/>
))}
</div>
{comment ? (
<span className="block rounded-lg bg-slate-100 px-3 py-1 text-xs text-neutral-600">
{comment}
</span>
) : null}
</div>
)
}
if (!message) return null

View file

@ -104,28 +104,30 @@ ${colorConfig
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
label,
labelFormatter,
labelClassName,
formatter,
valueFormatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
valueFormatter?: (value: unknown, name?: string, item?: unknown) => React.ReactNode
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
@ -234,11 +236,23 @@ function ChartTooltipContent({
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
{item.value !== undefined && item.value !== null && (
<span className="text-foreground font-mono font-medium tabular-nums">
{valueFormatter
? valueFormatter(
item.value,
item.name !== undefined && item.name !== null
? String(item.name)
: item.dataKey !== undefined && item.dataKey !== null
? String(item.dataKey)
: undefined,
item
)
: typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}

View file

@ -103,7 +103,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}, [session?.user])
// Sempre tenta obter o contexto da máquina.
// Sempre tenta obter o contexto da dispositivo.
// 1) Se a sessão Better Auth indicar role "machine", buscamos normalmente.
// 2) Se a sessão vier nula (alguns ambientes WebView), ainda assim tentamos
// carregar o contexto — se a API responder 200, assumimos que há sessão válida
@ -303,7 +303,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ensureUser, session?.user?.email, session?.user?.tenantId, session?.user?.role, convexUserId])
// Se não houver sessão mas tivermos contexto de máquina, tratamos como "machine"
// Se não houver sessão mas tivermos contexto de dispositivo, tratamos como "machine"
const baseRole = session?.user?.role ? session.user.role.toLowerCase() : (machineContext ? "machine" : null)
const personaRole = session?.user?.machinePersona ? session.user.machinePersona.toLowerCase() : null
const normalizedRole =

View file

@ -0,0 +1,67 @@
export type DeviceInventoryColumnMetadata = {
key: string
label: string
width: number
default?: boolean
description?: string
}
export type DeviceInventoryColumnConfig = {
key: string
label?: string
}
export const DEVICE_INVENTORY_COLUMN_METADATA: DeviceInventoryColumnMetadata[] = [
{ key: "displayName", label: "Dispositivo", width: 26, default: true },
{ key: "hostname", label: "Hostname", width: 24, default: true },
{ key: "deviceType", label: "Tipo", width: 14, default: true },
{ key: "devicePlatform", label: "Plataforma", width: 18, default: true },
{ key: "company", label: "Empresa", width: 26, default: true },
{ key: "status", label: "Status", width: 16, default: true },
{ key: "persona", label: "Persona", width: 16, default: true },
{ key: "active", label: "Ativo", width: 10, default: true },
{ key: "lastHeartbeat", label: "Último heartbeat", width: 20, default: true },
{ key: "assignedUser", label: "Responsável", width: 24, default: true },
{ key: "assignedEmail", label: "E-mail responsável", width: 26, default: true },
{ key: "linkedUsers", label: "Usuários vinculados", width: 28, default: true },
{ key: "authEmail", label: "E-mail autenticado", width: 26, default: true },
{ key: "osName", label: "Sistema operacional", width: 20, default: true },
{ key: "osVersion", label: "Versão SO", width: 18, default: true },
{ key: "architecture", label: "Arquitetura", width: 14, default: true },
{ key: "hardwareVendor", label: "Fabricante", width: 20, default: true },
{ key: "hardwareModel", label: "Modelo", width: 22, default: true },
{ key: "hardwareSerial", label: "Serial hardware", width: 24, default: true },
{ key: "cpu", label: "Processador", width: 22, default: true },
{ key: "physicalCores", label: "Cores físicas", width: 14, default: true },
{ key: "logicalCores", label: "Cores lógicas", width: 14, default: true },
{ key: "memoryGiB", label: "Memória (GiB)", width: 20, default: true },
{ key: "gpus", label: "GPUs", width: 24, default: true },
{ key: "labels", label: "Labels", width: 24, default: true },
{ key: "macs", label: "MACs", width: 24, default: true },
{ key: "serials", label: "Seriais", width: 24, default: true },
{ key: "primaryIp", label: "IP principal", width: 18, default: true },
{ key: "publicIp", label: "IP público", width: 18, default: true },
{ key: "registeredBy", label: "Registrado via", width: 18, default: true },
{ key: "tokenExpiresAt", label: "Token expira em", width: 20, default: true },
{ key: "tokenLastUsedAt", label: "Token último uso", width: 20, default: true },
{ key: "tokenUsageCount", label: "Uso do token", width: 14, default: true },
{ key: "createdAt", label: "Criado em", width: 20, default: true },
{ key: "updatedAt", label: "Atualizado em", width: 20, default: true },
{ key: "softwareCount", label: "Softwares instalados", width: 20, default: true },
{ key: "osBuild", label: "Build SO", width: 18, default: true },
{ key: "osLicense", label: "Licença ativada", width: 18, default: true },
{ key: "osExperience", label: "Experiência SO", width: 18, default: true },
{ key: "domain", label: "Domínio", width: 18, default: true },
{ key: "workgroup", label: "Grupo de trabalho", width: 20, default: true },
{ key: "deviceName", label: "Nome do dispositivo", width: 24, default: true },
{ key: "boardSerial", label: "Serial placa-mãe", width: 24, default: true },
{ key: "collaboratorName", label: "Colaborador (nome)", width: 24, default: true },
{ key: "collaboratorEmail", label: "Colaborador (e-mail)", width: 26, default: true },
{ key: "remoteAccessCount", label: "Acessos remotos", width: 18, default: true },
{ key: "fleetId", label: "Fleet ID", width: 18, default: true },
{ key: "fleetTeam", label: "Equipe Fleet", width: 18, default: true },
{ key: "fleetUpdatedAt", label: "Fleet atualizado em", width: 20, default: true },
{ key: "managementMode", label: "Modo de gestão", width: 20, default: false },
]
export type DeviceInventoryColumnKey = (typeof DEVICE_INVENTORY_COLUMN_METADATA)[number]["key"]

View file

@ -65,6 +65,11 @@ const serverTicketSchema = z.object({
tags: z.array(z.string()).default([]).optional(),
lastTimelineEntry: z.string().nullable().optional(),
metrics: z.any().nullable().optional(),
csatScore: z.number().nullable().optional(),
csatMaxScore: z.number().nullable().optional(),
csatComment: z.string().nullable().optional(),
csatRatedAt: z.number().nullable().optional(),
csatRatedBy: z.string().nullable().optional(),
category: z
.object({
id: z.string(),
@ -154,9 +159,17 @@ const serverTicketWithDetailsSchema = serverTicketSchema.extend({
});
export function mapTicketFromServer(input: unknown) {
const s = serverTicketSchema.parse(input);
const {
csatScore,
csatMaxScore,
csatComment,
csatRatedAt,
csatRatedBy,
...base
} = serverTicketSchema.parse(input);
const s = { csatScore, csatMaxScore, csatComment, csatRatedAt, csatRatedBy, ...base };
const ui = {
...s,
...base,
status: normalizeTicketStatus(s.status),
company: s.company
? { id: s.company.id, name: s.company.name, isAvulso: s.company.isAvulso ?? false }
@ -179,6 +192,11 @@ export function mapTicketFromServer(input: unknown) {
dueAt: s.dueAt ? new Date(s.dueAt) : null,
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
csatScore: typeof csatScore === "number" ? csatScore : null,
csatMaxScore: typeof csatMaxScore === "number" ? csatMaxScore : null,
csatComment: typeof csatComment === "string" && csatComment.trim().length > 0 ? csatComment.trim() : null,
csatRatedAt: csatRatedAt ? new Date(csatRatedAt) : null,
csatRatedBy: csatRatedBy ?? null,
workSummary: s.workSummary
? {
totalWorkedMs: s.workSummary.totalWorkedMs,
@ -211,7 +229,15 @@ export function mapTicketsFromServerList(arr: unknown[]) {
}
export function mapTicketWithDetailsFromServer(input: unknown) {
const s = serverTicketWithDetailsSchema.parse(input);
const {
csatScore,
csatMaxScore,
csatComment,
csatRatedAt,
csatRatedBy,
...base
} = serverTicketWithDetailsSchema.parse(input);
const s = { csatScore, csatMaxScore, csatComment, csatRatedAt, csatRatedBy, ...base };
const customFields = Object.entries(s.customFields ?? {}).reduce<
Record<string, { label: string; type: string; value?: unknown; displayValue?: string }>
>(
@ -231,47 +257,52 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
{}
);
const ui = {
...s,
...base,
customFields,
status: normalizeTicketStatus(s.status),
category: s.category ?? undefined,
subcategory: s.subcategory ?? undefined,
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
updatedAt: new Date(s.updatedAt),
createdAt: new Date(s.createdAt),
dueAt: s.dueAt ? new Date(s.dueAt) : null,
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
company: s.company ? { id: s.company.id, name: s.company.name, isAvulso: s.company.isAvulso ?? false } : undefined,
machine: s.machine
status: normalizeTicketStatus(base.status),
category: base.category ?? undefined,
subcategory: base.subcategory ?? undefined,
lastTimelineEntry: base.lastTimelineEntry ?? undefined,
updatedAt: new Date(base.updatedAt),
createdAt: new Date(base.createdAt),
dueAt: base.dueAt ? new Date(base.dueAt) : null,
firstResponseAt: base.firstResponseAt ? new Date(base.firstResponseAt) : null,
resolvedAt: base.resolvedAt ? new Date(base.resolvedAt) : null,
csatScore: typeof csatScore === "number" ? csatScore : null,
csatMaxScore: typeof csatMaxScore === "number" ? csatMaxScore : null,
csatComment: typeof csatComment === "string" && csatComment.trim().length > 0 ? csatComment.trim() : null,
csatRatedAt: csatRatedAt ? new Date(csatRatedAt) : null,
csatRatedBy: csatRatedBy ?? null,
company: base.company ? { id: base.company.id, name: base.company.name, isAvulso: base.company.isAvulso ?? false } : undefined,
machine: base.machine
? {
id: s.machine.id ?? null,
hostname: s.machine.hostname ?? null,
persona: s.machine.persona ?? null,
assignedUserName: s.machine.assignedUserName ?? null,
assignedUserEmail: s.machine.assignedUserEmail ?? null,
status: s.machine.status ?? null,
id: base.machine.id ?? null,
hostname: base.machine.hostname ?? null,
persona: base.machine.persona ?? null,
assignedUserName: base.machine.assignedUserName ?? null,
assignedUserEmail: base.machine.assignedUserEmail ?? null,
status: base.machine.status ?? null,
}
: null,
timeline: s.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })),
comments: s.comments.map((c) => ({
timeline: base.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })),
comments: base.comments.map((c) => ({
...c,
createdAt: new Date(c.createdAt),
updatedAt: new Date(c.updatedAt),
})),
workSummary: s.workSummary
workSummary: base.workSummary
? {
totalWorkedMs: s.workSummary.totalWorkedMs,
internalWorkedMs: s.workSummary.internalWorkedMs ?? 0,
externalWorkedMs: s.workSummary.externalWorkedMs ?? 0,
serverNow: s.workSummary.serverNow,
activeSession: s.workSummary.activeSession
totalWorkedMs: base.workSummary.totalWorkedMs,
internalWorkedMs: base.workSummary.internalWorkedMs ?? 0,
externalWorkedMs: base.workSummary.externalWorkedMs ?? 0,
serverNow: base.workSummary.serverNow,
activeSession: base.workSummary.activeSession
? {
...s.workSummary.activeSession,
startedAt: new Date(s.workSummary.activeSession.startedAt),
...base.workSummary.activeSession,
startedAt: new Date(base.workSummary.activeSession.startedAt),
}
: null,
perAgentTotals: (s.workSummary.perAgentTotals ?? []).map((item) => ({
perAgentTotals: (base.workSummary.perAgentTotals ?? []).map((item) => ({
agentId: item.agentId,
agentName: item.agentName ?? null,
agentEmail: item.agentEmail ?? null,

View file

@ -139,17 +139,30 @@ export const ticketSchema = z.object({
.nullable(),
dueAt: z.coerce.date().nullable(),
firstResponseAt: z.coerce.date().nullable(),
resolvedAt: z.coerce.date().nullable(),
updatedAt: z.coerce.date(),
createdAt: z.coerce.date(),
tags: z.array(z.string()).default([]),
lastTimelineEntry: z.string().optional(),
metrics: z
.object({
timeWaitingMinutes: z.number().nullable(),
timeOpenedMinutes: z.number().nullable(),
})
.nullable(),
resolvedAt: z.coerce.date().nullable(),
updatedAt: z.coerce.date(),
createdAt: z.coerce.date(),
tags: z.array(z.string()).default([]),
lastTimelineEntry: z.string().optional(),
metrics: z
.object({
timeWaitingMinutes: z.number().nullable(),
timeOpenedMinutes: z.number().nullable(),
})
.nullable(),
relatedTicketIds: z.array(z.string()).optional(),
resolvedWithTicketId: z.string().nullable().optional(),
reopenDeadline: z.number().nullable().optional(),
reopenWindowDays: z.number().nullable().optional(),
reopenedAt: z.number().nullable().optional(),
reopenedBy: z.string().nullable().optional(),
chatEnabled: z.boolean().optional(),
formTemplate: z.string().nullable().optional(),
csatScore: z.number().nullable().optional(),
csatMaxScore: z.number().nullable().optional(),
csatComment: z.string().nullable().optional(),
csatRatedAt: z.coerce.date().nullable().optional(),
csatRatedBy: z.string().nullable().optional(),
category: ticketCategorySummarySchema.nullable().optional(),
subcategory: ticketSubcategorySummarySchema.nullable().optional(),
workSummary: z

View file

@ -15,7 +15,7 @@ export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
const context = await auth.$context
const passwordHash = await context.password.hash(machineToken)
const machineName = `Máquina ${hostname}`
const machineName = `Dispositivo ${hostname}`
const user = await prisma.authUser.upsert({
where: { email: machineEmail },

View file

@ -31,7 +31,7 @@ export type MachineSessionContext = {
}
export class MachineInactiveError extends Error {
constructor(message = "Máquina desativada") {
constructor(message = "Dispositivo desativada") {
super(message)
this.name = "MachineInactiveError"
}
@ -45,7 +45,7 @@ export async function createMachineSession(machineToken: string, rememberMe = tr
const client = new ConvexHttpClient(convexUrl)
const resolved = await client.mutation(api.machines.resolveToken, { machineToken })
const resolved = await client.mutation(api.devices.resolveToken, { machineToken })
let machineEmail = resolved.machine.authEmail ?? null
const machineActive = resolved.machine.isActive ?? true
@ -62,7 +62,7 @@ export async function createMachineSession(machineToken: string, rememberMe = tr
persona: (resolved.machine.persona ?? null) ?? undefined,
})
await client.mutation(api.machines.linkAuthAccount, {
await client.mutation(api.devices.linkAuthAccount, {
machineId: resolved.machine._id as Id<"machines">,
authUserId: account.authUserId,
authEmail: account.authEmail,

View file

@ -1,5 +1,15 @@
import { buildXlsxWorkbook, type WorksheetConfig } from "@/lib/xlsx"
import type { Id } from "@/convex/_generated/dataModel"
import { DEVICE_INVENTORY_COLUMN_METADATA, type DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns"
import { buildXlsxWorkbook, type WorksheetConfig } from "@/lib/xlsx"
type DeviceCustomField = {
fieldId: Id<"deviceFields">
fieldKey: string
label: string
type: string
value: unknown
displayValue?: string
}
type LinkedUser = {
id: string
@ -11,6 +21,11 @@ export type MachineInventoryRecord = {
id: Id<"machines">
tenantId: string
hostname: string
displayName: string | null
deviceType: string | null
devicePlatform: string | null
deviceProfile?: Record<string, unknown> | null
managementMode: string | null
companyId: Id<"companies"> | null
companySlug: string | null
companyName: string | null
@ -36,6 +51,7 @@ export type MachineInventoryRecord = {
lastPostureAt?: number | null
remoteAccess?: unknown
linkedUsers?: LinkedUser[]
customFields?: DeviceCustomField[]
}
type WorkbookOptions = {
@ -43,6 +59,7 @@ type WorkbookOptions = {
generatedBy?: string | null
companyFilterLabel?: string | null
generatedAt?: Date
columns?: DeviceInventoryColumnConfig[]
}
type SoftwareEntry = {
@ -54,59 +71,270 @@ type SoftwareEntry = {
installedOn: string | null
}
const INVENTORY_HEADERS = [
"Hostname",
"Empresa",
"Status",
"Persona",
"Ativa",
"Último heartbeat",
"Responsável",
"E-mail responsável",
"Usuários vinculados",
"E-mail autenticado",
"Sistema operacional",
"Versão SO",
"Arquitetura",
"Fabricante",
"Modelo",
"Serial hardware",
"Processador",
"Cores físicas",
"Cores lógicas",
"Memória (GiB)",
"GPUs",
"Labels",
"MACs",
"Seriais",
"IP principal",
"IP público",
"Registrada via",
"Token expira em",
"Token último uso",
"Uso do token",
"Criada em",
"Atualizada em",
"Softwares instalados",
"Build SO",
"Licença ativada",
"Experiência SO",
"Domínio",
"Grupo de trabalho",
"Nome do dispositivo",
"Serial placa-mãe",
"Colaborador (nome)",
"Colaborador (e-mail)",
"Acessos remotos",
"Fleet ID",
"Equipe Fleet",
"Fleet atualizado em",
] as const
type InventoryColumnDefinition = {
key: string
label: string
width: number
getValue: (machine: MachineInventoryRecord, derived: MachineDerivedData) => unknown
}
const INVENTORY_COLUMN_WIDTHS = [
22, 26, 16, 14, 10, 20, 22, 24, 28, 24, 20, 18, 14, 18, 22, 22, 24, 12, 12, 14, 26, 20, 24, 24, 18, 18, 18, 20, 20, 14, 20, 20, 18,
18, 18, 20, 20, 20, 26, 24, 24, 26, 16, 18, 18, 20,
] as const
type MachineDerivedData = {
inventory: Record<string, unknown>
hardware: ReturnType<typeof extractHardware>
gpuNames: string[]
labels: string[]
primaryIp: string | null
publicIp: string | null
softwareEntries: SoftwareEntry[]
linkedUsersLabel: string | null
systemInfo: ReturnType<typeof extractSystemInfo>
collaborator: ReturnType<typeof extractCollaborator>
remoteAccessCount: number
fleetInfo: ReturnType<typeof extractFleetInfo>
customFieldByKey: Record<string, DeviceCustomField>
customFieldById: Record<string, DeviceCustomField>
}
const COLUMN_VALUE_RESOLVERS: Record<string, (machine: MachineInventoryRecord, derived: MachineDerivedData) => unknown> = {
displayName: (machine) => machine.displayName ?? machine.hostname,
hostname: (machine) => machine.hostname,
deviceType: (machine) => describeDeviceType(machine.deviceType),
devicePlatform: (machine) => machine.devicePlatform ?? null,
company: (machine) => machine.companyName ?? null,
status: (machine) => describeStatus(machine.status),
persona: (machine) => describePersona(machine.persona),
active: (machine) => yesNo(machine.isActive),
lastHeartbeat: (machine) => formatDateTime(machine.lastHeartbeatAt),
assignedUser: (machine) => machine.assignedUserName ?? machine.assignedUserEmail ?? null,
assignedEmail: (machine) => machine.assignedUserEmail ?? null,
linkedUsers: (_machine, derived) => derived.linkedUsersLabel,
authEmail: (machine) => machine.authEmail ?? null,
osName: (machine) => machine.osName,
osVersion: (machine) => machine.osVersion ?? null,
architecture: (machine) => machine.architecture ?? null,
hardwareVendor: (_machine, derived) => derived.hardware.vendor ?? derived.systemInfo.systemManufacturer ?? null,
hardwareModel: (_machine, derived) => derived.hardware.model ?? derived.systemInfo.systemModel ?? null,
hardwareSerial: (_machine, derived) => derived.hardware.serial ?? derived.systemInfo.boardSerial ?? null,
cpu: (_machine, derived) => derived.hardware.cpuType ?? null,
physicalCores: (_machine, derived) => derived.hardware.physicalCores ?? null,
logicalCores: (_machine, derived) => derived.hardware.logicalCores ?? null,
memoryGiB: (_machine, derived) => derived.hardware.memoryGiB ?? null,
gpus: (_machine, derived) => (derived.gpuNames.length > 0 ? derived.gpuNames.join(", ") : null),
labels: (_machine, derived) => (derived.labels.length > 0 ? derived.labels.join(", ") : null),
macs: (machine) => (machine.macAddresses.length > 0 ? machine.macAddresses.join(", ") : null),
serials: (machine) => (machine.serialNumbers.length > 0 ? machine.serialNumbers.join(", ") : null),
primaryIp: (_machine, derived) => derived.primaryIp ?? null,
publicIp: (_machine, derived) => derived.publicIp ?? null,
registeredBy: (machine) => machine.registeredBy ?? null,
tokenExpiresAt: (machine) => (machine.token?.expiresAt ? formatDateTime(machine.token.expiresAt) : null),
tokenLastUsedAt: (machine) => (machine.token?.lastUsedAt ? formatDateTime(machine.token.lastUsedAt) : null),
tokenUsageCount: (machine) => machine.token?.usageCount ?? 0,
createdAt: (machine) => formatDateTime(machine.createdAt),
updatedAt: (machine) => formatDateTime(machine.updatedAt),
softwareCount: (_machine, derived) => derived.softwareEntries.length,
osBuild: (_machine, derived) => derived.systemInfo.osBuild ?? null,
osLicense: (_machine, derived) => derived.systemInfo.license ?? null,
osExperience: (_machine, derived) => derived.systemInfo.experience ?? null,
domain: (_machine, derived) => derived.systemInfo.domain ?? null,
workgroup: (_machine, derived) => derived.systemInfo.workgroup ?? null,
deviceName: (_machine, derived) => derived.systemInfo.deviceName ?? null,
boardSerial: (_machine, derived) => derived.systemInfo.boardSerial ?? derived.hardware.serial ?? null,
collaboratorName: (_machine, derived) => derived.collaborator?.name ?? null,
collaboratorEmail: (_machine, derived) => derived.collaborator?.email ?? null,
remoteAccessCount: (_machine, derived) => derived.remoteAccessCount,
fleetId: (_machine, derived) => derived.fleetInfo?.id ?? null,
fleetTeam: (_machine, derived) => derived.fleetInfo?.team ?? null,
fleetUpdatedAt: (_machine, derived) =>
derived.fleetInfo?.updatedAt ? formatDateTime(derived.fleetInfo.updatedAt) : null,
managementMode: (machine) => describeManagementMode(machine.managementMode),
}
const DEFAULT_COLUMN_CONFIG: DeviceInventoryColumnConfig[] = DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map(
(meta) => ({ key: meta.key })
)
const INVENTORY_COLUMN_DEFINITIONS: InventoryColumnDefinition[] = DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({
key: meta.key,
label: meta.label,
width: meta.width,
getValue: COLUMN_VALUE_RESOLVERS[meta.key] ?? (() => null),
}))
const INVENTORY_COLUMN_MAP: Record<string, InventoryColumnDefinition> = Object.fromEntries(
INVENTORY_COLUMN_DEFINITIONS.map((column) => [column.key, column]),
)
const CUSTOM_FIELD_KEY_PREFIX = "custom:"
const CUSTOM_FIELD_ID_PREFIX = "custom#"
function deriveMachineData(machine: MachineInventoryRecord): MachineDerivedData {
const inventory = toRecord(machine.inventory) ?? {}
const hardware = extractHardware(inventory)
const gpuNames = extractGpuNames(inventory)
const labels = extractLabels(inventory)
const primaryIp = extractPrimaryIp(inventory)
const publicIp = extractPublicIp(inventory)
const softwareEntries = extractSoftwareEntries(machine.hostname, inventory)
const linkedUsersLabel = summarizeLinkedUsers(machine.linkedUsers)
const systemInfo = extractSystemInfo(inventory)
const collaborator = extractCollaborator(machine, inventory)
const remoteAccessCount = collectRemoteAccessEntries(machine).length
const fleetInfo = extractFleetInfo(inventory)
const customFieldByKey: Record<string, DeviceCustomField> = {}
const customFieldById: Record<string, DeviceCustomField> = {}
for (const field of machine.customFields ?? []) {
customFieldByKey[field.fieldKey] = field
customFieldById[String(field.fieldId)] = field
}
return {
inventory,
hardware,
gpuNames,
labels,
primaryIp,
publicIp,
softwareEntries,
linkedUsersLabel,
systemInfo,
collaborator,
remoteAccessCount,
fleetInfo,
customFieldByKey,
customFieldById,
}
}
function normalizeColumnConfig(columns?: DeviceInventoryColumnConfig[]): DeviceInventoryColumnConfig[] {
if (!columns || columns.length === 0) {
return [...DEFAULT_COLUMN_CONFIG]
}
const seen = new Set<string>()
const normalized: DeviceInventoryColumnConfig[] = []
for (const column of columns) {
const key = column.key.trim()
if (!key) continue
if (seen.has(key)) continue
if (!isCustomFieldKey(key) && !INVENTORY_COLUMN_MAP[key]) continue
seen.add(key)
normalized.push({
key,
label: column.label?.trim() || undefined,
})
}
return normalized.length > 0 ? normalized : [...DEFAULT_COLUMN_CONFIG]
}
function resolveColumnLabel(column: DeviceInventoryColumnConfig, derivedList: MachineDerivedData[]): string {
if (column.label) return column.label
const definition = INVENTORY_COLUMN_MAP[column.key]
if (definition) return definition.label
if (isCustomFieldKey(column.key)) {
for (const derived of derivedList) {
const field = lookupCustomField(column.key, derived)
if (field) return field.label
}
return "Campo personalizado"
}
return column.key
}
function resolveColumnWidth(key: string): number {
const definition = INVENTORY_COLUMN_MAP[key]
if (definition) return definition.width
if (isCustomFieldKey(key)) return 28
return 20
}
function isCustomFieldKey(key: string): boolean {
return key.startsWith(CUSTOM_FIELD_KEY_PREFIX) || key.startsWith(CUSTOM_FIELD_ID_PREFIX)
}
function lookupCustomField(key: string, derived: MachineDerivedData): DeviceCustomField | null {
if (key.startsWith(CUSTOM_FIELD_KEY_PREFIX)) {
const fieldKey = key.slice(CUSTOM_FIELD_KEY_PREFIX.length)
return derived.customFieldByKey[fieldKey] ?? null
}
if (key.startsWith(CUSTOM_FIELD_ID_PREFIX)) {
const fieldId = key.slice(CUSTOM_FIELD_ID_PREFIX.length)
return derived.customFieldById[fieldId] ?? null
}
return null
}
function formatCustomFieldValue(field: DeviceCustomField): string {
if (field.value === null || field.value === undefined) {
return "—"
}
if (field.displayValue !== undefined && field.displayValue !== null) {
const text = String(field.displayValue).trim()
if (text.length > 0) return text
}
switch (field.type) {
case "boolean":
return yesNo(Boolean(field.value))
case "number": {
const num = typeof field.value === "number" ? field.value : Number(field.value)
return Number.isFinite(num) ? String(num) : String(field.value)
}
case "date": {
const date = new Date(field.value as string | number)
if (Number.isNaN(date.getTime())) return String(field.value)
return date.toISOString().slice(0, 10)
}
default:
return String(field.value)
}
}
function resolveColumnValue(
key: string,
machine: MachineInventoryRecord,
derived: MachineDerivedData
): unknown {
if (isCustomFieldKey(key)) {
const field = lookupCustomField(key, derived)
if (!field) return "—"
return formatCustomFieldValue(field)
}
const definition = INVENTORY_COLUMN_MAP[key]
if (!definition) return "—"
return definition.getValue(machine, derived)
}
function formatInventoryCell(value: unknown): unknown {
if (value === null || value === undefined) return "—"
if (typeof value === "string") {
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : "—"
}
return value
}
function describeDeviceType(type: string | null | undefined): string {
const normalized = (type ?? "").toLowerCase()
switch (normalized) {
case "desktop":
return "Desktop"
case "mobile":
return "Celular"
case "tablet":
return "Tablet"
default:
return "Desconhecido"
}
}
function describeManagementMode(mode: string | null | undefined): string {
const normalized = (mode ?? "").toLowerCase()
switch (normalized) {
case "agent":
return "Agente"
case "manual":
return "Manual"
default:
return "—"
}
}
const SOFTWARE_HEADERS = ["Hostname", "Aplicativo", "Versão", "Origem", "Publicador", "Instalado em"] as const
const SOFTWARE_COLUMN_WIDTHS = [22, 36, 18, 18, 22, 20] as const
@ -173,7 +401,7 @@ const STATUS_LABELS: Record<string, string> = {
const PERSONA_LABELS: Record<string, string> = {
collaborator: "Colaborador",
manager: "Gestor",
machine: "Máquina",
machine: "Dispositivo",
}
const SUMMARY_STATUS_ORDER = ["Online", "Sem sinal", "Offline", "Manutenção", "Bloqueada", "Desativada", "Desconhecido"]
@ -186,7 +414,14 @@ export function buildMachinesInventoryWorkbook(
): Buffer {
const generatedAt = options.generatedAt ?? new Date()
const summaryRows = buildSummaryRows(machines, options, generatedAt)
const inventoryRows = machines.map((machine) => flattenMachine(machine))
const columnConfig = normalizeColumnConfig(options.columns)
const derivedList = machines.map((machine) => deriveMachineData(machine))
const headers = columnConfig.map((column) => resolveColumnLabel(column, derivedList))
const columnWidths = columnConfig.map((column) => resolveColumnWidth(column.key))
const inventoryRows = machines.map((machine, index) => {
const derived = derivedList[index]
return columnConfig.map((column) => formatInventoryCell(resolveColumnValue(column.key, machine, derived)))
})
const linksRows = buildLinkedUsersRows(machines)
const softwareRows = buildSoftwareRows(machines)
const partitionRows = buildPartitionRows(machines)
@ -210,9 +445,9 @@ export function buildMachinesInventoryWorkbook(
sheets.push({
name: "Inventário",
headers: [...INVENTORY_HEADERS],
headers,
rows: inventoryRows,
columnWidths: [...INVENTORY_COLUMN_WIDTHS],
columnWidths,
freezePane: { rowSplit: 1 },
autoFilter: true,
})
@ -353,11 +588,28 @@ function buildSummaryRows(
rows.push(["Filtro de empresa", options.companyFilterLabel])
}
rows.push(["Total de máquinas", machines.length])
rows.push(["Total de dispositivos", machines.length])
const activeCount = machines.filter((machine) => machine.isActive).length
rows.push(["Máquinas ativas", activeCount])
rows.push(["Máquinas inativas", machines.length - activeCount])
rows.push(["Dispositivos ativos", activeCount])
rows.push(["Dispositivos inativos", machines.length - activeCount])
const typeCounts = new Map<string, number>()
machines.forEach((machine) => {
const label = describeDeviceType(machine.deviceType)
typeCounts.set(label, (typeCounts.get(label) ?? 0) + 1)
})
const DEVICE_TYPE_ORDER = ["Desktop", "Celular", "Tablet", "Desconhecido"]
Array.from(typeCounts.entries())
.sort((a, b) => {
const idxA = DEVICE_TYPE_ORDER.indexOf(a[0])
const idxB = DEVICE_TYPE_ORDER.indexOf(b[0])
if (idxA === -1 && idxB === -1) return a[0].localeCompare(b[0], "pt-BR")
if (idxA === -1) return 1
if (idxB === -1) return -1
return idxA - idxB
})
.forEach(([label, total]) => rows.push([`Tipo: ${label}`, total]))
const statusCounts = new Map<string, number>()
machines.forEach((machine) => {
@ -396,70 +648,6 @@ function buildSummaryRows(
return rows
}
function flattenMachine(machine: MachineInventoryRecord): WorksheetRow {
const inventory = toRecord(machine.inventory)
const hardware = extractHardware(inventory)
const gpuNames = extractGpuNames(inventory)
const labels = extractLabels(inventory)
const primaryIp = extractPrimaryIp(inventory)
const publicIp = extractPublicIp(inventory)
const softwareEntries = extractSoftwareEntries(machine.hostname, inventory)
const linkedUsers = summarizeLinkedUsers(machine.linkedUsers)
const systemInfo = extractSystemInfo(inventory)
const collaborator = extractCollaborator(machine, inventory)
const remoteAccessCount = collectRemoteAccessEntries(machine).length
const fleetInfo = extractFleetInfo(inventory)
return [
machine.hostname,
machine.companyName ?? "—",
describeStatus(machine.status),
describePersona(machine.persona),
yesNo(machine.isActive),
formatDateTime(machine.lastHeartbeatAt),
machine.assignedUserName ?? machine.assignedUserEmail ?? "—",
machine.assignedUserEmail ?? "—",
linkedUsers ?? "—",
machine.authEmail ?? "—",
machine.osName,
machine.osVersion ?? "—",
machine.architecture ?? "—",
hardware.vendor ?? systemInfo.systemManufacturer ?? "—",
hardware.model ?? systemInfo.systemModel ?? "—",
hardware.serial ?? systemInfo.boardSerial ?? "—",
hardware.cpuType ?? "—",
hardware.physicalCores ?? "—",
hardware.logicalCores ?? "—",
hardware.memoryGiB ?? "—",
gpuNames.length > 0 ? gpuNames.join(", ") : "—",
labels.length > 0 ? labels.join(", ") : "—",
machine.macAddresses.length > 0 ? machine.macAddresses.join(", ") : "—",
machine.serialNumbers.length > 0 ? machine.serialNumbers.join(", ") : "—",
primaryIp ?? "—",
publicIp ?? "—",
machine.registeredBy ?? "—",
machine.token?.expiresAt ? formatDateTime(machine.token.expiresAt) ?? "—" : "—",
machine.token?.lastUsedAt ? formatDateTime(machine.token.lastUsedAt) ?? "—" : "—",
machine.token?.usageCount ?? 0,
formatDateTime(machine.createdAt) ?? "—",
formatDateTime(machine.updatedAt) ?? "—",
softwareEntries.length,
systemInfo.osBuild ?? "—",
systemInfo.license ?? "—",
systemInfo.experience ?? "—",
systemInfo.domain ?? "—",
systemInfo.workgroup ?? "—",
systemInfo.deviceName ?? "—",
systemInfo.boardSerial ?? hardware.serial ?? "—",
collaborator?.name ?? "—",
collaborator?.email ?? "—",
remoteAccessCount,
fleetInfo?.id ?? "—",
fleetInfo?.team ?? "—",
fleetInfo?.updatedAt ? formatDateTime(fleetInfo.updatedAt) ?? "—" : "—",
]
}
function buildLinkedUsersRows(machines: MachineInventoryRecord[]): Array<[string, string | null, string | null, string | null]> {
const rows: Array<[string, string | null, string | null, string | null]> = []
machines.forEach((machine) => {

View file

@ -408,7 +408,7 @@ function TicketPdfDocument({ ticket, logoDataUrl }: { ticket: TicketWithDetails;
]
if (ticket.machine) {
const machineLabel = ticket.machine.hostname ?? (ticket.machine.id ? `ID ${ticket.machine.id}` : "—")
rightMeta.push({ label: "Máquina", value: machineLabel })
rightMeta.push({ label: "Dispositivo", value: machineLabel })
}
if (ticket.resolvedAt) {
rightMeta.push({ label: "Resolvido em", value: formatDateTime(ticket.resolvedAt) })

View file

@ -1,15 +1,15 @@
import { describe, expect, it } from "vitest"
import { normalizeMachineRemoteAccess } from "@/components/admin/machines/admin-machines-overview"
import { normalizeDeviceRemoteAccess } from "@/components/admin/devices/admin-devices-overview"
describe("normalizeMachineRemoteAccess", () => {
describe("normalizeDeviceRemoteAccess", () => {
it("returns null when value is empty", () => {
expect(normalizeMachineRemoteAccess(undefined)).toBeNull()
expect(normalizeMachineRemoteAccess(" ")).toBeNull()
expect(normalizeDeviceRemoteAccess(undefined)).toBeNull()
expect(normalizeDeviceRemoteAccess(" ")).toBeNull()
})
it("parses plain identifier strings", () => {
const result = normalizeMachineRemoteAccess("PC-001")
const result = normalizeDeviceRemoteAccess("PC-001")
expect(result).toEqual({
provider: null,
identifier: "PC-001",
@ -21,7 +21,7 @@ describe("normalizeMachineRemoteAccess", () => {
})
it("detects URLs in string input", () => {
const result = normalizeMachineRemoteAccess("https://remote.example.com/session/123")
const result = normalizeDeviceRemoteAccess("https://remote.example.com/session/123")
expect(result).toEqual({
provider: null,
identifier: null,
@ -34,7 +34,7 @@ describe("normalizeMachineRemoteAccess", () => {
it("normalizes object payload with aliases", () => {
const timestamp = 1_701_234_567_890
const result = normalizeMachineRemoteAccess({
const result = normalizeDeviceRemoteAccess({
provider: "AnyDesk",
code: "123-456-789",
remoteUrl: "https://anydesk.com/session/123",

View file

@ -0,0 +1,263 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type { Doc, Id } from "../convex/_generated/dataModel"
import { resolveTicketHandler, reopenTicketHandler } from "../convex/tickets"
import { requireStaff, requireUser } from "../convex/rbac"
type ResolveCtx = Parameters<typeof resolveTicketHandler>[0]
type ReopenCtx = Parameters<typeof reopenTicketHandler>[0]
type TicketDoc = Doc<"tickets">
type MockedTicket = TicketDoc & { _id: Id<"tickets"> }
defineMocks()
function defineMocks() {
vi.mock("../convex/rbac", () => ({
requireStaff: vi.fn(),
requireUser: vi.fn(),
requireAdmin: vi.fn(),
}))
}
const mockedRequireStaff = vi.mocked(requireStaff)
const mockedRequireUser = vi.mocked(requireUser)
const NOW = 1_706_700_000_000
beforeEach(() => {
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.clearAllMocks()
})
function buildTicket(overrides: Partial<TicketDoc> = {}): MockedTicket {
const base: TicketDoc = {
_id: "ticket_main" as Id<"tickets">,
_creationTime: NOW - 10_000,
tenantId: "tenant-1",
reference: 41_000,
subject: "Computador não liga",
summary: null,
status: "AWAITING_ATTENDANCE",
priority: "MEDIUM",
channel: "EMAIL",
queueId: null,
queueSnapshot: null,
requesterId: "user_requester" as Id<"users">,
requesterSnapshot: { name: "Cliente", email: "cliente@example.com" },
assigneeId: "user_agent" as Id<"users">,
assigneeSnapshot: { name: "Agente", email: "agente@example.com" },
companyId: null,
companySnapshot: null,
machineId: null,
machineSnapshot: null,
slaPolicyId: null,
dueAt: null,
firstResponseAt: null,
resolvedAt: null,
createdAt: NOW - 50_000,
updatedAt: NOW - 1_000,
tags: [],
customFields: [],
activeSessionId: null,
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
csatScore: undefined,
csatMaxScore: undefined,
csatComment: undefined,
csatRatedAt: undefined,
csatRatedBy: undefined,
csatAssigneeId: undefined,
csatAssigneeSnapshot: undefined,
workSummary: undefined,
reopenDeadline: undefined,
reopenedAt: undefined,
formTemplate: undefined,
chatEnabled: true,
relatedTicketIds: undefined,
resolvedWithTicketId: undefined,
reopenWindowDays: undefined,
reopenedBy: undefined,
}
return { ...(base as TicketDoc), ...overrides }
}
function createResolveCtx(tickets: Record<string, TicketDoc>, events: Doc<"ticketEvents">[] = []) {
const patch = vi.fn(async () => undefined)
const insert = vi.fn(async () => undefined)
const collect = vi.fn(async () => events)
const query = vi.fn(() => ({
withIndex: vi.fn((_index: string, cb: (builder: { eq: (field: string, value: unknown) => { collect: typeof collect } }) => { collect: typeof collect }) => {
const cursor = {
eq: vi.fn(() => ({ collect })),
}
const result = cb(cursor as { eq: (field: string, value: unknown) => { collect: typeof collect } })
return result ?? { collect }
}),
}))
const ctx: ResolveCtx = {
db: {
get: vi.fn(async (id: Id<"tickets"> | Id<"users">) => {
return tickets[String(id)] ?? null
}),
patch,
insert,
query,
delete: vi.fn(),
},
} as unknown as ResolveCtx
return { ctx, patch, insert }
}
function createReopenCtx(ticket: TicketDoc) {
const patch = vi.fn(async () => undefined)
const insert = vi.fn(async () => undefined)
const ctx: ReopenCtx = {
db: {
get: vi.fn(async () => ticket),
patch,
insert,
query: vi.fn(),
delete: vi.fn(),
},
} as unknown as ReopenCtx
return { ctx, patch, insert }
}
describe("convex.tickets.resolveTicketHandler", () => {
it("marca o ticket como resolvido, vincula outro ticket e define prazo de reabertura", async () => {
const ticketMain = buildTicket()
const ticketLinked = buildTicket({
_id: "ticket_related" as Id<"tickets">,
reference: 41_001,
status: "AWAITING_ATTENDANCE",
})
const { ctx, patch, insert } = createResolveCtx({
[String(ticketMain._id)]: ticketMain,
[String(ticketLinked._id)]: ticketLinked,
})
mockedRequireStaff.mockResolvedValue({
user: {
_id: "user_agent" as Id<"users">,
name: "Agente",
email: "agente@example.com",
},
role: "ADMIN",
})
const result = await resolveTicketHandler(ctx, {
ticketId: ticketMain._id,
actorId: "user_agent" as Id<"users">,
resolvedWithTicketId: ticketLinked._id,
reopenWindowDays: 14,
})
expect(result.ok).toBe(true)
expect(result.reopenWindowDays).toBe(14)
expect(result.reopenDeadline).toBe(NOW + 14 * 24 * 60 * 60 * 1000)
expect(patch).toHaveBeenCalledWith(
ticketMain._id,
expect.objectContaining({
status: "RESOLVED",
reopenDeadline: result.reopenDeadline,
resolvedWithTicketId: ticketLinked._id,
})
)
expect(patch).toHaveBeenCalledWith(
ticketLinked._id,
expect.objectContaining({
relatedTicketIds: expect.arrayContaining([ticketMain._id]),
})
)
expect(insert).toHaveBeenCalledWith(
"ticketEvents",
expect.objectContaining({
ticketId: ticketMain._id,
type: "STATUS_CHANGED",
payload: expect.objectContaining({ to: "RESOLVED" }),
})
)
expect(insert).toHaveBeenCalledWith(
"ticketEvents",
expect.objectContaining({
ticketId: ticketLinked._id,
type: "TICKET_LINKED",
payload: expect.objectContaining({ linkedTicketId: ticketMain._id }),
})
)
})
})
describe("convex.tickets.reopenTicketHandler", () => {
it("reabre o ticket quando dentro do prazo e permissões válidas", async () => {
const ticket = buildTicket({
status: "RESOLVED",
reopenDeadline: NOW + 3 * 24 * 60 * 60 * 1000,
resolvedAt: NOW - 60_000,
})
const { ctx, patch, insert } = createReopenCtx(ticket)
mockedRequireUser.mockResolvedValue({
user: {
_id: ticket.requesterId,
email: "cliente@example.com",
companyId: null,
},
role: "COLLABORATOR",
})
const result = await reopenTicketHandler(ctx, {
ticketId: ticket._id,
actorId: ticket.requesterId,
})
expect(result.ok).toBe(true)
expect(result.reopenedAt).toBe(NOW)
expect(patch).toHaveBeenCalledWith(
ticket._id,
expect.objectContaining({ status: "AWAITING_ATTENDANCE", reopenedAt: NOW, resolvedAt: undefined })
)
expect(insert).toHaveBeenCalledWith(
"ticketEvents",
expect.objectContaining({ ticketId: ticket._id, type: "TICKET_REOPENED" })
)
})
it("falha quando o prazo para reabrir expirou", async () => {
const ticket = buildTicket({
status: "RESOLVED",
reopenDeadline: NOW - 1,
})
const { ctx } = createReopenCtx(ticket)
mockedRequireUser.mockResolvedValue({
user: {
_id: ticket.requesterId,
email: "cliente@example.com",
companyId: null,
},
role: "COLLABORATOR",
})
await expect(
reopenTicketHandler(ctx, {
ticketId: ticket._id,
actorId: ticket.requesterId,
})
).rejects.toThrow("O prazo para reabrir este chamado expirou")
})
})

View file

@ -0,0 +1,219 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import type { Doc, Id } from "../convex/_generated/dataModel"
import { submitCsatHandler } from "../convex/tickets"
import { requireUser } from "../convex/rbac"
vi.mock("../convex/rbac", () => ({
requireUser: vi.fn(),
requireStaff: vi.fn(),
requireAdmin: vi.fn(),
}))
type SubmitCsatCtx = Parameters<typeof submitCsatHandler>[0]
const mockedRequireUser = vi.mocked(requireUser)
const FIXED_NOW = 1_706_500_000_000
function makeTicket(overrides: Partial<Doc<"tickets">> = {}): Doc<"tickets"> {
const ticket = {
_id: "ticket_1" as Id<"tickets">,
_creationTime: FIXED_NOW - 10_000,
tenantId: "tenant-1",
reference: 42_100,
subject: "Computador não liga",
summary: null,
status: "RESOLVED",
priority: "MEDIUM",
channel: "EMAIL",
queueId: null,
queueSnapshot: null,
requesterId: "user_requester" as Id<"users">,
requesterSnapshot: { name: "Cliente", email: "cliente@example.com" },
assigneeId: null,
assigneeSnapshot: null,
companyId: null,
companySnapshot: null,
machineId: null,
machineSnapshot: null,
slaPolicyId: null,
dueAt: null,
firstResponseAt: null,
resolvedAt: FIXED_NOW - 1000,
createdAt: FIXED_NOW - 20_000,
updatedAt: FIXED_NOW - 100,
tags: [],
customFields: [],
activeSessionId: null,
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
csatScore: undefined,
csatMaxScore: undefined,
csatComment: undefined,
csatRatedAt: undefined,
csatRatedBy: undefined,
} satisfies Partial<Doc<"tickets">>
return { ...(ticket as Doc<"tickets">), ...overrides }
}
function createCtx(ticket: Doc<"tickets">, events: Doc<"ticketEvents">[] = []) {
const collect = vi.fn(async () => events)
const query = vi.fn(() => ({
withIndex: vi.fn((_index: string, cb: (builder: { eq: (field: string, value: unknown) => { collect: typeof collect } }) => { collect: typeof collect }) => {
const cursor = {
eq: vi.fn(() => ({
collect,
})),
}
const result = cb(cursor as { eq: (field: string, value: unknown) => { collect: typeof collect } })
return result ?? { collect }
}),
}))
const patch = vi.fn(async () => undefined)
const insert = vi.fn(async () => undefined)
const deleteFn = vi.fn(async () => undefined)
const ctx: SubmitCsatCtx = {
db: {
get: vi.fn(async (id: Id<"tickets"> | Id<"users">) => {
if (id === ticket._id) return ticket
return null
}),
patch,
query,
delete: deleteFn,
insert,
},
} as unknown as SubmitCsatCtx
return { ctx, patch, insert, deleteFn, query }
}
describe("convex.tickets.submitCsat", () => {
afterEach(() => {
vi.clearAllMocks()
})
it("permite que o solicitante avalie um chamado resolvido", async () => {
const ticket = makeTicket()
const { ctx, patch, insert, deleteFn } = createCtx(ticket)
mockedRequireUser.mockResolvedValue({
user: {
_id: "user_requester" as Id<"users">,
email: "cliente@example.com",
companyId: null,
},
role: "COLLABORATOR",
})
const result = await submitCsatHandler(ctx, {
ticketId: ticket._id,
actorId: "user_requester" as Id<"users">,
score: 4,
maxScore: 5,
comment: "Atendimento excelente!",
})
expect(result.score).toBe(4)
expect(result.comment).toBe("Atendimento excelente!")
expect(patch).toHaveBeenCalledWith(
ticket._id,
expect.objectContaining({
csatScore: 4,
csatMaxScore: 5,
csatComment: "Atendimento excelente!",
csatRatedBy: "user_requester",
})
)
expect(insert).toHaveBeenCalledWith(
"ticketEvents",
expect.objectContaining({
ticketId: ticket._id,
type: "CSAT_RATED",
payload: expect.objectContaining({ score: 4, maxScore: 5, comment: "Atendimento excelente!" }),
})
)
expect(deleteFn).not.toHaveBeenCalled()
})
it("bloqueia avaliações antes do encerramento do chamado para colaboradores", async () => {
const ticket = makeTicket({ status: "PENDING" })
const { ctx, patch, insert } = createCtx(ticket)
mockedRequireUser.mockResolvedValue({
user: {
_id: "user_requester" as Id<"users">,
email: "cliente@example.com",
companyId: null,
},
role: "COLLABORATOR",
})
await expect(
submitCsatHandler(ctx, {
ticketId: ticket._id,
actorId: "user_requester" as Id<"users">,
score: 5,
comment: "Perfeito",
})
).rejects.toThrow("Avaliações só são permitidas após o encerramento do chamado")
expect(patch).not.toHaveBeenCalled()
expect(insert).not.toHaveBeenCalled()
})
it("não permite registrar uma segunda avaliação", async () => {
const ticket = makeTicket({
csatScore: 4,
csatMaxScore: 5,
csatRatedAt: FIXED_NOW - 2000,
csatRatedBy: "user_requester" as Id<"users">,
})
const { ctx, patch, insert, deleteFn } = createCtx(ticket)
mockedRequireUser.mockResolvedValue({
user: {
_id: "user_requester" as Id<"users">,
email: "cliente@example.com",
companyId: null,
},
role: "COLLABORATOR",
})
await expect(
submitCsatHandler(ctx, {
ticketId: ticket._id,
actorId: "user_requester" as Id<"users">,
score: 5,
comment: "Vou tentar atualizar",
})
).rejects.toThrow("Este chamado já possui uma avaliação registrada")
expect(patch).not.toHaveBeenCalled()
expect(insert).not.toHaveBeenCalled()
expect(deleteFn).not.toHaveBeenCalled()
})
it("impede que administradores registrem avaliação", async () => {
const ticket = makeTicket()
const { ctx, patch, insert } = createCtx(ticket)
mockedRequireUser.mockResolvedValue({
user: {
_id: "user_admin" as Id<"users">,
email: "admin@example.com",
companyId: null,
},
role: "ADMIN",
})
await expect(
submitCsatHandler(ctx, {
ticketId: ticket._id,
actorId: "user_admin" as Id<"users">,
score: 4,
comment: "somente testes",
})
).rejects.toThrow("Somente o solicitante pode avaliar o chamado")
expect(patch).not.toHaveBeenCalled()
expect(insert).not.toHaveBeenCalled()
})
})