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 prisma migrate deploy` — aplica migrações ao banco SQLite local.
- `pnpm convex:dev` — roda o Convex em modo desenvolvimento, gerando tipos em `convex/_generated`. - `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. 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. 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 --> <!-- 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: - 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). - `/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 dev` — desenvolvimento (porta 1420).
- `pnpm -C apps/desktop tauri build` — gera instaladores. - `pnpm -C apps/desktop tauri build` — gera instaladores.
- **Fluxo do agente**: - **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. 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). 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`). 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. 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`). 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. 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 ### 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. - 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). - `/portal/debug` exibe JSON de `get-session` e `machines/session` (útil para diagnosticar cookies/bearer).
### Observações adicionais ### Observações adicionais
@ -116,7 +116,7 @@ pnpm build
## Estado do portal / app web ## Estado do portal / app web
- Autenticação Better Auth com `AuthGuard`. - 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. - 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`. - Relatórios e painéis utilizam `AppShell` + `SiteHeader`.
- `usePersistentCompanyFilter` mantém filtro global de empresa em relatórios/admin. - `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 > Empresas: cadastro + “Cliente avulso?”, horas contratadas, vínculos de usuários.
- Admin > Usuários/Equipe: - Admin > Usuários/Equipe:
- Abas separadas: "Equipe" (administradores e agentes) e "Usuários" (gestores e colaboradores). - 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. - 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. - Convites: campo "Espaço (ID interno)" removido da UI de geração.
- Admin > Usuários: vincular colaborador à empresa. - 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. - **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. - **Gestores** (`manager`): visualizam tickets da empresa, comentam publicamente, acessam dashboards.
- **Colaboradores** (`collaborator`): portal (`/portal`), tickets próprios, comentários públicos, editor rico, anexos. - **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 ### 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). - 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?...` - CSAT: `/api/reports/csat.xlsx?...`
- SLA: `/api/reports/sla.xlsx?...` - SLA: `/api/reports/sla.xlsx?...`
- Horas: `/api/reports/hours-by-client.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 complementares**:
- `docs/DEV.md` — guia diário atualizado. - `docs/DEV.md` — guia diário atualizado.
- `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog. - `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog.

View file

@ -1,11 +1,11 @@
# Sistema de Chamados — App Desktop (Tauri) # Sistema de Chamados — App Desktop (Tauri)
Cliente desktop (Tauri v2 + Vite) que: Cliente desktop (Tauri v2 + Vite) que:
- Coleta perfil/métricas da máquina via comandos Rust. - Coleta perfil/métricas da dispositivo via comandos Rust.
- Registra a máquina com um código de provisionamento. - Registra a dispositivo com um código de provisionamento.
- Envia heartbeat periódico ao backend (`/api/machines/heartbeat`). - Envia heartbeat periódico ao backend (`/api/machines/heartbeat`).
- Redireciona para a UI web do sistema após provisionamento. - 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”. - Exibe abas de Resumo, Inventário, Diagnóstico e Configurações; permite “Enviar inventário agora”.
## URLs e ambiente ## URLs e ambiente
@ -65,7 +65,7 @@ pnpm -C apps/desktop tauri build --bundles nsis
Consulte https://tauri.app/start/prerequisites/ Consulte https://tauri.app/start/prerequisites/
## Fluxo (resumo) ## 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. 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. 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. 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 ## 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 ```bash
pnpm install pnpm install
pnpm --filter appsdesktop tauri signer generate pnpm --filter appsdesktop tauri signer generate
@ -267,10 +267,10 @@
.\svc start .\svc start
``` ```
6. Confirme no GitHub que o runner aparece como `online`. 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). - 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. - 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). - 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). - 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. - 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`). - Garanta que o certificado TLS usado pelo Nginx é renovado (p. ex. `certbot renew`).
4. Manter runners: 4. Manter runners:
- VPS: monitore serviço `actions.runner.*`. Reinicie se necessário (`sudo ./svc.sh restart`). - 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. | | 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. | | 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. | | 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)] #[derive(thiserror::Error, Debug)]
pub enum AgentError { pub enum AgentError {
#[error("Falha ao obter hostname da máquina")] #[error("Falha ao obter hostname da dispositivo")]
Hostname, Hostname,
#[error("Nenhum identificador de hardware disponível (MAC/serial)")] #[error("Nenhum identificador de hardware disponível (MAC/serial)")]
MissingIdentifiers, 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"> <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 <ShieldAlert className="size-4" /> Acesso bloqueado
</span> </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"> <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. envio de informações ficam indisponíveis.
</p> </p>
{companyName ? ( {companyName ? (

View file

@ -273,9 +273,9 @@ function App() {
const text = await res.text() const text = await res.text()
const msg = text.toLowerCase() const msg = text.toLowerCase()
const isInvalid = const isInvalid =
msg.includes("token de máquina inválido") || msg.includes("token de dispositivo inválido") ||
msg.includes("token de máquina revogado") || msg.includes("token de dispositivo revogado") ||
msg.includes("token de máquina expirado") msg.includes("token de dispositivo expirado")
if (isInvalid) { if (isInvalid) {
try { try {
await store.delete("token"); await store.delete("config"); await store.save() await store.delete("token"); await store.delete("config"); await store.save()
@ -293,7 +293,7 @@ function App() {
} catch {} } catch {}
} else { } else {
// Não limpa token em falhas genéricas (ex.: rede); apenas informa // 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 tokenVerifiedRef.current = true
setTokenValidationTick((tick) => tick + 1) setTokenValidationTick((tick) => tick + 1)
} }
@ -443,12 +443,12 @@ function App() {
return return
} }
if (!validatedCompany) { 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 return
} }
const normalizedEmail = collabEmail.trim().toLowerCase() const normalizedEmail = collabEmail.trim().toLowerCase()
if (!normalizedEmail) { 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 return
} }
if (!emailRegex.current.test(normalizedEmail)) { if (!emailRegex.current.test(normalizedEmail)) {
@ -575,7 +575,7 @@ function App() {
setError(null) setError(null)
} }
if (!currentActive) { 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) setIsLaunchingSystem(false)
return return
} }
@ -590,7 +590,7 @@ function App() {
: "" : ""
setIsMachineActive(false) setIsMachineActive(false)
setIsLaunchingSystem(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 return
} }
// Se sessão falhar, tenta identificar token inválido/expirado // Se sessão falhar, tenta identificar token inválido/expirado
@ -604,9 +604,9 @@ function App() {
const text = await hb.text() const text = await hb.text()
const low = text.toLowerCase() const low = text.toLowerCase()
const invalid = const invalid =
low.includes("token de máquina inválido") || low.includes("token de dispositivo inválido") ||
low.includes("token de máquina revogado") || low.includes("token de dispositivo revogado") ||
low.includes("token de máquina expirado") low.includes("token de dispositivo expirado")
if (invalid) { if (invalid) {
// Força onboarding // Força onboarding
await store?.delete("token"); await store?.delete("config"); await store?.save() await store?.delete("token"); await store?.delete("config"); await store?.save()
@ -616,7 +616,7 @@ function App() {
setConfig(null) setConfig(null)
setStatus(null) setStatus(null)
setIsMachineActive(true) setIsMachineActive(true)
setError("Sessão expirada. Reprovisione a máquina para continuar.") setError("Sessão expirada. Reprovisione a dispositivo para continuar.")
setIsLaunchingSystem(false) setIsLaunchingSystem(false)
const p = await invoke<MachineProfile>("collect_machine_profile") const p = await invoke<MachineProfile>("collect_machine_profile")
setProfile(p) 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} {error ? <p className="mt-3 rounded-md bg-rose-50 p-2 text-sm text-rose-700">{error}</p> : null}
{!token ? ( {!token ? (
<div className="mt-4 space-y-3"> <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"> <div className="grid gap-2">
<label className="text-sm font-medium">Código de provisionamento</label> <label className="text-sm font-medium">Código de provisionamento</label>
<div className="relative"> <div className="relative">
@ -822,7 +822,7 @@ function App() {
</p> </p>
) : ( ) : (
<p className="text-xs text-slate-500"> <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> </p>
)} )}
</div> </div>
@ -832,7 +832,7 @@ function App() {
<div className="space-y-1"> <div className="space-y-1">
<span className="block text-sm font-semibold text-emerald-800">{validatedCompany.name}</span> <span className="block text-sm font-semibold text-emerald-800">{validatedCompany.name}</span>
<span className="text-xs text-emerald-700/80"> <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> </span>
</div> </div>
</div> </div>
@ -884,7 +884,7 @@ function App() {
</div> </div>
) : null} ) : null}
<div className="mt-2 flex gap-2"> <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>
</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 commentTemplates from "../commentTemplates.js";
import type * as companies from "../companies.js"; import type * as companies from "../companies.js";
import type * as crons from "../crons.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 fields from "../fields.js";
import type * as files from "../files.js"; import type * as files from "../files.js";
import type * as invites from "../invites.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 seed from "../seed.js";
import type * as slas from "../slas.js"; import type * as slas from "../slas.js";
import type * as teams from "../teams.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 tickets from "../tickets.js";
import type * as users from "../users.js"; import type * as users from "../users.js";
@ -52,6 +56,9 @@ declare const fullApi: ApiFromModules<{
commentTemplates: typeof commentTemplates; commentTemplates: typeof commentTemplates;
companies: typeof companies; companies: typeof companies;
crons: typeof crons; crons: typeof crons;
deviceExportTemplates: typeof deviceExportTemplates;
deviceFields: typeof deviceFields;
devices: typeof devices;
fields: typeof fields; fields: typeof fields;
files: typeof files; files: typeof files;
invites: typeof invites; invites: typeof invites;
@ -64,6 +71,7 @@ declare const fullApi: ApiFromModules<{
seed: typeof seed; seed: typeof seed;
slas: typeof slas; slas: typeof slas;
teams: typeof teams; teams: typeof teams;
ticketFormSettings: typeof ticketFormSettings;
tickets: typeof tickets; tickets: typeof tickets;
users: typeof users; 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({ export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users") }, args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) },
handler: async (ctx, { tenantId, viewerId }) => { handler: async (ctx, { tenantId, viewerId, scope }) => {
await requireAdmin(ctx, viewerId, tenantId); await requireAdmin(ctx, viewerId, tenantId);
const fields = await ctx.db const fields = await ctx.db
.query("ticketFields") .query("ticketFields")
@ -47,6 +47,12 @@ export const list = query({
.collect(); .collect();
return fields 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) .sort((a, b) => a.order - b.order)
.map((field) => ({ .map((field) => ({
id: field._id, id: field._id,
@ -57,6 +63,7 @@ export const list = query({
required: field.required, required: field.required,
options: field.options ?? [], options: field.options ?? [],
order: field.order, order: field.order,
scope: field.scope ?? "all",
createdAt: field.createdAt, createdAt: field.createdAt,
updatedAt: field.updatedAt, updatedAt: field.updatedAt,
})); }));
@ -64,8 +71,8 @@ export const list = query({
}); });
export const listForTenant = query({ export const listForTenant = query({
args: { tenantId: v.string(), viewerId: v.id("users") }, args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) },
handler: async (ctx, { tenantId, viewerId }) => { handler: async (ctx, { tenantId, viewerId, scope }) => {
await requireUser(ctx, viewerId, tenantId); await requireUser(ctx, viewerId, tenantId);
const fields = await ctx.db const fields = await ctx.db
.query("ticketFields") .query("ticketFields")
@ -73,6 +80,12 @@ export const listForTenant = query({
.collect(); .collect();
return fields 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) .sort((a, b) => a.order - b.order)
.map((field) => ({ .map((field) => ({
id: field._id, id: field._id,
@ -83,6 +96,7 @@ export const listForTenant = query({
required: field.required, required: field.required,
options: field.options ?? [], options: field.options ?? [],
order: field.order, 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); await requireAdmin(ctx, actorId, tenantId);
const normalizedLabel = label.trim(); const normalizedLabel = label.trim();
if (normalizedLabel.length < 2) { if (normalizedLabel.length < 2) {
@ -116,6 +131,15 @@ export const create = mutation({
validateOptions(type as FieldType, options ?? undefined); validateOptions(type as FieldType, options ?? undefined);
const key = normalizeKey(normalizedLabel); const key = normalizeKey(normalizedLabel);
await ensureUniqueKey(ctx, tenantId, key); 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 const existing = await ctx.db
.query("ticketFields") .query("ticketFields")
@ -133,6 +157,7 @@ export const create = mutation({
required, required,
options, options,
order: maxOrder + 1, order: maxOrder + 1,
scope: normalizedScope,
createdAt: now, createdAt: now,
updatedAt: 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); await requireAdmin(ctx, actorId, tenantId);
const field = await ctx.db.get(fieldId); const field = await ctx.db.get(fieldId);
if (!field || field.tenantId !== tenantId) { if (!field || field.tenantId !== tenantId) {
@ -173,6 +199,16 @@ export const update = mutation({
} }
validateOptions(type as FieldType, options ?? undefined); 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; let key = field.key;
if (field.label !== normalizedLabel) { if (field.label !== normalizedLabel) {
key = normalizeKey(normalizedLabel); key = normalizeKey(normalizedLabel);
@ -186,6 +222,7 @@ export const update = mutation({
type, type,
required, required,
options, options,
scope: normalizedScope,
updatedAt: Date.now(), updatedAt: Date.now(),
}); });
}, },

View file

@ -8,6 +8,7 @@ import { randomBytes } from "@noble/hashes/utils"
import type { Doc, Id } from "./_generated/dataModel" import type { Doc, Id } from "./_generated/dataModel"
import type { MutationCtx, QueryCtx } from "./_generated/server" import type { MutationCtx, QueryCtx } from "./_generated/server"
import { normalizeStatus } from "./tickets" import { normalizeStatus } from "./tickets"
import { requireAdmin } from "./rbac"
const DEFAULT_TENANT_ID = "tenant-atlas" const DEFAULT_TENANT_ID = "tenant-atlas"
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias 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 } 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) { async function findActiveMachineToken(ctx: QueryCtx, machineId: Id<"machines">, now: number) {
const tokens = await ctx.db const tokens = await ctx.db
.query("machineTokens") .query("machineTokens")
@ -93,6 +102,51 @@ function computeFingerprint(tenantId: string, companySlug: string | undefined, h
return toHex(sha256(payload)) 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 { function extractCollaboratorEmail(metadata: unknown): string | null {
if (!metadata || typeof metadata !== "object") return null if (!metadata || typeof metadata !== "object") return null
const record = metadata as Record<string, unknown> const record = metadata as Record<string, unknown>
@ -126,18 +180,18 @@ async function getActiveToken(
.unique() .unique()
if (!token) { if (!token) {
throw new ConvexError("Token de máquina inválido") throw new ConvexError("Token de dispositivo inválido")
} }
if (token.revoked) { if (token.revoked) {
throw new ConvexError("Token de máquina revogado") throw new ConvexError("Token de dispositivo revogado")
} }
if (token.expiresAt < Date.now()) { 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) const machine = await ctx.db.get(token.machineId)
if (!machine) { 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 } return { token, machine }
@ -381,7 +435,7 @@ async function evaluatePostureAndMaybeRaise(
if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "false").toLowerCase() !== "true") return if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "false").toLowerCase() !== "true") return
if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) 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(" | ") const summary = findings.map((f) => `${f.severity.toUpperCase()}: ${f.message}`).join(" | ")
await createTicketForAlert(ctx, machine.tenantId, machine.companyId, subject, summary) await createTicketForAlert(ctx, machine.tenantId, machine.companyId, subject, summary)
} }
@ -445,6 +499,7 @@ export const register = mutation({
companyId: companyId ?? existing.companyId, companyId: companyId ?? existing.companyId,
companySlug: companySlug ?? existing.companySlug, companySlug: companySlug ?? existing.companySlug,
hostname: args.hostname, hostname: args.hostname,
displayName: existing.displayName ?? args.hostname,
osName: args.os.name, osName: args.os.name,
osVersion: args.os.version, osVersion: args.os.version,
architecture: args.os.architecture, architecture: args.os.architecture,
@ -457,6 +512,9 @@ export const register = mutation({
status: "online", status: "online",
isActive: true, isActive: true,
registeredBy: args.registeredBy ?? existing.registeredBy, registeredBy: args.registeredBy ?? existing.registeredBy,
deviceType: existing.deviceType ?? "desktop",
devicePlatform: args.os.name ?? existing.devicePlatform,
managementMode: existing.managementMode ?? "agent",
persona: existing.persona, persona: existing.persona,
assignedUserId: existing.assignedUserId, assignedUserId: existing.assignedUserId,
assignedUserEmail: existing.assignedUserEmail, assignedUserEmail: existing.assignedUserEmail,
@ -470,6 +528,7 @@ export const register = mutation({
companyId, companyId,
companySlug, companySlug,
hostname: args.hostname, hostname: args.hostname,
displayName: args.hostname,
osName: args.os.name, osName: args.os.name,
osVersion: args.os.version, osVersion: args.os.version,
architecture: args.os.architecture, architecture: args.os.architecture,
@ -483,6 +542,9 @@ export const register = mutation({
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
registeredBy: args.registeredBy, registeredBy: args.registeredBy,
deviceType: "desktop",
devicePlatform: args.os.name,
managementMode: "agent",
persona: undefined, persona: undefined,
assignedUserId: undefined, assignedUserId: undefined,
assignedUserEmail: undefined, assignedUserEmail: undefined,
@ -582,6 +644,7 @@ export const upsertInventory = mutation({
companyId: companyId ?? existing.companyId, companyId: companyId ?? existing.companyId,
companySlug: companySlug ?? existing.companySlug, companySlug: companySlug ?? existing.companySlug,
hostname: args.hostname, hostname: args.hostname,
displayName: existing.displayName ?? args.hostname,
osName: args.os.name, osName: args.os.name,
osVersion: args.os.version, osVersion: args.os.version,
architecture: args.os.architecture, architecture: args.os.architecture,
@ -592,6 +655,9 @@ export const upsertInventory = mutation({
updatedAt: now, updatedAt: now,
status: args.metrics ? "online" : existing.status ?? "unknown", status: args.metrics ? "online" : existing.status ?? "unknown",
registeredBy: args.registeredBy ?? existing.registeredBy, registeredBy: args.registeredBy ?? existing.registeredBy,
deviceType: existing.deviceType ?? "desktop",
devicePlatform: args.os.name ?? existing.devicePlatform,
managementMode: existing.managementMode ?? "agent",
persona: existing.persona, persona: existing.persona,
assignedUserId: existing.assignedUserId, assignedUserId: existing.assignedUserId,
assignedUserEmail: existing.assignedUserEmail, assignedUserEmail: existing.assignedUserEmail,
@ -605,6 +671,7 @@ export const upsertInventory = mutation({
companyId, companyId,
companySlug, companySlug,
hostname: args.hostname, hostname: args.hostname,
displayName: args.hostname,
osName: args.os.name, osName: args.os.name,
osVersion: args.os.version, osVersion: args.os.version,
architecture: args.os.architecture, architecture: args.os.architecture,
@ -617,6 +684,9 @@ export const upsertInventory = mutation({
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
registeredBy: args.registeredBy, registeredBy: args.registeredBy,
deviceType: "desktop",
devicePlatform: args.os.name,
managementMode: "agent",
persona: undefined, persona: undefined,
assignedUserId: undefined, assignedUserId: undefined,
assignedUserEmail: undefined, assignedUserEmail: undefined,
@ -673,9 +743,13 @@ export const heartbeat = mutation({
await ctx.db.patch(machine._id, { await ctx.db.patch(machine._id, {
hostname: args.hostname ?? machine.hostname, hostname: args.hostname ?? machine.hostname,
displayName: machine.displayName ?? args.hostname ?? machine.hostname,
osName: args.os?.name ?? machine.osName, osName: args.os?.name ?? machine.osName,
osVersion: args.os?.version ?? machine.osVersion, osVersion: args.os?.version ?? machine.osVersion,
architecture: args.os?.architecture ?? machine.architecture, architecture: args.os?.architecture ?? machine.architecture,
devicePlatform: args.os?.name ?? machine.devicePlatform,
deviceType: machine.deviceType ?? "desktop",
managementMode: machine.managementMode ?? "agent",
lastHeartbeatAt: now, lastHeartbeatAt: now,
updatedAt: now, updatedAt: now,
status: args.status ?? "online", status: args.status ?? "online",
@ -839,6 +913,11 @@ export const listByTenant = query({
id: machine._id, id: machine._id,
tenantId: machine.tenantId, tenantId: machine.tenantId,
hostname: machine.hostname, 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, companyId: machine.companyId ?? null,
companySlug: machine.companySlug ?? companyFromId?.slug ?? companyFromSlug?.slug ?? null, companySlug: machine.companySlug ?? companyFromId?.slug ?? companyFromSlug?.slug ?? null,
companyName: resolvedCompany?.name ?? null, companyName: resolvedCompany?.name ?? null,
@ -873,6 +952,7 @@ export const listByTenant = query({
inventory, inventory,
postureAlerts, postureAlerts,
lastPostureAt, lastPostureAt,
customFields: machine.customFields ?? [],
} }
}) })
) )
@ -957,6 +1037,11 @@ export async function getByIdHandler(
id: machine._id, id: machine._id,
tenantId: machine.tenantId, tenantId: machine.tenantId,
hostname: machine.hostname, 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, companyId: machine.companyId ?? null,
companySlug: machine.companySlug ?? resolvedCompany?.slug ?? null, companySlug: machine.companySlug ?? resolvedCompany?.slug ?? null,
companyName: resolvedCompany?.name ?? null, companyName: resolvedCompany?.name ?? null,
@ -992,6 +1077,7 @@ export async function getByIdHandler(
postureAlerts, postureAlerts,
lastPostureAt, lastPostureAt,
remoteAccess: machine.remoteAccess ?? null, remoteAccess: machine.remoteAccess ?? null,
customFields: machine.customFields ?? [],
} }
} }
@ -1333,7 +1419,7 @@ export async function updatePersonaHandler(
) { ) {
const machine = await ctx.db.get(args.machineId) const machine = await ctx.db.get(args.machineId)
if (!machine) { if (!machine) {
throw new ConvexError("Máquina não encontrada") throw new ConvexError("Dispositivo não encontrada")
} }
let nextPersona = machine.persona ?? undefined let nextPersona = machine.persona ?? undefined
@ -1343,7 +1429,7 @@ export async function updatePersonaHandler(
if (!trimmed) { if (!trimmed) {
nextPersona = undefined nextPersona = undefined
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) { } 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 { } else {
nextPersona = trimmed nextPersona = trimmed
} }
@ -1380,7 +1466,7 @@ export async function updatePersonaHandler(
} }
if (nextPersona && !nextAssignedUserId) { 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) { if (nextPersona && nextAssignedUserId) {
@ -1435,6 +1521,196 @@ export async function updatePersonaHandler(
return { ok: true, persona: nextPersona ?? null } 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({ export const updatePersona = mutation({
args: { args: {
machineId: v.id("machines"), machineId: v.id("machines"),
@ -1454,7 +1730,7 @@ export const getContext = query({
handler: async (ctx, args) => { handler: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId) const machine = await ctx.db.get(args.machineId)
if (!machine) { if (!machine) {
throw new ConvexError("Máquina não encontrada") throw new ConvexError("Dispositivo não encontrada")
} }
const linkedUserIds = machine.linkedUserIds ?? [] const linkedUserIds = machine.linkedUserIds ?? []
@ -1515,7 +1791,7 @@ export const linkAuthAccount = mutation({
handler: async (ctx, args) => { handler: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId) const machine = await ctx.db.get(args.machineId)
if (!machine) { if (!machine) {
throw new ConvexError("Máquina não encontrada") throw new ConvexError("Dispositivo não encontrada")
} }
await ctx.db.patch(machine._id, { await ctx.db.patch(machine._id, {
@ -1535,7 +1811,7 @@ export const linkUser = mutation({
}, },
handler: async (ctx, { machineId, email }) => { handler: async (ctx, { machineId, email }) => {
const machine = await ctx.db.get(machineId) 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 tenantId = machine.tenantId
const normalized = email.trim().toLowerCase() const normalized = email.trim().toLowerCase()
@ -1546,7 +1822,7 @@ export const linkUser = mutation({
if (!user) throw new ConvexError("Usuário não encontrado") if (!user) throw new ConvexError("Usuário não encontrado")
const role = (user.role ?? "").toUpperCase() const role = (user.role ?? "").toUpperCase()
if (role === 'ADMIN' || role === 'AGENT') { 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 ?? []) const current = new Set<Id<"users">>(machine.linkedUserIds ?? [])
@ -1563,7 +1839,7 @@ export const unlinkUser = mutation({
}, },
handler: async (ctx, { machineId, userId }) => { handler: async (ctx, { machineId, userId }) => {
const machine = await ctx.db.get(machineId) 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) const next = (machine.linkedUserIds ?? []).filter((id) => id !== userId)
await ctx.db.patch(machine._id, { linkedUserIds: next, updatedAt: Date.now() }) await ctx.db.patch(machine._id, { linkedUserIds: next, updatedAt: Date.now() })
return { ok: true } return { ok: true }
@ -1580,25 +1856,29 @@ export const rename = mutation({
// Reutiliza requireStaff através de tickets.ts helpers // Reutiliza requireStaff através de tickets.ts helpers
const machine = await ctx.db.get(machineId) const machine = await ctx.db.get(machineId)
if (!machine) { 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) const viewer = await ctx.db.get(actorId)
if (!viewer || viewer.tenantId !== machine.tenantId) { 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 normalizedRole = (viewer.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"]) const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(normalizedRole)) { 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() const nextName = hostname.trim()
if (nextName.length < 2) { 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 } return { ok: true }
}, },
}) })
@ -1612,17 +1892,17 @@ export const toggleActive = mutation({
handler: async (ctx, { machineId, actorId, active }) => { handler: async (ctx, { machineId, actorId, active }) => {
const machine = await ctx.db.get(machineId) const machine = await ctx.db.get(machineId)
if (!machine) { if (!machine) {
throw new ConvexError("Máquina não encontrada") throw new ConvexError("Dispositivo não encontrada")
} }
const actor = await ctx.db.get(actorId) const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) { 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 normalizedRole = (actor.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"]) const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(normalizedRole)) { 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, { await ctx.db.patch(machineId, {
@ -1642,17 +1922,17 @@ export const resetAgent = mutation({
handler: async (ctx, { machineId, actorId }) => { handler: async (ctx, { machineId, actorId }) => {
const machine = await ctx.db.get(machineId) const machine = await ctx.db.get(machineId)
if (!machine) { if (!machine) {
throw new ConvexError("Máquina não encontrada") throw new ConvexError("Dispositivo não encontrada")
} }
const actor = await ctx.db.get(actorId) const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) { 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 normalizedRole = (actor.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"]) const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(normalizedRole)) { 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 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 }) => { handler: async (ctx, { machineId, actorId, provider, identifier, url, notes, action, entryId, clear }) => {
const machine = await ctx.db.get(machineId) const machine = await ctx.db.get(machineId)
if (!machine) { if (!machine) {
throw new ConvexError("Máquina não encontrada") throw new ConvexError("Dispositivo não encontrada")
} }
const actor = await ctx.db.get(actorId) const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) { 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 normalizedRole = (actor.role ?? "AGENT").toUpperCase()
@ -1937,17 +2217,17 @@ export const remove = mutation({
handler: async (ctx, { machineId, actorId }) => { handler: async (ctx, { machineId, actorId }) => {
const machine = await ctx.db.get(machineId) const machine = await ctx.db.get(machineId)
if (!machine) { if (!machine) {
throw new ConvexError("Máquina não encontrada") throw new ConvexError("Dispositivo não encontrada")
} }
const actor = await ctx.db.get(actorId) const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) { 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 role = (actor.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"]) const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(role)) { 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 const tokens = await ctx.db

View file

@ -51,6 +51,39 @@ function extractScore(payload: unknown): number | null {
return 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 { function isNotNull<T>(value: T | null): value is T {
return value !== null; return value !== null;
} }
@ -119,6 +152,44 @@ async function fetchScopedTicketsByCreatedRange(
.collect(); .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) { async function fetchQueues(ctx: QueryCtx, tenantId: string) {
return ctx.db return ctx.db
.query("queues") .query("queues")
@ -159,12 +230,47 @@ type CsatSurvey = {
ticketId: Id<"tickets">; ticketId: Id<"tickets">;
reference: number; reference: number;
score: number; score: number;
maxScore: number;
comment: string | null;
receivedAt: number; receivedAt: number;
assigneeId: string | null;
assigneeName: string | null;
}; };
async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise<CsatSurvey[]> { async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise<CsatSurvey[]> {
const perTicket = await Promise.all( const perTicket = await Promise.all(
tickets.map(async (ticket) => { 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 const events = await ctx.db
.query("ticketEvents") .query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id)) .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
@ -174,11 +280,16 @@ async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Pro
.map((event) => { .map((event) => {
const score = extractScore(event.payload); const score = extractScore(event.payload);
if (score === null) return null; if (score === null) return null;
const assignee = extractAssignee(event.payload)
return { return {
ticketId: ticket._id, ticketId: ticket._id,
reference: ticket.reference, reference: ticket.reference,
score, score,
maxScore: extractMaxScore(event.payload) ?? 5,
comment: extractComment(event.payload),
receivedAt: event.createdAt, receivedAt: event.createdAt,
assigneeId: assignee.id,
assigneeName: assignee.name,
} as CsatSurvey; } as CsatSurvey;
}) })
.filter(isNotNull); .filter(isNotNull);
@ -275,12 +386,61 @@ export async function csatOverviewHandler(
const startMs = endMs - days * ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS;
const surveys = surveysAll.filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs); 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) => ({ const distribution = [1, 2, 3, 4, 5].map((score) => ({
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 { return {
totalSurveys: surveys.length, totalSurveys: surveys.length,
averageScore, averageScore,
@ -293,9 +453,15 @@ export async function csatOverviewHandler(
ticketId: item.ticketId, ticketId: item.ticketId,
reference: item.reference, reference: item.reference,
score: item.score, score: item.score,
maxScore: item.maxScore,
comment: item.comment,
receivedAt: item.receivedAt, receivedAt: item.receivedAt,
assigneeId: item.assigneeId,
assigneeName: item.assigneeName,
})), })),
rangeDays: days, 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"> } { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
) { ) {
const viewer = await requireStaff(ctx, viewerId, tenantId); 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 days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const end = new Date(); const end = new Date();
end.setUTCHours(0, 0, 0, 0); end.setUTCHours(0, 0, 0, 0);
const endMs = end.getTime() + ONE_DAY_MS; const endMs = end.getTime() + ONE_DAY_MS;
const startMs = endMs - days * 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 opened: Record<string, number> = {}
const resolved: Record<string, number> = {} const resolved: Record<string, number> = {}
@ -328,15 +494,19 @@ export async function openedResolvedByDayHandler(
resolved[key] = 0 resolved[key] = 0
} }
for (const t of tickets) { for (const ticket of openedTickets) {
if (t.createdAt >= startMs && t.createdAt < endMs) { if (ticket.createdAt >= startMs && ticket.createdAt < endMs) {
const key = formatDateKey(t.createdAt) const key = formatDateKey(ticket.createdAt)
opened[key] = (opened[key] ?? 0) + 1 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 }> = [] 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()), totalWorkedMs: v.optional(v.number()),
internalWorkedMs: v.optional(v.number()), internalWorkedMs: v.optional(v.number()),
externalWorkedMs: 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_machine", ["tenantId", "machineId"])
.index("by_tenant", ["tenantId"]) .index("by_tenant", ["tenantId"])
.index("by_tenant_created", ["tenantId", "createdAt"]) .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({ ticketComments: defineTable({
ticketId: v.id("tickets"), ticketId: v.id("tickets"),
@ -221,6 +243,45 @@ export default defineSchema({
createdAt: v.number(), createdAt: v.number(),
}).index("by_ticket", ["ticketId"]), }).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({ commentTemplates: defineTable({
tenantId: v.string(), tenantId: v.string(),
kind: v.optional(v.string()), kind: v.optional(v.string()),
@ -291,11 +352,29 @@ export default defineSchema({
}) })
) )
), ),
scope: v.optional(v.string()),
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
}) })
.index("by_tenant_key", ["tenantId", "key"]) .index("by_tenant_key", ["tenantId", "key"])
.index("by_tenant_order", ["tenantId", "order"]) .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"]), .index("by_tenant", ["tenantId"]),
userInvites: defineTable({ userInvites: defineTable({
@ -339,6 +418,23 @@ export default defineSchema({
serialNumbers: v.array(v.string()), serialNumbers: v.array(v.string()),
fingerprint: v.string(), fingerprint: v.string(),
metadata: v.optional(v.any()), 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()), lastHeartbeatAt: v.optional(v.number()),
status: v.optional(v.string()), status: v.optional(v.string()),
isActive: v.optional(v.boolean()), isActive: v.optional(v.boolean()),
@ -382,4 +478,58 @@ export default defineSchema({
.index("by_tenant_machine", ["tenantId", "machineId"]) .index("by_tenant_machine", ["tenantId", "machineId"])
.index("by_machine_created", ["machineId", "createdAt"]) .index("by_machine_created", ["machineId", "createdAt"])
.index("by_machine_revoked_expires", ["machineId", "revoked", "expiresAt"]), .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 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). - 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. - 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`). - 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. - 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) #### 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. - 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 SMTP_TLS=true
MAILER_SENDER_EMAIL="Nome <no-reply@seu-dominio.com>" MAILER_SENDER_EMAIL="Nome <no-reply@seu-dominio.com>"
# Máquina/inventário # Dispositivo/inventário
MACHINE_PROVISIONING_SECRET=<hex forte> MACHINE_PROVISIONING_SECRET=<hex forte>
MACHINE_TOKEN_TTL_MS=2592000000 MACHINE_TOKEN_TTL_MS=2592000000
FLEET_SYNC_SECRET=<hex forte ou igual ao de provisionamento> FLEET_SYNC_SECRET=<hex forte ou igual ao de provisionamento>
@ -239,7 +239,7 @@ docker run --rm -it \
### Smoke test pósdeploy (CI) ### Smoke test pósdeploy (CI)
O pipeline executa um teste rápido após o deploy do Web: 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` - Espera `HTTP 201` e extrai `machineToken`
- Envia `heartbeat` e espera `HTTP 200` - Envia `heartbeat` e espera `HTTP 200`
- Se falhar, o job é marcado como erro (evita regressões silenciosas) - 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`). - “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`. - “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)** 1. **No painel (Admin → Dispositivos)**
- Abra os detalhes da máquina que será reaproveitada (ex.: a “amarela” que passará da TI/João para a Maria). - 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. - 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. - 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). - 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** 3. **Dispositivo 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. - 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 máquina azul aparecerá como um **novo registro** no painel (inventário/tickets começarão do zero). Renomeie/associe conforme necessário. - 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** 4. **Verificação final**
- A máquina antiga (amarela) continua listada, agora vinculada à Maria, com seus tickets históricos. - A dispositivo 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 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. - Importante: não usar `CONVEX_DEPLOYMENT` em conjunto com URL + ADMIN_KEY.
- Como forçar o deploy do Convex - 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`). - 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/**`). - 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. - 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. - 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. - 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). Ú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 - Unificação de “Usuários” e “Agentes de dispositivo
- Antes: abas separadas “Usuários” (pessoas) e “Agentes de máquina”. - Antes: abas separadas “Usuários” (pessoas) e “Agentes de dispositivo”.
- Agora: uma só aba “Usuários” com filtro de tipo (Todos | Pessoas | Máquinas). - 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`. - 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. - 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. - 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. - 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” - Windows ▸ Rótulo do sistema sem duplicidade do “major”
- Exemplo: “Windows 11 Pro (26100)” em vez de “Windows 11 Pro 11 (26100)”. - 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. - Motivo: legibilidade e padronização em chips/cartões.
- Vínculos visuais entre máquinas e pessoas - Vínculos visuais entre dispositivos e pessoas
- Cards de máquinas mostram “Usuário vinculado” quando disponível (assignment/metadata): `src/components/admin/machines/admin-machines-overview.tsx:3198`. - 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 “Máquinas vinculadas” (derivado de assign/metadata): `src/components/admin/admin-users-manager.tsx` (seção “Máquinas vinculadas” no sheet de edição). - 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 ▸ Máquinas. - Observação: por ora é leitura; ajustes detalhados de vínculo permanecem em Admin ▸ Dispositivos.
### Identidade, email e histórico (reinstalação) ### 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. - 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. - 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): - Estrutura (Convex):
- `machines.linkedUserIds: Id<"users">[]` — lista de vínculos adicionais além do `assignedUserId` (principal). - `machines.linkedUserIds: Id<"users">[]` — lista de vínculos adicionais além do `assignedUserId` (principal).
- Mutations: `machines.linkUser(machineId, email)`, `machines.unlinkUser(machineId, userId)`. - 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: - UI:
- Detalhes da máquina mostram “Usuários vinculados” com remoção por item e campo para adicionar por email. - Detalhes da dispositivo 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`. - Editor de usuário mostra “Dispositivos 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. - Racional: permitir que uma dispositivo tenha mais de um colaborador/gestor associado, mantendo um “principal” (persona) para políticas e contexto.
### Onde editar ### Onde editar
- Usuários (pessoas): editar nome, email, papel, tenant e empresa; redefinir senha pelo painel. Arquivo: `src/components/admin/admin-users-manager.tsx`. - 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 ## Filtros e busca
- Busca livre por hostname, e-mail, MAC e número de série. - 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. - 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`). - macOS: pacotes (`pkgutil`), serviços (`launchctl`).
- Postura/Alertas: CPU alta, serviço parado, SMART em falha com severidade e última avaliação. - 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). - 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 máquina. - Ação administrativa extra: botão “Ajustar acesso” permite trocar colaborador/gestor e e-mail vinculados sem re-provisionar a dispositivo.
## Exportação ## Exportação
- Exportar CSV de softwares ou serviços diretamente da seção detalhada (quando disponíveis). - 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. - **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.). - **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. - **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). - **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. - **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. - **Serviços**: serviços coletados (Windows/Linux) com nome, display name e status.
- **Alertas**: postura recente (tipo, mensagem, severidade, criado em). - **Alertas**: postura recente (tipo, mensagem, severidade, criado em).
- **Métricas**: CPU/Memória/Disco/GPU com timestamp coletado. - **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. - **Sistema**: visão categorizada (Sistema, Dispositivo, Hardware, Acesso, Token, Fleet) contendo build, licença, domínio, fabricante, serial, colaborador, contagem de acessos, etc.
## Notas ## 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`. - **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. - **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. - **Á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. - **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. > 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. > Documento vivo. Atualize após cada marco relevante.
## Contexto ## Contexto
- **Objetivo:** Expandir o Sistema de Chamados (Next.js + Convex + Better Auth) para suportar: - **Objetivo:** Expandir o Sistema de Chamados (Next.js + Convex + Better Auth) para suportar:
- Cliente desktop nativo (Tauri) mantendo UI web e realtime. - 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. - Integração com agente de inventário (osquery/FleetDM) para registrar hardware, software e heartbeats.
- Pipeline de distribuição para Windows/macOS/Linux. - 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. - **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. | | 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. | | 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. | | 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). | | 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. | | 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. | | 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 máquina | ⏳ A fazer | Detectar token e exibir info da máquina em tickets. | | 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. | | 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. | | 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) ## Notas de Implementação (Atual)
- Criada pasta `apps/desktop` via `create-tauri-app` com template `vanilla-ts`. - 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. - 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: - 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_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`. - `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. - `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). - Novas tabelas Convex: `machines` (fingerprint, heartbeat, vínculo com AuthUser) e `machineTokens` (hash + TTL).
- Novos endpoints Next: - 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/heartbeat` — atualiza estado, métricas e renova TTL.
- `POST /api/machines/sessions` — troca `machineToken` por sessão Better Auth e devolve cookies. - `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`). - 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. - 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). - 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. - 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`. - 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. - 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. - 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) ### 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`). - 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. - 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. - 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). - 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á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`. - 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) ### Checklist de dependências Tauri (Linux)
```bash ```bash

View file

@ -13,8 +13,8 @@ Documento de referência sobre o estado atual do sistema (web + desktop), melhor
| Item | Descrição | Impacto | | 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. | | **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 máquinas/colaboradores. | | **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. | | **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. | | **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. | | **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 | | 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. | | 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. | | 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. - Monitorar execução do novo workflow de qualidade em PRs.
- Garantir que a equipe esteja ciente do procedimento atualizado de deploy (symlink + service update). - 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)._ _Ú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. 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 ## 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. - 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. - 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 ## O que já foi aplicado no projeto
- Middleware permite `GET /machines/handshake` sem exigir login (rota pública). - 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. - 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). - 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. - 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: 1) No executável, com DevTools:
```js ```js
fetch('/api/machines/session').then(r => r.status).then(console.log) 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): 2) Na UI (Portal/Topo):
- Mostrar nome/email do colaborador/gestor (não “Cliente / Sem email definido”). - 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`). 3) No Portal, o formulário “Abrir chamado” deve habilitar normalmente (usa `machineContext.assignedUserId`).
Se `GET /api/machines/session` retornar 403: 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) - Privada: `~/.tauri/raven.key` (nunca compartilhar)
- Pública: `~/.tauri/raven.key.pub` (cole em `tauri.conf.json > plugins.updater.pubkey`) - 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`** 2. **Verificar o `tauri.conf.json`**
```json ```json

View file

@ -61,8 +61,8 @@ Referências úteis:
- Abrir no navegador padrão com `openUrl` (`apps/desktop/src/main.ts:694`). - 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. - 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: - Mensagem de erro genérica no desktop:
- Antes: "Erro desconhecido ao registrar a máquina". - Antes: "Erro desconhecido ao registrar a dispositivo".
- Agora: exibe `Falha ao registrar máquina (STATUS): mensagem — detalhes` (quando disponíveis), facilitando diagnóstico. - Agora: exibe `Falha ao registrar dispositivo (STATUS): mensagem — detalhes` (quando disponíveis), facilitando diagnóstico.
## Provisionamento — segredo e boas práticas ## Provisionamento — segredo e boas práticas
- Variável: `MACHINE_PROVISIONING_SECRET` (VPS/Convex backend). - Variável: `MACHINE_PROVISIONING_SECRET` (VPS/Convex backend).
@ -74,7 +74,7 @@ Referências úteis:
docker service update --force sistema_convex_backend docker service update --force sistema_convex_backend
``` ```
3. Validar com `POST /api/machines/register` (esperado 201). 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 ## Pendências e próximos passos
- Mapear erros "esperados" para HTTP adequado no web (Next): - Mapear erros "esperados" para HTTP adequado no web (Next):
@ -90,9 +90,9 @@ Referências úteis:
## Checklist rápido de verificação (QA) ## 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. - `.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`. - "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. - 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 { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header" 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" import { DEFAULT_TENANT_ID } from "@/lib/constants"
export const runtime = "nodejs" export const runtime = "nodejs"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function AdminMachinesPage({ export default async function AdminDevicesPage({
searchParams, searchParams,
}: { searchParams: Promise<Record<string, string | string[] | undefined>> }) { }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const params = await searchParams const params = await searchParams
@ -16,13 +16,13 @@ export default async function AdminMachinesPage({
<AppShell <AppShell
header={ header={
<SiteHeader <SiteHeader
title="Parque de máquinas" title="Parque de dispositivos"
lead="Acompanhe quais dispositivos estão ativos, métricas recentes e a sincronização do agente." 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"> <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> </div>
</AppShell> </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 client = createConvexClient()
const { id } = await ctx.params const { id } = await ctx.params
const machineId = id as Id<"machines"> 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 }) if (!data) return NextResponse.json({ error: "Not found" }, { status: 404 })
return NextResponse.json(data, { status: 200 }) return NextResponse.json(data, { status: 200 })
} catch (err) { } catch (err) {

View file

@ -36,13 +36,13 @@ export async function GET(_request: Request, context: RouteContext) {
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
try { try {
const machine = (await client.query(api.machines.getById, { const machine = (await client.query(api.devices.getById, {
id: machineId, id: machineId,
includeMetadata: true, includeMetadata: true,
})) as MachineInventoryRecord | null })) as MachineInventoryRecord | null
if (!machine || machine.tenantId !== tenantId) { 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], { const workbook = buildMachinesInventoryWorkbook([machine], {
@ -64,6 +64,6 @@ export async function GET(_request: Request, context: RouteContext) {
}) })
} catch (error) { } catch (error) {
console.error("Failed to export machine inventory", 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) const client = new ConvexHttpClient(convexUrl)
try { try {
const machine = (await client.query(api.machines.getContext, { const machine = (await client.query(api.devices.getContext, {
machineId: parsed.machineId as Id<"machines">, machineId: parsed.machineId as Id<"machines">,
})) as { })) as {
id: string id: string
@ -47,7 +47,7 @@ export async function POST(request: Request) {
} | null } | null
if (!machine) { 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 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, companyId: machine.companyId ? (machine.companyId as Id<"companies">) : undefined,
})) as { _id?: Id<"users"> } | null })) as { _id?: Id<"users"> } | null
await client.mutation(api.machines.updatePersona, { await client.mutation(api.devices.updatePersona, {
machineId: parsed.machineId as Id<"machines">, machineId: parsed.machineId as Id<"machines">,
persona: parsed.persona, persona: parsed.persona,
assignedUserId: ensuredUser?._id, assignedUserId: ensuredUser?._id,
@ -73,6 +73,6 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true }) return NextResponse.json({ ok: true })
} catch (error) { } catch (error) {
console.error("[machines.access]", 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, assertAuthenticatedSession: assertAuthenticatedSession,
})) }))
describe("POST /api/admin/machines/delete", () => { describe("POST /api/admin/devices/delete", () => {
const originalEnv = process.env.NEXT_PUBLIC_CONVEX_URL const originalEnv = process.env.NEXT_PUBLIC_CONVEX_URL
let restoreConsole: (() => void) | undefined let restoreConsole: (() => void) | undefined
@ -65,7 +65,7 @@ describe("POST /api/admin/machines/delete", () => {
it("returns ok when the machine removal succeeds", async () => { it("returns ok when the machine removal succeeds", async () => {
const { POST } = await import("./route") const { POST } = await import("./route")
const response = await POST( const response = await POST(
new Request("http://localhost/api/admin/machines/delete", { new Request("http://localhost/api/admin/devices/delete", {
method: "POST", method: "POST",
body: JSON.stringify({ machineId: "jn_machine" }), 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 () => { it("still succeeds when the Convex machine is already missing", async () => {
mutationMock.mockImplementation(async (_ctx, payload) => { mutationMock.mockImplementation(async (_ctx, payload) => {
if (payload && typeof payload === "object" && "machineId" in 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" } return { _id: "user_123" }
}) })
const { POST } = await import("./route") const { POST } = await import("./route")
const response = await POST( const response = await POST(
new Request("http://localhost/api/admin/machines/delete", { new Request("http://localhost/api/admin/devices/delete", {
method: "POST", method: "POST",
body: JSON.stringify({ machineId: "jn_machine" }), body: JSON.stringify({ machineId: "jn_machine" }),
}) })
@ -107,14 +107,14 @@ describe("POST /api/admin/machines/delete", () => {
}) })
const { POST } = await import("./route") const { POST } = await import("./route")
const response = await POST( const response = await POST(
new Request("http://localhost/api/admin/machines/delete", { new Request("http://localhost/api/admin/devices/delete", {
method: "POST", method: "POST",
body: JSON.stringify({ machineId: "jn_machine" }), body: JSON.stringify({ machineId: "jn_machine" }),
}) })
) )
expect(response.status).toBe(500) 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() expect(deleteManyMock).not.toHaveBeenCalled()
}) })
}) })

View file

@ -50,17 +50,17 @@ export async function POST(request: Request) {
let machineMissing = false let machineMissing = false
try { try {
await convex.mutation(api.machines.remove, { await convex.mutation(api.devices.remove, {
machineId: parsed.data.machineId as Id<"machines">, machineId: parsed.data.machineId as Id<"machines">,
actorId, actorId,
}) })
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "" const message = error instanceof Error ? error.message : ""
if (message.includes("Máquina não encontrada")) { if (message.includes("Dispositivo não encontrada")) {
machineMissing = true machineMissing = true
} else { } else {
console.error("[machines.delete] Convex failure", error) 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 }) return NextResponse.json({ ok: true, machineMissing })
} catch (error) { } catch (error) {
console.error("[machines.delete] Falha ao excluir", 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) const client = new ConvexHttpClient(convexUrl)
try { try {
await client.mutation(api.machines.linkUser, { await client.mutation(api.devices.linkUser, {
machineId: parsed.machineId as Id<"machines">, machineId: parsed.machineId as Id<"machines">,
email: parsed.email, email: parsed.email,
}) })
@ -53,7 +53,7 @@ export async function DELETE(request: Request) {
const client = new ConvexHttpClient(convexUrl) const client = new ConvexHttpClient(convexUrl)
try { try {
await client.mutation(api.machines.unlinkUser, { await client.mutation(api.devices.unlinkUser, {
machineId: parsed.data.machineId as Id<"machines">, machineId: parsed.data.machineId as Id<"machines">,
userId: parsed.data.userId as Id<"users">, userId: parsed.data.userId as Id<"users">,
}) })

View file

@ -59,7 +59,7 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true }) return NextResponse.json({ ok: true })
} catch (error) { } catch (error) {
console.error("[machines.rename] Falha ao renomear", 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 }) return NextResponse.json({ ok: true, revoked: result?.revoked ?? 0 })
} catch (error) { } catch (error) {
console.error("[machines.resetAgent] Falha ao resetar agente", 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 }) return NextResponse.json({ ok: true })
} catch (error) { } catch (error) {
console.error("[machines.toggleActive] Falha ao atualizar status", 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") { 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 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") { 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)) { if (!sessionIsAdmin && !canManageRole(nextRole)) {
@ -356,7 +356,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
} }
if (target.role === "machine") { 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) { if (target.email === session.user.email) {

View file

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

View file

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

View file

@ -56,7 +56,7 @@ export async function POST(request: Request) {
} }
try { 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) return jsonWithCors(response, 200, origin, CORS_METHODS)
} catch (error) { } catch (error) {
console.error("[machines.heartbeat] Falha ao registrar heartbeat", 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(response.status).toBe(200)
expect(mutationMock).toHaveBeenCalledWith( expect(mutationMock).toHaveBeenCalledWith(
api.machines.heartbeat, api.devices.heartbeat,
expect.objectContaining({ expect.objectContaining({
machineToken: "token-123", machineToken: "token-123",
hostname: "machine", hostname: "machine",
@ -67,7 +67,7 @@ describe("POST /api/machines/inventory", () => {
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(mutationMock).toHaveBeenCalledWith( expect(mutationMock).toHaveBeenCalledWith(
api.machines.upsertInventory, api.devices.upsertInventory,
expect.objectContaining({ expect.objectContaining({
provisioningCode: "a".repeat(32), provisioningCode: "a".repeat(32),
hostname: "machine", 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) const tokenParsed = tokenModeSchema.safeParse(raw)
if (tokenParsed.success) { if (tokenParsed.success) {
try { try {
const result = await client.mutation(api.machines.heartbeat, { const result = await client.mutation(api.devices.heartbeat, {
machineToken: tokenParsed.data.machineToken, machineToken: tokenParsed.data.machineToken,
hostname: tokenParsed.data.hostname, hostname: tokenParsed.data.hostname,
os: tokenParsed.data.os, os: tokenParsed.data.os,
@ -87,7 +87,7 @@ export async function POST(request: Request) {
const provParsed = provisioningModeSchema.safeParse(raw) const provParsed = provisioningModeSchema.safeParse(raw)
if (provParsed.success) { if (provParsed.success) {
try { try {
const result = await client.mutation(api.machines.upsertInventory, { const result = await client.mutation(api.devices.upsertInventory, {
provisioningCode: provParsed.data.provisioningCode.trim().toLowerCase(), provisioningCode: provParsed.data.provisioningCode.trim().toLowerCase(),
hostname: provParsed.data.hostname, hostname: provParsed.data.hostname,
os: provParsed.data.os, os: provParsed.data.os,

View file

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

View file

@ -43,7 +43,7 @@ describe("GET /api/machines/session", () => {
expect(response.status).toBe(403) expect(response.status).toBe(403)
const payload = await response.json() 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() expect(mockCreateConvexClient).not.toHaveBeenCalled()
}) })

View file

@ -21,7 +21,7 @@ export const runtime = "nodejs"
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const session = await assertAuthenticatedSession() const session = await assertAuthenticatedSession()
if (!session || session.user?.role !== "machine") { 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 let client
@ -42,23 +42,23 @@ export async function GET(request: NextRequest) {
if (!machineId) { if (!machineId) {
try { try {
const lookup = (await client.query(api.machines.findByAuthEmail, { const lookup = (await client.query(api.devices.findByAuthEmail, {
authEmail: session.user.email.toLowerCase(), authEmail: session.user.email.toLowerCase(),
})) as { id: string } | null })) as { id: string } | null
if (!lookup?.id) { 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"> machineId = lookup.id as Id<"machines">
} catch (error) { } catch (error) {
console.error("[machines.session] Falha ao localizar máquina por e-mail", error) console.error("[machines.session] Falha ao localizar dispositivo por e-mail", error)
return NextResponse.json({ error: "Não foi possível localizar a máquina." }, { status: 500 }) return NextResponse.json({ error: "Não foi possível localizar a dispositivo." }, { status: 500 })
} }
} }
try { try {
let context = (await client.query(api.machines.getContext, { let context = (await client.query(api.devices.getContext, {
machineId, machineId,
})) as { })) as {
id: string id: string
@ -109,7 +109,7 @@ export async function GET(request: NextRequest) {
ensuredAssignedUserRole = ensuredUser.role ?? ensuredAssignedUserRole ?? assignedRole ensuredAssignedUserRole = ensuredUser.role ?? ensuredAssignedUserRole ?? assignedRole
ensuredPersona = normalizedPersona ensuredPersona = normalizedPersona
await client.mutation(api.machines.updatePersona, { await client.mutation(api.devices.updatePersona, {
machineId: machineId as Id<"machines">, machineId: machineId as Id<"machines">,
persona: normalizedPersona, persona: normalizedPersona,
assignedUserId: ensuredUser._id as Id<"users">, assignedUserId: ensuredUser._id as Id<"users">,
@ -118,7 +118,7 @@ export async function GET(request: NextRequest) {
assignedUserRole: (ensuredAssignedUserRole ?? assignedRole).toUpperCase(), assignedUserRole: (ensuredAssignedUserRole ?? assignedRole).toUpperCase(),
}) })
context = (await client.query(api.machines.getContext, { context = (await client.query(api.devices.getContext, {
machineId, machineId,
})) as typeof context })) as typeof context
@ -172,7 +172,7 @@ export async function GET(request: NextRequest) {
return response return response
} catch (error) { } catch (error) {
console.error("[machines.session] Falha ao obter contexto da máquina", error) console.error("[machines.session] Falha ao obter contexto da dispositivo", error)
return NextResponse.json({ error: "Falha ao obter contexto da máquina." }, { status: 500 }) 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) { } catch (error) {
if (error instanceof MachineInactiveError) { if (error instanceof MachineInactiveError) {
return jsonWithCors( 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, 423,
origin, origin,
CORS_METHODS CORS_METHODS
) )
} }
console.error("[machines.sessions] Falha ao criar sessão", error) 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 { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { buildMachinesInventoryWorkbook, type MachineInventoryRecord } from "@/server/machines/inventory-export" import { buildMachinesInventoryWorkbook, type MachineInventoryRecord } from "@/server/machines/inventory-export"
import type { DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns"
export const runtime = "nodejs" export const runtime = "nodejs"
@ -22,6 +23,31 @@ export async function GET(request: Request) {
const companyId = searchParams.get("companyId") ?? undefined const companyId = searchParams.get("companyId") ?? undefined
const machineIdParams = searchParams.getAll("machineId").filter(Boolean) const machineIdParams = searchParams.getAll("machineId").filter(Boolean)
const machineIdFilter = machineIdParams.length > 0 ? new Set(machineIdParams) : null 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 client = new ConvexHttpClient(convexUrl)
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
@ -46,7 +72,7 @@ export async function GET(request: Request) {
} }
try { try {
const machines = (await client.query(api.machines.listByTenant, { const machines = (await client.query(api.devices.listByTenant, {
tenantId, tenantId,
includeMetadata: true, includeMetadata: true,
})) as MachineInventoryRecord[] })) as MachineInventoryRecord[]
@ -77,6 +103,7 @@ export async function GET(request: Request) {
generatedBy: session.user.name ?? session.user.email, generatedBy: session.user.name ?? session.user.email,
companyFilterLabel, companyFilterLabel,
generatedAt: new Date(), generatedAt: new Date(),
columns: columnConfig,
}) })
const body = new Uint8Array(workbook) const body = new Uint8Array(workbook)

View file

@ -9,7 +9,7 @@ const ERROR_TEMPLATE = `
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> <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; } 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); } 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> </head>
<body> <body>
<main> <main>
<h1>Não foi possível autenticar esta máquina</h1> <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 máquina ativa.</p> <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> <p>Volte ao agente desktop, gere um novo token ou realize o provisionamento novamente.</p>
<a href="/">Voltar para o Raven</a> <a href="/">Voltar para o Raven</a>
</main> </main>
@ -36,7 +36,7 @@ const INACTIVE_TEMPLATE = `
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Máquina desativada</title> <title>Dispositivo desativada</title>
<style> <style>
:root { color-scheme: dark light; } :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; } 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> <body>
<main> <main>
<div class="badge">Acesso bloqueado</div> <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>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> <a href="mailto:suporte@rever.com.br">Falar com o suporte</a>
</main> </main>
</body> </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, { return new NextResponse(ERROR_TEMPLATE, {
status: 500, status: 500,
headers: { headers: {

View file

@ -102,7 +102,7 @@ const ROLE_LABELS: Record<string, string> = {
manager: "Gestor", manager: "Gestor",
agent: "Agente", agent: "Agente",
collaborator: "Colaborador", collaborator: "Colaborador",
machine: "Agente de máquina", machine: "Agente de dispositivo",
} }
function formatRole(role: string) { function formatRole(role: string) {
@ -305,7 +305,7 @@ export function AdminUsersManager({
() => [ () => [
{ value: "all", label: "Todos" }, { value: "all", label: "Todos" },
{ value: "people", label: "Pessoas" }, { 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 [teamSelection, setTeamSelection] = useState<Set<string>>(new Set())
const [isBulkDeletingTeam, setIsBulkDeletingTeam] = useState(false) const [isBulkDeletingTeam, setIsBulkDeletingTeam] = useState(false)
const [bulkDeleteTeamOpen, setBulkDeleteTeamOpen] = useState(false) const [bulkDeleteTeamOpen, setBulkDeleteTeamOpen] = useState(false)
// Removidos filtros antigos de Pessoas/Máquinas (agora unificado) // Removidos filtros antigos de Pessoas/Dispositivos (agora unificado)
// Unificado (pessoas + máquinas) // Unificado (pessoas + dispositivos)
const [usersSearch, setUsersSearch] = useState("") const [usersSearch, setUsersSearch] = useState("")
const [usersTypeFilter, setUsersTypeFilter] = useState<"all" | "people" | "machines">("people") const [usersTypeFilter, setUsersTypeFilter] = useState<"all" | "people" | "machines">("people")
const [usersCompanyFilter, setUsersCompanyFilter] = useState<string>("all") const [usersCompanyFilter, setUsersCompanyFilter] = useState<string>("all")
@ -366,7 +366,7 @@ export function AdminUsersManager({
const cleanupPreview = useMemo(() => Array.from(buildKeepEmailSet()).join(", "), [buildKeepEmailSet]) 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 = { type MachinesListItem = {
id: string id: string
hostname?: string hostname?: string
@ -375,7 +375,7 @@ export function AdminUsersManager({
linkedUsers?: Array<{ id: string; email: string; name: string }> linkedUsers?: Array<{ id: string; email: string; name: string }>
} }
const machinesList = useQuery( const machinesList = useQuery(
api.machines.listByTenant, api.devices.listByTenant,
convexUserId ? { tenantId: defaultTenantId, includeMetadata: true } : "skip" convexUserId ? { tenantId: defaultTenantId, includeMetadata: true } : "skip"
) as MachinesListItem[] | undefined ) 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 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 [inviteSelection, setInviteSelection] = useState<Set<string>>(new Set())
const selectedInvites = useMemo(() => invites.filter((i) => inviteSelection.has(i.id)), [invites, inviteSelection]) const selectedInvites = useMemo(() => invites.filter((i) => inviteSelection.has(i.id)), [invites, inviteSelection])
@ -951,7 +951,7 @@ export function AdminUsersManager({
if (!user) return if (!user) return
const machineId = extractMachineId(user.email) const machineId = extractMachineId(user.email)
if (!machineId) return if (!machineId) return
const response = await fetch(`/api/admin/machines/delete`, { const response = await fetch(`/api/admin/devices/delete`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
@ -1098,9 +1098,9 @@ async function handleDeleteUser() {
if (isMachine) { if (isMachine) {
const machineId = extractMachineId(deleteTarget.email) const machineId = extractMachineId(deleteTarget.email)
if (!machineId) { 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", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId }), body: JSON.stringify({ machineId }),
@ -1108,9 +1108,9 @@ async function handleDeleteUser() {
}) })
if (!response.ok) { if (!response.ok) {
const data = await response.json().catch(() => ({})) 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 { } else {
const response = await fetch(`/api/admin/users/${deleteTarget.id}`, { const response = await fetch(`/api/admin/users/${deleteTarget.id}`, {
method: "DELETE", method: "DELETE",
@ -1457,7 +1457,7 @@ async function handleDeleteUser() {
<Input <Input
value={usersSearch} value={usersSearch}
onChange={(event) => setUsersSearch(event.target.value)} 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" className="h-9 pl-9"
/> />
</div> </div>
@ -1513,7 +1513,7 @@ async function handleDeleteUser() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Usuários</CardTitle> <CardTitle>Usuários</CardTitle>
<CardDescription>Pessoas e máquinas com acesso ao sistema.</CardDescription> <CardDescription>Pessoas e dispositivos com acesso ao sistema.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">
@ -1564,11 +1564,11 @@ async function handleDeleteUser() {
</div> </div>
</TableCell> </TableCell>
<TableCell className="px-4 font-medium text-neutral-800"> <TableCell className="px-4 font-medium text-neutral-800">
{user.name || (user.role === "machine" ? "Máquina" : "—")} {user.name || (user.role === "machine" ? "Dispositivo" : "—")}
</TableCell> </TableCell>
<TableCell className="px-4 text-neutral-600">{user.email}</TableCell> <TableCell className="px-4 text-neutral-600">{user.email}</TableCell>
<TableCell className="px-4 text-neutral-600"> <TableCell className="px-4 text-neutral-600">
{user.role === "machine" ? "Máquina" : "Pessoa"} {user.role === "machine" ? "Dispositivo" : "Pessoa"}
</TableCell> </TableCell>
<TableCell className="px-4 text-neutral-600"> <TableCell className="px-4 text-neutral-600">
{user.role === "machine" ? ( {user.role === "machine" ? (
@ -1606,11 +1606,11 @@ async function handleDeleteUser() {
<Link <Link
href={ href={
extractMachineId(user.email) extractMachineId(user.email)
? `/admin/machines/${extractMachineId(user.email)}` ? `/admin/devices/${extractMachineId(user.email)}`
: "/admin/machines" : "/admin/devices"
} }
> >
Detalhes da máquina Detalhes da dispositivo
</Link> </Link>
</Button> </Button>
) : null} ) : null}
@ -2113,7 +2113,7 @@ async function handleDeleteUser() {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Remover usuários selecionados</DialogTitle> <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> </DialogHeader>
<div className="max-h-64 space-y-2 overflow-auto"> <div className="max-h-64 space-y-2 overflow-auto">
{Array.from(usersSelection).slice(0, 5).map((id) => { {Array.from(usersSelection).slice(0, 5).map((id) => {
@ -2240,27 +2240,27 @@ async function handleDeleteUser() {
if (r === 'admin' || r === 'agent') return null if (r === 'admin' || r === 'agent') return null
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Máquinas vinculadas</Label> <Label>Dispositivos vinculadas</Label>
{linkedMachinesForEditUser.length > 0 ? ( {linkedMachinesForEditUser.length > 0 ? (
<ul className="divide-y divide-slate-200 rounded-md border border-slate-200 bg-slate-50/60"> <ul className="divide-y divide-slate-200 rounded-md border border-slate-200 bg-slate-50/60">
{linkedMachinesForEditUser.map((m) => ( {linkedMachinesForEditUser.map((m) => (
<li key={`linked-m-${m.id}`} className="flex items-center justify-between px-3 py-2 text-sm"> <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> <span className="truncate">{m.hostname || m.id}</span>
<Button asChild size="sm" variant="ghost"> <Button asChild size="sm" variant="ghost">
<Link href={`/admin/machines/${m.id}`}>Abrir</Link> <Link href={`/admin/devices/${m.id}`}>Abrir</Link>
</Button> </Button>
</li> </li>
))} ))}
</ul> </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> </div>
) )
})()} })()}
{isMachineEditing ? ( {isMachineEditing ? (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-3 text-sm text-neutral-600"> <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>
) : ( ) : (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700"> <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> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{deleteTarget?.role === "machine" ? "Remover agente de máquina" : "Remover colaborador"} {deleteTarget?.role === "machine" ? "Remover agente de dispositivo" : "Remover colaborador"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{deleteTarget?.role === "machine" {deleteTarget?.role === "machine"
@ -2328,7 +2328,7 @@ async function handleDeleteUser() {
</p> </p>
{deleteTarget?.role === "machine" ? ( {deleteTarget?.role === "machine" ? (
<p> <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>
) : ( ) : (
<p>Esse usuário não poderá mais acessar o painel até receber um novo convite.</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 { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { MultiValueInput } from "@/components/ui/multi-value-input" 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 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 const effectiveTenantId = tenantId ?? companies[0]?.tenantId ?? DEFAULT_TENANT_ID
// Máquinas por empresa para contagem rápida // Dispositivos por empresa para contagem rápida
const machines = useQuery(api.machines.listByTenant, { const machines = useQuery(api.devices.listByTenant, {
tenantId: effectiveTenantId, tenantId: effectiveTenantId,
includeMetadata: false, includeMetadata: false,
}) as unknown[] | undefined }) as unknown[] | undefined
@ -513,10 +513,10 @@ export function AdminCompaniesManager({ initialCompanies, tenantId }: Props) {
type="button" type="button"
className="inline-flex items-center gap-1 text-muted-foreground transition hover:text-foreground" className="inline-flex items-center gap-1 text-muted-foreground transition hover:text-foreground"
onClick={() => { 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>
<button <button
type="button" type="button"
@ -754,7 +754,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
<TableHead>Empresa</TableHead> <TableHead>Empresa</TableHead>
<TableHead>Contratos ativos</TableHead> <TableHead>Contratos ativos</TableHead>
<TableHead>Contatos</TableHead> <TableHead>Contatos</TableHead>
<TableHead>Máquinas</TableHead> <TableHead>Dispositivos</TableHead>
<TableHead className="text-right">Ações</TableHead> <TableHead className="text-right">Ações</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -864,9 +864,9 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
<IconCopy className="mr-2 size-3.5" /> <IconCopy className="mr-2 size-3.5" />
Código Código
</Button> </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" /> <IconDeviceDesktop className="mr-2 size-3.5" />
Máquinas Dispositivos
</Button> </Button>
<Button <Button
size="icon" size="icon"
@ -1687,10 +1687,10 @@ function CompanySheet({ tenantId, editor, onClose, onCreated, onUpdated }: Compa
{editor?.mode === "edit" ? ( {editor?.mode === "edit" ? (
<AccordionItem value="machines" className="rounded-lg border border-border/60 bg-muted/20 px-4"> <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"> <AccordionContent className="pb-5">
<div className="rounded-lg border border-border/60 bg-background p-3"> <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> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>

View file

@ -5,28 +5,28 @@ import { useQuery } from "convex/react"
import { useParams, useRouter } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { import {
MachineDetails, DeviceDetails,
normalizeMachineItem, normalizeDeviceItem,
type MachinesQueryItem, type DevicesQueryItem,
} from "@/components/admin/machines/admin-machines-overview" } from "@/components/admin/devices/admin-devices-overview"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import { ConvexHttpClient } from "convex/browser" 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 router = useRouter()
const params = useParams<{ id?: string | string[] }>() const params = useParams<{ id?: string | string[] }>()
const routeMachineId = Array.isArray(params?.id) ? params?.id[0] : params?.id const routeDeviceId = Array.isArray(params?.id) ? params?.id[0] : params?.id
const effectiveMachineId = machineId ?? routeMachineId ?? "" const effectiveDeviceId = deviceId ?? routeDeviceId ?? ""
const canLoadMachine = Boolean(effectiveMachineId) const canLoadDevice = Boolean(effectiveDeviceId)
const single = useQuery( const single = useQuery(
api.machines.getById, api.devices.getById,
canLoadMachine canLoadDevice
? ({ id: effectiveMachineId as Id<"machines">, includeMetadata: true } as const) ? ({ id: effectiveDeviceId as Id<"machines">, includeMetadata: true } as const)
: "skip" : "skip"
) )
@ -34,7 +34,7 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
const [fallback, setFallback] = useState<Record<string, unknown> | null | undefined>(undefined) const [fallback, setFallback] = useState<Record<string, unknown> | null | undefined>(undefined)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const [retryTick, setRetryTick] = useState(0) const [retryTick, setRetryTick] = useState(0)
const shouldLoad = fallback === undefined && Boolean(effectiveMachineId) const shouldLoad = fallback === undefined && Boolean(effectiveDeviceId)
const [isHydrated, setIsHydrated] = useState(false) const [isHydrated, setIsHydrated] = useState(false)
useEffect(() => { useEffect(() => {
@ -51,8 +51,8 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
if (convexUrl) { if (convexUrl) {
try { try {
const http = new ConvexHttpClient(convexUrl) const http = new ConvexHttpClient(convexUrl)
const data = (await http.query(api.machines.getById, { const data = (await http.query(api.devices.getById, {
id: effectiveMachineId as Id<"machines">, id: effectiveDeviceId as Id<"machines">,
includeMetadata: true, includeMetadata: true,
})) as Record<string, unknown> | null })) as Record<string, unknown> | null
@ -75,7 +75,7 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
} }
try { try {
const res = await fetch(`/api/admin/machines/${effectiveMachineId}/details`, { const res = await fetch(`/api/admin/devices/${effectiveDeviceId}/details`, {
credentials: "include", credentials: "include",
cache: "no-store", cache: "no-store",
}) })
@ -108,29 +108,29 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
} }
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
console.error("[admin-machine-details] API fallback fetch failed", err) console.error("[admin-device-details] API fallback fetch failed", err)
setLoadError("Erro de rede ao carregar os dados da máquina.") setLoadError("Erro de rede ao carregar os dados da dispositivo.")
} }
} }
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
console.error("[admin-machine-details] Unexpected probe failure", err) console.error("[admin-device-details] Unexpected probe failure", err)
setLoadError("Erro de rede ao carregar os dados da máquina.") setLoadError("Erro de rede ao carregar os dados da dispositivo.")
} }
} }
} }
probe().catch((err) => { probe().catch((err) => {
if (!cancelled) { if (!cancelled) {
console.error("[admin-machine-details] Probe promise rejected", err) console.error("[admin-device-details] Probe promise rejected", err)
setLoadError("Erro de rede ao carregar os dados da máquina.") setLoadError("Erro de rede ao carregar os dados da dispositivo.")
} }
}) })
return () => { return () => {
cancelled = true 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 // Timeout de proteção: se depois de X segundos ainda estiver carregando e sem fallback, mostra erro claro
useEffect(() => { useEffect(() => {
@ -141,12 +141,12 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
) )
}, 10_000) }, 10_000)
return () => clearTimeout(timeout) 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) const source = single ?? (fallback === undefined ? undefined : fallback)
if (source === undefined || source === null) return source as null if (source === undefined || source === null) return source as null
return normalizeMachineItem(source) return normalizeDeviceItem(source)
}, [single, fallback]) }, [single, fallback])
const isLoading = single === undefined && fallback === undefined && !loadError const isLoading = single === undefined && fallback === undefined && !loadError
const isNotFound = (single === null || fallback === null) && !loadError const isNotFound = (single === null || fallback === null) && !loadError
@ -174,11 +174,11 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
// ignore // ignore
} }
} }
if (loadError && !machine) { if (loadError && !device) {
return ( return (
<Card> <Card>
<CardContent className="space-y-3 p-6"> <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> <p className="text-sm text-muted-foreground">{loadError}</p>
<div className="pt-2 flex items-center gap-2"> <div className="pt-2 flex items-center gap-2">
<Button size="sm" onClick={onRetry}>Tentar novamente</Button> <Button size="sm" onClick={onRetry}>Tentar novamente</Button>
@ -204,7 +204,7 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
return ( return (
<Card> <Card>
<CardContent className="space-y-3 p-6"> <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> <p className="text-sm text-muted-foreground">Verifique o identificador e tente novamente.</p>
<div className="pt-2 flex items-center gap-2"> <div className="pt-2 flex items-center gap-2">
<Button size="sm" onClick={onRetry}>Recarregar</Button> <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 href?: string | null
} }
type MachineBreadcrumbsProps = { type DeviceBreadcrumbsProps = {
tenantId: string tenantId: string
machineId: string deviceId: string
machineHref?: string | null deviceHref?: string | null
extra?: BreadcrumbSegment[] extra?: BreadcrumbSegment[]
} }
export function MachineBreadcrumbs({ tenantId: _tenantId, machineId, machineHref, extra }: MachineBreadcrumbsProps) { export function DeviceBreadcrumbs({ tenantId: _tenantId, deviceId, deviceHref, extra }: DeviceBreadcrumbsProps) {
const { convexUserId } = useAuth() const { convexUserId } = useAuth()
const canLoadMachine = Boolean(machineId && convexUserId) const canLoadDevice = Boolean(deviceId && convexUserId)
const item = useQuery( const item = useQuery(
api.machines.getById, api.devices.getById,
canLoadMachine canLoadDevice
? ({ id: machineId as Id<"machines">, includeMetadata: false } as const) ? ({ id: deviceId as Id<"machines">, includeMetadata: false } as const)
: "skip" : "skip"
) )
const hostname = useMemo(() => item?.hostname ?? "Detalhe", [item]) const hostname = useMemo(() => item?.hostname ?? "Detalhe", [item])
const segments = useMemo(() => { const segments = useMemo(() => {
const trail: BreadcrumbSegment[] = [ const trail: BreadcrumbSegment[] = [
{ label: "Máquinas", href: "/admin/machines" }, { label: "Dispositivos", href: "/admin/devices" },
{ label: hostname, href: machineHref ?? undefined }, { label: hostname, href: deviceHref ?? undefined },
] ]
if (Array.isArray(extra) && extra.length > 0) { if (Array.isArray(extra) && extra.length > 0) {
trail.push(...extra.filter((segment): segment is BreadcrumbSegment => Boolean(segment?.label))) trail.push(...extra.filter((segment): segment is BreadcrumbSegment => Boolean(segment?.label)))
} }
return trail return trail
}, [hostname, machineHref, extra]) }, [hostname, deviceHref, extra])
return ( return (
<nav className="mb-4 text-sm text-neutral-600"> <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 type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
import { EmptyIndicator } from "@/components/ui/empty-indicator" import { EmptyIndicator } from "@/components/ui/empty-indicator"
type MachineTicketHistoryItem = { type DeviceTicketHistoryItem = {
id: string id: string
reference: number reference: number
subject: string subject: string
@ -40,7 +40,7 @@ type MachineTicketHistoryItem = {
assignee: { name: string | null; email: string | null } | null assignee: { name: string | null; email: string | null } | null
} }
type MachineTicketsHistoryArgs = { type DeviceTicketsHistoryArgs = {
machineId: Id<"machines"> machineId: Id<"machines">
status?: "open" | "resolved" status?: "open" | "resolved"
priority?: string priority?: string
@ -49,7 +49,7 @@ type MachineTicketsHistoryArgs = {
to?: number to?: number
} }
type MachineTicketsHistoryStats = { type DeviceTicketsHistoryStats = {
total: number total: number
openCount: number openCount: number
resolvedCount: 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 [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all")
const [priorityFilter, setPriorityFilter] = useState<string>("ALL") const [priorityFilter, setPriorityFilter] = useState<string>("ALL")
const [periodPreset, setPeriodPreset] = useState<PeriodPreset>("90d") 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 range = useMemo(() => computeRange(periodPreset, customFrom, customTo), [periodPreset, customFrom, customTo])
const queryArgs = useMemo(() => { const queryArgs = useMemo(() => {
const args: MachineTicketsHistoryArgs = { const args: DeviceTicketsHistoryArgs = {
machineId: machineId as Id<"machines">, machineId: deviceId as Id<"machines">,
} }
if (statusFilter !== "all") { if (statusFilter !== "all") {
args.status = statusFilter args.status = statusFilter
@ -187,15 +187,15 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
args.to = range.to args.to = range.to
} }
return args 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( const { results: tickets, status: paginationStatus, loadMore } = usePaginatedQuery(
api.machines.listTicketsHistory, api.devices.listTicketsHistory,
queryArgs, queryArgs,
{ initialNumItems: 25 } { 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 totalTickets = stats?.total ?? 0
const openTickets = stats?.openCount ?? 0 const openTickets = stats?.openCount ?? 0
const resolvedTickets = stats?.resolvedCount ?? 0 const resolvedTickets = stats?.resolvedCount ?? 0
@ -321,7 +321,7 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
<EmptyHeader> <EmptyHeader>
<EmptyTitle>Nenhum chamado encontrado</EmptyTitle> <EmptyTitle>Nenhum chamado encontrado</EmptyTitle>
<EmptyDescription> <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> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
<EmptyContent> <EmptyContent>

View file

@ -109,7 +109,7 @@ const navigation: NavigationGroup[] = [
requiredRole: "admin", requiredRole: "admin",
}, },
{ title: "Usuários", url: "/admin/users", icon: Users, 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" }, { 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) { async function handleSubmit(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
if (machineInactive) { 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 return
} }
if (!convexUserId || !ticket) return if (!convexUserId || !ticket) return
@ -328,7 +328,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
<CardContent className="space-y-6 px-5 pb-6"> <CardContent className="space-y-6 px-5 pb-6">
{machineInactive ? ( {machineInactive ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700"> <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> </div>
) : null} ) : null}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">

View file

@ -64,13 +64,13 @@ export function PortalTicketForm() {
event.preventDefault() event.preventDefault()
if (isSubmitting || !isFormValid) return if (isSubmitting || !isFormValid) return
if (machineInactive) { 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 return
} }
if (!viewerId) { if (!viewerId) {
const detail = viewerErrorMessage ? ` Detalhes: ${viewerErrorMessage}` : "" const detail = viewerErrorMessage ? ` Detalhes: ${viewerErrorMessage}` : ""
toast.error( 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" } { id: "portal-new-ticket" }
) )
return return
@ -145,12 +145,12 @@ export function PortalTicketForm() {
<CardContent className="space-y-6 px-5 pb-6"> <CardContent className="space-y-6 px-5 pb-6">
{machineInactive ? ( {machineInactive ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700"> <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> </div>
) : null} ) : null}
{!isViewerReady ? ( {!isViewerReady ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700"> <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 ? ( {machineContextLoading ? (
<p className="mt-2 text-xs text-amber-600">Carregando informa<EFBFBD><EFBFBD>es da m<EFBFBD>quina...</p> <p className="mt-2 text-xs text-amber-600">Carregando informa<EFBFBD><EFBFBD>es da m<EFBFBD>quina...</p>
) : null} ) : null}

View file

@ -188,7 +188,7 @@ export function CompanyReport() {
</Card> </Card>
<Card className="border-slate-200"> <Card className="border-slate-200">
<CardHeader> <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> <CardDescription className="text-neutral-600">Inventário registrado nesta empresa.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{report.machines.total}</CardContent> <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"> <Card className="border-slate-200">
<CardHeader> <CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Sistemas operacionais</CardTitle> <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> </CardHeader>
<CardContent className="pb-6"> <CardContent className="pb-6">
<ChartContainer config={MACHINE_STATUS_CONFIG} className="mx-auto aspect-square max-h-[240px]"> <ChartContainer config={MACHINE_STATUS_CONFIG} className="mx-auto aspect-square max-h-[240px]">

View file

@ -1,7 +1,7 @@
"use client" "use client"
import { useQuery } from "convex/react" 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 { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
@ -13,7 +13,7 @@ import { useState } from "react"
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts" import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart" import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Button } from "@/components/ui/button" 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 { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter" 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 ( return (
<div className="space-y-8"> <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"> <Card className="border-slate-200">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500"> <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 <IconMoodSmile className="size-4 text-teal-500" /> CSAT médio
</CardTitle> </CardTitle>
<CardDescription className="text-neutral-600">Média das respostas recebidas.</CardDescription> <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>
</CardHeader> </CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900"> <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> </CardContent>
</Card> </Card>
<Card className="border-slate-200"> <Card className="border-slate-200">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500"> <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> </CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totalSurveys}</CardContent> <CardContent className="text-3xl font-semibold text-neutral-900">{data.totalSurveys}</CardContent>
</Card> </Card>
<Card className="border-slate-200"> <Card className="border-slate-200">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500"> <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> </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> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="text-3xl font-semibold text-neutral-900">
{data.recent.length === 0 ? ( {positiveRate === null ? "—" : `${(positiveRate * 100).toFixed(1).replace(".0", "")}%`}
<p className="text-sm text-neutral-500">Ainda não coletamos nenhuma avaliação.</p> </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 }) => ( <p className="text-sm text-neutral-500">Ainda não avaliações suficientes.</p>
<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>
))
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </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"> <Card className="border-slate-200">
<CardHeader> <CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Distribuição das notas</CardTitle> <CardTitle className="text-lg font-semibold text-neutral-900">Distribuição das notas</CardTitle>
@ -140,17 +245,80 @@ export function CsatReport() {
{data.totalSurveys === 0 ? ( {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> <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 }))}> <BarChart data={data.distribution.map((d: { score: number; total: number }) => ({ score: `Nota ${d.score}`, total: d.total }))}>
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<XAxis dataKey="score" tickLine={false} axisLine={false} tickMargin={8} /> <XAxis dataKey="score" tickLine={false} axisLine={false} tickMargin={8} />
<Bar dataKey="total" fill="var(--chart-3)" radius={[4, 4, 0, 0]} /> <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> </BarChart>
</ChartContainer> </ChartContainer>
)} )}
</CardContent> </CardContent>
</Card> </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> </div>
) )
} }

View file

@ -13,6 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
type ClosingTemplate = { id: string; title: string; body: string } type ClosingTemplate = { id: string; title: string; body: string }
@ -45,7 +46,7 @@ const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
body: sanitizeTemplate(` body: sanitizeTemplate(`
<p>Olá {{cliente}},</p> <p>Olá {{cliente}},</p>
<p>A equipe da ${DEFAULT_COMPANY_NAME} agradece o contato. Este ticket está sendo encerrado.</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> <p>{{agente}} · ${DEFAULT_COMPANY_NAME}</p>
`), `),
}, },
@ -67,7 +68,7 @@ const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
body: sanitizeTemplate(` body: sanitizeTemplate(`
<p>Prezado(a) {{cliente}},</p> <p>Prezado(a) {{cliente}},</p>
<p>Esse ticket está sendo encerrado pois realizamos 3 tentativas sem retorno.</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> <p>{{agente}} · ${DEFAULT_COMPANY_NAME}</p>
`), `),
}, },
@ -105,6 +106,7 @@ export function CloseTicketDialog({
ticketId, ticketId,
tenantId, tenantId,
actorId, actorId,
ticketReference,
requesterName, requesterName,
agentName, agentName,
onSuccess, onSuccess,
@ -117,6 +119,7 @@ export function CloseTicketDialog({
ticketId: string ticketId: string
tenantId: string tenantId: string
actorId: Id<"users"> | null actorId: Id<"users"> | null
ticketReference?: number | null
requesterName?: string | null requesterName?: string | null
agentName?: string | null agentName?: string | null
onSuccess: () => void onSuccess: () => void
@ -128,7 +131,7 @@ export function CloseTicketDialog({
onWorkSummaryAdjusted?: (result: AdjustWorkSummaryResult) => void onWorkSummaryAdjusted?: (result: AdjustWorkSummaryResult) => void
canAdjustTime?: boolean canAdjustTime?: boolean
}) { }) {
const updateStatus = useMutation(api.tickets.updateStatus) const resolveTicketMutation = useMutation(api.tickets.resolveTicket)
const addComment = useMutation(api.tickets.addComment) const addComment = useMutation(api.tickets.addComment)
const adjustWorkSummary = useMutation(api.tickets.adjustWorkSummary) const adjustWorkSummary = useMutation(api.tickets.adjustWorkSummary)
@ -160,6 +163,24 @@ export function CloseTicketDialog({
const [externalMinutes, setExternalMinutes] = useState<string>("0") const [externalMinutes, setExternalMinutes] = useState<string>("0")
const [adjustReason, setAdjustReason] = useState<string>("") const [adjustReason, setAdjustReason] = useState<string>("")
const enableAdjustment = Boolean(canAdjustTime && workSummary) 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 hydrateTemplateBody = useCallback((templateHtml: string) => {
const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName) const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName)
@ -269,6 +290,21 @@ export function CloseTicketDialog({
setIsSubmitting(true) setIsSubmitting(true)
toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" }) toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" })
try { 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) { if (applyAdjustment) {
const result = (await adjustWorkSummary({ const result = (await adjustWorkSummary({
ticketId: ticketId as unknown as Id<"tickets">, ticketId: ticketId as unknown as Id<"tickets">,
@ -280,7 +316,13 @@ export function CloseTicketDialog({
onWorkSummaryAdjusted?.(result) 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 withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
const sanitized = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders)) const sanitized = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
const hasContent = sanitized.replace(/<[^>]*>/g, "").trim().length > 0 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> <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>
<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> </div>
{enableAdjustment ? ( {enableAdjustment ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4"> <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__" 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({ const schema = z.object({
subject: z.string().default(""), subject: z.string().default(""),
summary: z.string().optional(), summary: z.string().optional(),
@ -158,6 +175,41 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
[companiesRemote] [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( const customersRemote = useQuery(
api.users.listCustomers, api.users.listCustomers,
directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip" directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
@ -395,6 +447,52 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
return 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) setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" }) toast.loading("Criando ticket…", { id: "new-ticket" })
try { try {
@ -413,6 +511,8 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
assigneeId: selectedAssignee ? (selectedAssignee as Id<"users">) : undefined, assigneeId: selectedAssignee ? (selectedAssignee as Id<"users">) : undefined,
categoryId: values.categoryId as Id<"ticketCategories">, categoryId: values.categoryId as Id<"ticketCategories">,
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">, subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
formTemplate: selectedFormKey !== "default" ? selectedFormKey : undefined,
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
}) })
const summaryFallback = values.summary?.trim() ?? "" const summaryFallback = values.summary?.trim() ?? ""
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
@ -446,6 +546,8 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
subcategoryId: "", subcategoryId: "",
}) })
form.clearErrors() form.clearErrors()
setSelectedFormKey("default")
setCustomFieldValues({})
setAssigneeInitialized(false) setAssigneeInitialized(false)
setAttachments([]) setAttachments([])
// Navegar para o ticket recém-criado // Navegar para o ticket recém-criado
@ -497,6 +599,28 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
</Button> </Button>
</div> </div>
</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> <FieldSet>
<FieldGroup className="lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]"> <FieldGroup className="lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div className="space-y-4"> <div className="space-y-4">
@ -811,6 +935,118 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
</Select> </Select>
</Field> </Field>
</div> </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> </div>
</FieldGroup> </FieldGroup>
</FieldSet> </FieldSet>

View file

@ -32,6 +32,7 @@ export function StatusSelect({
value, value,
tenantId, tenantId,
requesterName, requesterName,
ticketReference,
showCloseButton = true, showCloseButton = true,
onStatusChange, onStatusChange,
}: { }: {
@ -39,6 +40,7 @@ export function StatusSelect({
value: TicketStatus value: TicketStatus
tenantId: string tenantId: string
requesterName?: string | null requesterName?: string | null
ticketReference?: number | null
showCloseButton?: boolean showCloseButton?: boolean
onStatusChange?: (next: TicketStatus) => void onStatusChange?: (next: TicketStatus) => void
}) { }) {
@ -94,6 +96,7 @@ export function StatusSelect({
ticketId={ticketId} ticketId={ticketId}
tenantId={tenantId} tenantId={tenantId}
actorId={actorId} actorId={actorId}
ticketReference={ticketReference ?? null}
requesterName={requesterName} requesterName={requesterName}
agentName={agentName} agentName={agentName}
onSuccess={() => { 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 { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header"; import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketTimeline } from "@/components/tickets/ticket-timeline"; 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"; import { useAuth } from "@/lib/auth-client";
export function TicketDetailView({ id }: { id: string }) { export function TicketDetailView({ id }: { id: string }) {
@ -90,9 +92,11 @@ export function TicketDetailView({ id }: { id: string }) {
return ( return (
<div className="flex flex-col gap-6 px-4 lg:px-6"> <div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket} /> <TicketSummaryHeader ticket={ticket} />
<TicketCsatCard ticket={ticket} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]"> <div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6"> <div className="space-y-6">
<TicketComments ticket={ticket} /> <TicketComments ticket={ticket} />
<TicketChatPanel ticketId={ticket.id as string} />
<TicketTimeline ticket={ticket} /> <TicketTimeline ticket={ticket} />
</div> </div>
<TicketDetailsPanel ticket={ticket} /> <TicketDetailsPanel ticket={ticket} />

View file

@ -142,6 +142,22 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
: machineAssignedName && machineAssignedName.length > 0 : machineAssignedName && machineAssignedName.length > 0
? machineAssignedName ? machineAssignedName
: null : 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 viewerEmail = session?.user?.email ?? machineContext?.assignedUserEmail ?? null
const viewerAvatar = session?.user?.avatarUrl ?? null const viewerAvatar = session?.user?.avatarUrl ?? null
const viewerAgentMeta = useMemo( const viewerAgentMeta = useMemo(
@ -165,6 +181,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const startWork = useMutation(api.tickets.startWork) const startWork = useMutation(api.tickets.startWork)
const pauseWork = useMutation(api.tickets.pauseWork) const pauseWork = useMutation(api.tickets.pauseWork)
const updateCategories = useMutation(api.tickets.updateCategories) 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 agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queuesEnabled = Boolean(isStaff && convexUserId) const queuesEnabled = Boolean(isStaff && convexUserId)
const companiesRemote = useQuery( const companiesRemote = useQuery(
@ -227,7 +244,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
| null | null
| undefined | undefined
const [status, setStatus] = useState<TicketStatus>(ticket.status)
const [assigneeState, setAssigneeState] = useState(ticket.assignee ?? null) const [assigneeState, setAssigneeState] = useState(ticket.assignee ?? null)
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [subject, setSubject] = useState(ticket.subject) const [subject, setSubject] = useState(ticket.subject)
@ -242,6 +258,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [assigneeSelection, setAssigneeSelection] = useState(currentAssigneeId) const [assigneeSelection, setAssigneeSelection] = useState(currentAssigneeId)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [pauseDialogOpen, setPauseDialogOpen] = useState(false) const [pauseDialogOpen, setPauseDialogOpen] = useState(false)
const [isReopening, setIsReopening] = useState(false)
const [pauseReason, setPauseReason] = useState<string>(PAUSE_REASONS[0]?.value ?? "NO_CONTACT") const [pauseReason, setPauseReason] = useState<string>(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
const [pauseNote, setPauseNote] = useState("") const [pauseNote, setPauseNote] = useState("")
const [pausing, setPausing] = useState(false) const [pausing, setPausing] = useState(false)
@ -326,8 +343,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId]) const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id]) const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id])
const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty
const assigneeReasonRequired = assigneeDirty && !isManager const normalizedAssigneeReason = assigneeChangeReason.trim()
const assigneeReasonValid = !assigneeReasonRequired || assigneeChangeReason.trim().length >= 5 const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5
const saveDisabled = !formDirty || saving || !assigneeReasonValid const saveDisabled = !formDirty || saving || !assigneeReasonValid
const companyLabel = useMemo(() => { const companyLabel = useMemo(() => {
if (ticket.company?.name) return ticket.company.name if (ticket.company?.name) return ticket.company.name
@ -488,9 +505,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
throw new Error("assignee-not-allowed") throw new Error("assignee-not-allowed")
} }
const reasonValue = assigneeChangeReason.trim() const reasonValue = assigneeChangeReason.trim()
if (reasonValue.length < 5) { if (reasonValue.length > 0 && reasonValue.length < 5) {
setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres.") setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres ou deixe em branco.")
toast.error("Informe um motivo para registrar a troca do responsável.", { id: "assignee" }) toast.error("Informe ao menos 5 caracteres no motivo ou deixe o campo vazio.", { id: "assignee" })
return return
} }
if (reasonValue.length > 1000) { if (reasonValue.length > 1000) {
@ -505,7 +522,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
ticketId: ticket.id as Id<"tickets">, ticketId: ticket.id as Id<"tickets">,
assigneeId: assigneeSelection as Id<"users">, assigneeId: assigneeSelection as Id<"users">,
actorId: convexUserId as Id<"users">, actorId: convexUserId as Id<"users">,
reason: reasonValue, reason: reasonValue.length > 0 ? reasonValue : undefined,
}) })
toast.success("Responsável atualizado!", { id: "assignee" }) toast.success("Responsável atualizado!", { id: "assignee" })
if (assigneeSelection) { if (assigneeSelection) {
@ -1008,6 +1025,26 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
} }
}, [ticket.id, ticket.reference]) }, [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 ( return (
<div className={cardClass}> <div className={cardClass}>
<div className="absolute right-6 top-6 flex items-center gap-3"> <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} ticketId={ticket.id as unknown as string}
tenantId={ticket.tenantId} tenantId={ticket.tenantId}
actorId={convexUserId as Id<"users"> | null} actorId={convexUserId as Id<"users"> | null}
ticketReference={ticket.reference ?? null}
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null} requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
agentName={agentName} agentName={agentName}
workSummary={ workSummary={
@ -1095,9 +1133,26 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
value={status} value={status}
tenantId={ticket.tenantId} tenantId={ticket.tenantId}
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null} requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
ticketReference={ticket.reference ?? null}
showCloseButton={false} showCloseButton={false}
onStatusChange={setStatus} 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 ? ( {isPlaying ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -1427,8 +1482,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</p> </p>
{assigneeReasonError ? ( {assigneeReasonError ? (
<p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p> <p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p>
) : assigneeReasonRequired && assigneeChangeReason.trim().length < 5 ? ( ) : normalizedAssigneeReason.length > 0 && normalizedAssigneeReason.length < 5 ? (
<p className="text-xs font-semibold text-rose-600">Descreva o motivo com pelo menos 5 caracteres.</p> <p className="text-xs font-semibold text-rose-600">Descreva o motivo com pelo menos 5 caracteres ou deixe em branco.</p>
) : null} ) : null}
</div> </div>
) : null} ) : null}

View file

@ -351,17 +351,55 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
message = "CSAT recebido" message = "CSAT recebido"
} }
if (entry.type === "CSAT_RATED") { if (entry.type === "CSAT_RATED") {
const score = typeof payload.score === "number" ? payload.score : payload.rating const rawScoreSource = (payload as { score?: unknown; rating?: unknown }) ?? {}
const maxScore = const rawScore =
typeof payload.maxScore === "number" typeof rawScoreSource.score === "number"
? payload.maxScore ? rawScoreSource.score
: typeof payload.max === "number" : typeof rawScoreSource.rating === "number"
? payload.max ? 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 : undefined
message = const safeMax = rawMax && Number.isFinite(rawMax) && rawMax > 0 ? Math.round(rawMax) : 5
typeof score === "number" const safeScore =
? `CSAT avaliado: ${score}${typeof maxScore === "number" ? `/${maxScore}` : ""}` typeof rawScore === "number" && Number.isFinite(rawScore)
: "CSAT avaliado" ? 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 if (!message) return null

View file

@ -115,6 +115,7 @@ function ChartTooltipContent({
labelFormatter, labelFormatter,
labelClassName, labelClassName,
formatter, formatter,
valueFormatter,
color, color,
nameKey, nameKey,
labelKey, labelKey,
@ -125,6 +126,7 @@ function ChartTooltipContent({
indicator?: "line" | "dot" | "dashed" indicator?: "line" | "dot" | "dashed"
nameKey?: string nameKey?: string
labelKey?: string labelKey?: string
valueFormatter?: (value: unknown, name?: string, item?: unknown) => React.ReactNode
}) { }) {
const { config } = useChart() const { config } = useChart()
@ -234,9 +236,21 @@ function ChartTooltipContent({
{itemConfig?.label || item.name} {itemConfig?.label || item.name}
</span> </span>
</div> </div>
{item.value && ( {item.value !== undefined && item.value !== null && (
<span className="text-foreground font-mono font-medium tabular-nums"> <span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()} {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> </span>
)} )}
</div> </div>

View file

@ -103,7 +103,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
}, [session?.user]) }, [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. // 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 // 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 // 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ensureUser, session?.user?.email, session?.user?.tenantId, session?.user?.role, convexUserId]) }, [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 baseRole = session?.user?.role ? session.user.role.toLowerCase() : (machineContext ? "machine" : null)
const personaRole = session?.user?.machinePersona ? session.user.machinePersona.toLowerCase() : null const personaRole = session?.user?.machinePersona ? session.user.machinePersona.toLowerCase() : null
const normalizedRole = 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(), tags: z.array(z.string()).default([]).optional(),
lastTimelineEntry: z.string().nullable().optional(), lastTimelineEntry: z.string().nullable().optional(),
metrics: z.any().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 category: z
.object({ .object({
id: z.string(), id: z.string(),
@ -154,9 +159,17 @@ const serverTicketWithDetailsSchema = serverTicketSchema.extend({
}); });
export function mapTicketFromServer(input: unknown) { 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 = { const ui = {
...s, ...base,
status: normalizeTicketStatus(s.status), status: normalizeTicketStatus(s.status),
company: s.company company: s.company
? { id: s.company.id, name: s.company.name, isAvulso: s.company.isAvulso ?? false } ? { 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, dueAt: s.dueAt ? new Date(s.dueAt) : null,
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null, firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : 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 workSummary: s.workSummary
? { ? {
totalWorkedMs: s.workSummary.totalWorkedMs, totalWorkedMs: s.workSummary.totalWorkedMs,
@ -211,7 +229,15 @@ export function mapTicketsFromServerList(arr: unknown[]) {
} }
export function mapTicketWithDetailsFromServer(input: 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< const customFields = Object.entries(s.customFields ?? {}).reduce<
Record<string, { label: string; type: string; value?: unknown; displayValue?: string }> Record<string, { label: string; type: string; value?: unknown; displayValue?: string }>
>( >(
@ -231,47 +257,52 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
{} {}
); );
const ui = { const ui = {
...s, ...base,
customFields, customFields,
status: normalizeTicketStatus(s.status), status: normalizeTicketStatus(base.status),
category: s.category ?? undefined, category: base.category ?? undefined,
subcategory: s.subcategory ?? undefined, subcategory: base.subcategory ?? undefined,
lastTimelineEntry: s.lastTimelineEntry ?? undefined, lastTimelineEntry: base.lastTimelineEntry ?? undefined,
updatedAt: new Date(s.updatedAt), updatedAt: new Date(base.updatedAt),
createdAt: new Date(s.createdAt), createdAt: new Date(base.createdAt),
dueAt: s.dueAt ? new Date(s.dueAt) : null, dueAt: base.dueAt ? new Date(base.dueAt) : null,
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null, firstResponseAt: base.firstResponseAt ? new Date(base.firstResponseAt) : null,
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null, resolvedAt: base.resolvedAt ? new Date(base.resolvedAt) : null,
company: s.company ? { id: s.company.id, name: s.company.name, isAvulso: s.company.isAvulso ?? false } : undefined, csatScore: typeof csatScore === "number" ? csatScore : null,
machine: s.machine 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, id: base.machine.id ?? null,
hostname: s.machine.hostname ?? null, hostname: base.machine.hostname ?? null,
persona: s.machine.persona ?? null, persona: base.machine.persona ?? null,
assignedUserName: s.machine.assignedUserName ?? null, assignedUserName: base.machine.assignedUserName ?? null,
assignedUserEmail: s.machine.assignedUserEmail ?? null, assignedUserEmail: base.machine.assignedUserEmail ?? null,
status: s.machine.status ?? null, status: base.machine.status ?? null,
} }
: null, : null,
timeline: s.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })), timeline: base.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })),
comments: s.comments.map((c) => ({ comments: base.comments.map((c) => ({
...c, ...c,
createdAt: new Date(c.createdAt), createdAt: new Date(c.createdAt),
updatedAt: new Date(c.updatedAt), updatedAt: new Date(c.updatedAt),
})), })),
workSummary: s.workSummary workSummary: base.workSummary
? { ? {
totalWorkedMs: s.workSummary.totalWorkedMs, totalWorkedMs: base.workSummary.totalWorkedMs,
internalWorkedMs: s.workSummary.internalWorkedMs ?? 0, internalWorkedMs: base.workSummary.internalWorkedMs ?? 0,
externalWorkedMs: s.workSummary.externalWorkedMs ?? 0, externalWorkedMs: base.workSummary.externalWorkedMs ?? 0,
serverNow: s.workSummary.serverNow, serverNow: base.workSummary.serverNow,
activeSession: s.workSummary.activeSession activeSession: base.workSummary.activeSession
? { ? {
...s.workSummary.activeSession, ...base.workSummary.activeSession,
startedAt: new Date(s.workSummary.activeSession.startedAt), startedAt: new Date(base.workSummary.activeSession.startedAt),
} }
: null, : null,
perAgentTotals: (s.workSummary.perAgentTotals ?? []).map((item) => ({ perAgentTotals: (base.workSummary.perAgentTotals ?? []).map((item) => ({
agentId: item.agentId, agentId: item.agentId,
agentName: item.agentName ?? null, agentName: item.agentName ?? null,
agentEmail: item.agentEmail ?? null, agentEmail: item.agentEmail ?? null,

View file

@ -150,6 +150,19 @@ export const ticketSchema = z.object({
timeOpenedMinutes: z.number().nullable(), timeOpenedMinutes: z.number().nullable(),
}) })
.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(), category: ticketCategorySummarySchema.nullable().optional(),
subcategory: ticketSubcategorySummarySchema.nullable().optional(), subcategory: ticketSubcategorySummarySchema.nullable().optional(),
workSummary: z workSummary: z

View file

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

View file

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

View file

@ -1,5 +1,15 @@
import { buildXlsxWorkbook, type WorksheetConfig } from "@/lib/xlsx"
import type { Id } from "@/convex/_generated/dataModel" 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 = { type LinkedUser = {
id: string id: string
@ -11,6 +21,11 @@ export type MachineInventoryRecord = {
id: Id<"machines"> id: Id<"machines">
tenantId: string tenantId: string
hostname: string hostname: string
displayName: string | null
deviceType: string | null
devicePlatform: string | null
deviceProfile?: Record<string, unknown> | null
managementMode: string | null
companyId: Id<"companies"> | null companyId: Id<"companies"> | null
companySlug: string | null companySlug: string | null
companyName: string | null companyName: string | null
@ -36,6 +51,7 @@ export type MachineInventoryRecord = {
lastPostureAt?: number | null lastPostureAt?: number | null
remoteAccess?: unknown remoteAccess?: unknown
linkedUsers?: LinkedUser[] linkedUsers?: LinkedUser[]
customFields?: DeviceCustomField[]
} }
type WorkbookOptions = { type WorkbookOptions = {
@ -43,6 +59,7 @@ type WorkbookOptions = {
generatedBy?: string | null generatedBy?: string | null
companyFilterLabel?: string | null companyFilterLabel?: string | null
generatedAt?: Date generatedAt?: Date
columns?: DeviceInventoryColumnConfig[]
} }
type SoftwareEntry = { type SoftwareEntry = {
@ -54,59 +71,270 @@ type SoftwareEntry = {
installedOn: string | null installedOn: string | null
} }
const INVENTORY_HEADERS = [ type InventoryColumnDefinition = {
"Hostname", key: string
"Empresa", label: string
"Status", width: number
"Persona", getValue: (machine: MachineInventoryRecord, derived: MachineDerivedData) => unknown
"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
const INVENTORY_COLUMN_WIDTHS = [ type MachineDerivedData = {
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, inventory: Record<string, unknown>
18, 18, 20, 20, 20, 26, 24, 24, 26, 16, 18, 18, 20, hardware: ReturnType<typeof extractHardware>
] as const 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_HEADERS = ["Hostname", "Aplicativo", "Versão", "Origem", "Publicador", "Instalado em"] as const
const SOFTWARE_COLUMN_WIDTHS = [22, 36, 18, 18, 22, 20] 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> = { const PERSONA_LABELS: Record<string, string> = {
collaborator: "Colaborador", collaborator: "Colaborador",
manager: "Gestor", manager: "Gestor",
machine: "Máquina", machine: "Dispositivo",
} }
const SUMMARY_STATUS_ORDER = ["Online", "Sem sinal", "Offline", "Manutenção", "Bloqueada", "Desativada", "Desconhecido"] const SUMMARY_STATUS_ORDER = ["Online", "Sem sinal", "Offline", "Manutenção", "Bloqueada", "Desativada", "Desconhecido"]
@ -186,7 +414,14 @@ export function buildMachinesInventoryWorkbook(
): Buffer { ): Buffer {
const generatedAt = options.generatedAt ?? new Date() const generatedAt = options.generatedAt ?? new Date()
const summaryRows = buildSummaryRows(machines, options, generatedAt) 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 linksRows = buildLinkedUsersRows(machines)
const softwareRows = buildSoftwareRows(machines) const softwareRows = buildSoftwareRows(machines)
const partitionRows = buildPartitionRows(machines) const partitionRows = buildPartitionRows(machines)
@ -210,9 +445,9 @@ export function buildMachinesInventoryWorkbook(
sheets.push({ sheets.push({
name: "Inventário", name: "Inventário",
headers: [...INVENTORY_HEADERS], headers,
rows: inventoryRows, rows: inventoryRows,
columnWidths: [...INVENTORY_COLUMN_WIDTHS], columnWidths,
freezePane: { rowSplit: 1 }, freezePane: { rowSplit: 1 },
autoFilter: true, autoFilter: true,
}) })
@ -353,11 +588,28 @@ function buildSummaryRows(
rows.push(["Filtro de empresa", options.companyFilterLabel]) 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 const activeCount = machines.filter((machine) => machine.isActive).length
rows.push(["Máquinas ativas", activeCount]) rows.push(["Dispositivos ativos", activeCount])
rows.push(["Máquinas inativas", machines.length - 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>() const statusCounts = new Map<string, number>()
machines.forEach((machine) => { machines.forEach((machine) => {
@ -396,70 +648,6 @@ function buildSummaryRows(
return rows 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]> { function buildLinkedUsersRows(machines: MachineInventoryRecord[]): Array<[string, string | null, string | null, string | null]> {
const rows: Array<[string, string | null, string | null, string | null]> = [] const rows: Array<[string, string | null, string | null, string | null]> = []
machines.forEach((machine) => { machines.forEach((machine) => {

View file

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

View file

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