feat: dispositivos e ajustes de csat e relatórios
This commit is contained in:
parent
25d2a9b062
commit
e0ef66555d
86 changed files with 5811 additions and 992 deletions
10
README.md
10
README.md
|
|
@ -68,13 +68,13 @@ Para fluxos detalhados de desenvolvimento — banco de dados local (SQLite/Prism
|
|||
- `pnpm prisma migrate deploy` — aplica migrações ao banco SQLite local.
|
||||
- `pnpm convex:dev` — roda o Convex em modo desenvolvimento, gerando tipos em `convex/_generated`.
|
||||
|
||||
## Transferir máquina entre colaboradores
|
||||
## Transferir dispositivo entre colaboradores
|
||||
|
||||
Quando uma máquina trocar de responsável:
|
||||
Quando uma dispositivo trocar de responsável:
|
||||
|
||||
1. Abra `Admin > Máquinas`, selecione o equipamento e clique em **Resetar agente**.
|
||||
1. Abra `Admin > Dispositivos`, selecione o equipamento e clique em **Resetar agente**.
|
||||
2. No equipamento, execute o reset local do agente (`rever-agent reset` ou reinstale o serviço) e reprovisione com o código da empresa.
|
||||
3. Após o agente gerar um novo token, associe a máquina ao novo colaborador no painel.
|
||||
3. Após o agente gerar um novo token, associe a dispositivo ao novo colaborador no painel.
|
||||
|
||||
Sem o reset de agente, o Convex reaproveita o token anterior e o inventário continua vinculado ao usuário antigo.
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ Consulte `PROXIMOS_PASSOS.md` para acompanhar o backlog funcional e o progresso
|
|||
|
||||
<!-- ci: smoke test 3 -->
|
||||
|
||||
## Diagnóstico de sessão da máquina (Desktop)
|
||||
## Diagnóstico de sessão da dispositivo (Desktop)
|
||||
|
||||
- Quando o portal for aberto via app desktop, use a página `https://seu-app/portal/debug` para validar cookies e contexto:
|
||||
- `/api/auth/get-session` deve idealmente mostrar `user.role = "machine"` (em alguns ambientes WebView pode retornar `null`, o que não é bloqueante).
|
||||
|
|
|
|||
16
agents.md
16
agents.md
|
|
@ -67,19 +67,19 @@ pnpm build
|
|||
- `pnpm -C apps/desktop tauri dev` — desenvolvimento (porta 1420).
|
||||
- `pnpm -C apps/desktop tauri build` — gera instaladores.
|
||||
- **Fluxo do agente**:
|
||||
1. Coleta perfil da máquina (hostname, OS, MAC, seriais, métricas).
|
||||
1. Coleta perfil da dispositivo (hostname, OS, MAC, seriais, métricas).
|
||||
2. Provisiona via `POST /api/machines/register` usando `MACHINE_PROVISIONING_SECRET`, informando perfil de acesso (Colaborador/Gestor) + dados do colaborador.
|
||||
3. Envia heartbeats periódicos (`/api/machines/heartbeat`) com inventário básico + estendido (discos SMART, GPUs, serviços, softwares, CPU window).
|
||||
4. Realiza handshake em `APP_URL/machines/handshake?token=...&redirect=...` para receber cookies Better Auth + sessão (colaborador → `/portal`, gestor → `/dashboard`).
|
||||
5. Token persistido no cofre do SO (Keyring); store guarda apenas metadados.
|
||||
6. Envio manual de inventário via botão (POST `/api/machines/inventory`).
|
||||
7. Updates automáticos: plugin `@tauri-apps/plugin-updater` consulta `latest.json` publicado nos releases do GitHub.
|
||||
- **Admin ▸ Máquinas**: permite ajustar perfil/email associado, visualizar inventário completo e remover máquina.
|
||||
- **Admin ▸ Dispositivos**: permite ajustar perfil/email associado, visualizar inventário completo e remover dispositivo.
|
||||
|
||||
### Sessão "machine" no frontend
|
||||
- Ao autenticar como máquina, o front chama `/api/machines/session`, popula `machineContext` (assignedUser*, persona) e deriva role/`viewerId`.
|
||||
- Ao autenticar como dispositivo, o front chama `/api/machines/session`, popula `machineContext` (assignedUser*, persona) e deriva role/`viewerId`.
|
||||
- Mesmo quando `get-session` é `null` na WebView, o portal utiliza `machineContext` para saber o colaborador/gestor logado.
|
||||
- UI remove opção "Sair" no menu do usuário quando detecta sessão de máquina.
|
||||
- UI remove opção "Sair" no menu do usuário quando detecta sessão de dispositivo.
|
||||
- `/portal/debug` exibe JSON de `get-session` e `machines/session` (útil para diagnosticar cookies/bearer).
|
||||
|
||||
### Observações adicionais
|
||||
|
|
@ -116,7 +116,7 @@ pnpm build
|
|||
|
||||
## Estado do portal / app web
|
||||
- Autenticação Better Auth com `AuthGuard`.
|
||||
- Sidebar inferior agrega avatar, link para `/settings` e logout (oculto em sessões de máquina).
|
||||
- Sidebar inferior agrega avatar, link para `/settings` e logout (oculto em sessões de dispositivo).
|
||||
- Formulários de ticket (novo/editar/comentários) usam editor rico + anexos; placeholders e validação PT-BR.
|
||||
- Relatórios e painéis utilizam `AppShell` + `SiteHeader`.
|
||||
- `usePersistentCompanyFilter` mantém filtro global de empresa em relatórios/admin.
|
||||
|
|
@ -126,7 +126,7 @@ pnpm build
|
|||
- Admin > Empresas: cadastro + “Cliente avulso?”, horas contratadas, vínculos de usuários.
|
||||
- Admin > Usuários/Equipe:
|
||||
- Abas separadas: "Equipe" (administradores e agentes) e "Usuários" (gestores e colaboradores).
|
||||
- Multi‑seleção + ações em massa: excluir usuários, remover agentes de máquina e revogar convites pendentes.
|
||||
- Multi‑seleção + ações em massa: excluir usuários, remover agentes de dispositivo e revogar convites pendentes.
|
||||
- Filtros por papel, empresa e espaço (tenant) quando aplicável; busca unificada.
|
||||
- Convites: campo "Espaço (ID interno)" removido da UI de geração.
|
||||
- Admin > Usuários: vincular colaborador à empresa.
|
||||
|
|
@ -137,7 +137,7 @@ pnpm build
|
|||
- **Equipe interna** (`admin`, `agent`, `collaborator`): cria/acompanha tickets, comenta, altera status/fila, gera relatórios.
|
||||
- **Gestores** (`manager`): visualizam tickets da empresa, comentam publicamente, acessam dashboards.
|
||||
- **Colaboradores** (`collaborator`): portal (`/portal`), tickets próprios, comentários públicos, editor rico, anexos.
|
||||
- **Sessão Máquina**: desktop registra heartbeat/inventário e redireciona colaborador/gestor ao portal apropriado com cookies válidos.
|
||||
- **Sessão Dispositivo**: desktop registra heartbeat/inventário e redireciona colaborador/gestor ao portal apropriado com cookies válidos.
|
||||
|
||||
### Correções recentes
|
||||
- Temporizador do ticket (atendimento em andamento): a UI passa a aplicar atualização otimista na abertura/pausa da sessão para que o tempo corrente não "salte" para minutos indevidos. O back‑end continua a fonte da verdade (total acumulado é reconciliado ao pausar).
|
||||
|
|
@ -160,7 +160,7 @@ pnpm build
|
|||
- CSAT: `/api/reports/csat.xlsx?...`
|
||||
- SLA: `/api/reports/sla.xlsx?...`
|
||||
- Horas: `/api/reports/hours-by-client.xlsx?...`
|
||||
- Inventário de máquinas: `/api/reports/machines-inventory.xlsx?[companyId=...]`
|
||||
- Inventário de dispositivos: `/api/reports/machines-inventory.xlsx?[companyId=...]`
|
||||
- **Docs complementares**:
|
||||
- `docs/DEV.md` — guia diário atualizado.
|
||||
- `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# Sistema de Chamados — App Desktop (Tauri)
|
||||
|
||||
Cliente desktop (Tauri v2 + Vite) que:
|
||||
- Coleta perfil/métricas da máquina via comandos Rust.
|
||||
- Registra a máquina com um código de provisionamento.
|
||||
- Coleta perfil/métricas da dispositivo via comandos Rust.
|
||||
- Registra a dispositivo com um código de provisionamento.
|
||||
- Envia heartbeat periódico ao backend (`/api/machines/heartbeat`).
|
||||
- Redireciona para a UI web do sistema após provisionamento.
|
||||
- Armazena o token da máquina com segurança no cofre do SO (Keyring).
|
||||
- Armazena o token da dispositivo com segurança no cofre do SO (Keyring).
|
||||
- Exibe abas de Resumo, Inventário, Diagnóstico e Configurações; permite “Enviar inventário agora”.
|
||||
|
||||
## URLs e ambiente
|
||||
|
|
@ -65,7 +65,7 @@ pnpm -C apps/desktop tauri build --bundles nsis
|
|||
Consulte https://tauri.app/start/prerequisites/
|
||||
|
||||
## Fluxo (resumo)
|
||||
1) Ao abrir, o app coleta o perfil da máquina e exibe um resumo.
|
||||
1) Ao abrir, o app coleta o perfil da dispositivo e exibe um resumo.
|
||||
2) Informe o “código de provisionamento” (chave definida no servidor) e confirme.
|
||||
3) O servidor retorna um `machineToken`; o app salva e inicia o heartbeat.
|
||||
4) O app abre `APP_URL/machines/handshake?token=...` no WebView para autenticar a sessão na UI.
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@
|
|||
|
||||
## 5. Gerar chaves do updater Tauri
|
||||
|
||||
1. Em qualquer máquina com Node/pnpm (pode ser seu computador local):
|
||||
1. Em qualquer dispositivo com Node/pnpm (pode ser seu computador local):
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter appsdesktop tauri signer generate
|
||||
|
|
@ -267,10 +267,10 @@
|
|||
.\svc start
|
||||
```
|
||||
6. Confirme no GitHub que o runner aparece como `online`.
|
||||
7. Mantenha a máquina ligada e conectada durante o período em que o workflow precisa rodar:
|
||||
7. Mantenha a dispositivo ligada e conectada durante o período em que o workflow precisa rodar:
|
||||
- Para releases desktop, o runner só precisa estar ligado enquanto o job `desktop_release` estiver em execução (crie a tag e aguarde o workflow terminar).
|
||||
- Após a conclusão, você pode desligar o computador até a próxima release.
|
||||
8. Observação importante: o runner Windows pode ser sua máquina pessoal. Garanta apenas que:
|
||||
8. Observação importante: o runner Windows pode ser sua dispositivo pessoal. Garanta apenas que:
|
||||
- Você confia no código que será executado (o runner processa os jobs do repositório).
|
||||
- O serviço do runner esteja ativo enquanto o workflow rodar (caso desligue o PC, as releases ficam na fila).
|
||||
- Há espaço em disco suficiente e nenhuma política corporativa bloqueando a instalação dos pré-requisitos.
|
||||
|
|
@ -429,7 +429,7 @@
|
|||
- Garanta que o certificado TLS usado pelo Nginx é renovado (p. ex. `certbot renew`).
|
||||
4. Manter runners:
|
||||
- VPS: monitore serviço `actions.runner.*`. Reinicie se necessário (`sudo ./svc.sh restart`).
|
||||
- Windows: mantenha máquina ligada e atualizada. Se o serviço parar, abra `services.msc` → `GitHub Actions Runner` → Start.
|
||||
- Windows: mantenha dispositivo ligada e atualizada. Se o serviço parar, abra `services.msc` → `GitHub Actions Runner` → Start.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -451,7 +451,7 @@
|
|||
| Job `desktop_release` falha na etapa `tauri-action` | Toolchain incompleto no Windows | Reinstale Rust, WebView2 e componentes C++ do Visual Studio. |
|
||||
| Artefatos não chegam à VPS | Caminho incorreto ou chave SSH inválida | Verifique `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY` e se a pasta `/var/www/updates` existe. |
|
||||
| App não encontra update | URL ou chave pública divergente no `tauri.conf.json` | Confirme que `endpoints` bate com o domínio HTTPS e que `pubkey` é exatamente a chave pública gerada. |
|
||||
| Runner aparece offline no GitHub | Serviço parado ou máquina desligada | VPS: `sudo ./svc.sh status`; Windows: abra `Services` e reinicie o `GitHub Actions Runner`. |
|
||||
| Runner aparece offline no GitHub | Serviço parado ou dispositivo desligada | VPS: `sudo ./svc.sh status`; Windows: abra `Services` e reinicie o `GitHub Actions Runner`. |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use tokio::sync::Notify;
|
|||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum AgentError {
|
||||
#[error("Falha ao obter hostname da máquina")]
|
||||
#[error("Falha ao obter hostname da dispositivo")]
|
||||
Hostname,
|
||||
#[error("Nenhum identificador de hardware disponível (MAC/serial)")]
|
||||
MissingIdentifiers,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ export function DeactivationScreen({ companyName }: { companyName?: string | nul
|
|||
<span className="inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold text-rose-700">
|
||||
<ShieldAlert className="size-4" /> Acesso bloqueado
|
||||
</span>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Máquina desativada</h1>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Dispositivo desativada</h1>
|
||||
<p className="max-w-md text-sm text-neutral-600">
|
||||
Esta máquina foi desativada temporariamente pelos administradores. Enquanto isso, o acesso ao portal e o
|
||||
Esta dispositivo foi desativada temporariamente pelos administradores. Enquanto isso, o acesso ao portal e o
|
||||
envio de informações ficam indisponíveis.
|
||||
</p>
|
||||
{companyName ? (
|
||||
|
|
|
|||
|
|
@ -273,9 +273,9 @@ function App() {
|
|||
const text = await res.text()
|
||||
const msg = text.toLowerCase()
|
||||
const isInvalid =
|
||||
msg.includes("token de máquina inválido") ||
|
||||
msg.includes("token de máquina revogado") ||
|
||||
msg.includes("token de máquina expirado")
|
||||
msg.includes("token de dispositivo inválido") ||
|
||||
msg.includes("token de dispositivo revogado") ||
|
||||
msg.includes("token de dispositivo expirado")
|
||||
if (isInvalid) {
|
||||
try {
|
||||
await store.delete("token"); await store.delete("config"); await store.save()
|
||||
|
|
@ -293,7 +293,7 @@ function App() {
|
|||
} catch {}
|
||||
} else {
|
||||
// Não limpa token em falhas genéricas (ex.: rede); apenas informa
|
||||
setError("Falha ao validar sessão da máquina. Tente novamente.")
|
||||
setError("Falha ao validar sessão da dispositivo. Tente novamente.")
|
||||
tokenVerifiedRef.current = true
|
||||
setTokenValidationTick((tick) => tick + 1)
|
||||
}
|
||||
|
|
@ -443,12 +443,12 @@ function App() {
|
|||
return
|
||||
}
|
||||
if (!validatedCompany) {
|
||||
setError("Valide o código de provisionamento antes de registrar a máquina.")
|
||||
setError("Valide o código de provisionamento antes de registrar a dispositivo.")
|
||||
return
|
||||
}
|
||||
const normalizedEmail = collabEmail.trim().toLowerCase()
|
||||
if (!normalizedEmail) {
|
||||
setError("Informe o e-mail do colaborador vinculado a esta máquina.")
|
||||
setError("Informe o e-mail do colaborador vinculado a esta dispositivo.")
|
||||
return
|
||||
}
|
||||
if (!emailRegex.current.test(normalizedEmail)) {
|
||||
|
|
@ -575,7 +575,7 @@ function App() {
|
|||
setError(null)
|
||||
}
|
||||
if (!currentActive) {
|
||||
setError("Esta máquina está desativada. Entre em contato com o suporte da Rever para reativar o acesso.")
|
||||
setError("Esta dispositivo está desativada. Entre em contato com o suporte da Rever para reativar o acesso.")
|
||||
setIsLaunchingSystem(false)
|
||||
return
|
||||
}
|
||||
|
|
@ -590,7 +590,7 @@ function App() {
|
|||
: ""
|
||||
setIsMachineActive(false)
|
||||
setIsLaunchingSystem(false)
|
||||
setError(message.length > 0 ? message : "Esta máquina está desativada. Entre em contato com o suporte da Rever.")
|
||||
setError(message.length > 0 ? message : "Esta dispositivo está desativada. Entre em contato com o suporte da Rever.")
|
||||
return
|
||||
}
|
||||
// Se sessão falhar, tenta identificar token inválido/expirado
|
||||
|
|
@ -604,9 +604,9 @@ function App() {
|
|||
const text = await hb.text()
|
||||
const low = text.toLowerCase()
|
||||
const invalid =
|
||||
low.includes("token de máquina inválido") ||
|
||||
low.includes("token de máquina revogado") ||
|
||||
low.includes("token de máquina expirado")
|
||||
low.includes("token de dispositivo inválido") ||
|
||||
low.includes("token de dispositivo revogado") ||
|
||||
low.includes("token de dispositivo expirado")
|
||||
if (invalid) {
|
||||
// Força onboarding
|
||||
await store?.delete("token"); await store?.delete("config"); await store?.save()
|
||||
|
|
@ -616,7 +616,7 @@ function App() {
|
|||
setConfig(null)
|
||||
setStatus(null)
|
||||
setIsMachineActive(true)
|
||||
setError("Sessão expirada. Reprovisione a máquina para continuar.")
|
||||
setError("Sessão expirada. Reprovisione a dispositivo para continuar.")
|
||||
setIsLaunchingSystem(false)
|
||||
const p = await invoke<MachineProfile>("collect_machine_profile")
|
||||
setProfile(p)
|
||||
|
|
@ -787,7 +787,7 @@ function App() {
|
|||
{error ? <p className="mt-3 rounded-md bg-rose-50 p-2 text-sm text-rose-700">{error}</p> : null}
|
||||
{!token ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-sm text-slate-600">Informe os dados para registrar esta máquina.</p>
|
||||
<p className="text-sm text-slate-600">Informe os dados para registrar esta dispositivo.</p>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Código de provisionamento</label>
|
||||
<div className="relative">
|
||||
|
|
@ -822,7 +822,7 @@ function App() {
|
|||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">
|
||||
Informe o código único fornecido pela equipe para vincular esta máquina a uma empresa.
|
||||
Informe o código único fornecido pela equipe para vincular esta dispositivo a uma empresa.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -832,7 +832,7 @@ function App() {
|
|||
<div className="space-y-1">
|
||||
<span className="block text-sm font-semibold text-emerald-800">{validatedCompany.name}</span>
|
||||
<span className="text-xs text-emerald-700/80">
|
||||
Código reconhecido. Esta máquina será vinculada automaticamente à empresa informada.
|
||||
Código reconhecido. Esta dispositivo será vinculada automaticamente à empresa informada.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -884,7 +884,7 @@ function App() {
|
|||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button disabled={busy || !validatedCompany || !isEmailValid || !collabName.trim() || provisioningCode.trim().length < 32} onClick={register} className="rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white hover:bg-black/90 disabled:opacity-60">Registrar máquina</button>
|
||||
<button disabled={busy || !validatedCompany || !isEmailValid || !collabName.trim() || provisioningCode.trim().length < 32} onClick={register} className="rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white hover:bg-black/90 disabled:opacity-60">Registrar dispositivo</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
8
convex/_generated/api.d.ts
vendored
8
convex/_generated/api.d.ts
vendored
|
|
@ -15,6 +15,9 @@ import type * as categories from "../categories.js";
|
|||
import type * as commentTemplates from "../commentTemplates.js";
|
||||
import type * as companies from "../companies.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as deviceExportTemplates from "../deviceExportTemplates.js";
|
||||
import type * as deviceFields from "../deviceFields.js";
|
||||
import type * as devices from "../devices.js";
|
||||
import type * as fields from "../fields.js";
|
||||
import type * as files from "../files.js";
|
||||
import type * as invites from "../invites.js";
|
||||
|
|
@ -27,6 +30,7 @@ import type * as revision from "../revision.js";
|
|||
import type * as seed from "../seed.js";
|
||||
import type * as slas from "../slas.js";
|
||||
import type * as teams from "../teams.js";
|
||||
import type * as ticketFormSettings from "../ticketFormSettings.js";
|
||||
import type * as tickets from "../tickets.js";
|
||||
import type * as users from "../users.js";
|
||||
|
||||
|
|
@ -52,6 +56,9 @@ declare const fullApi: ApiFromModules<{
|
|||
commentTemplates: typeof commentTemplates;
|
||||
companies: typeof companies;
|
||||
crons: typeof crons;
|
||||
deviceExportTemplates: typeof deviceExportTemplates;
|
||||
deviceFields: typeof deviceFields;
|
||||
devices: typeof devices;
|
||||
fields: typeof fields;
|
||||
files: typeof files;
|
||||
invites: typeof invites;
|
||||
|
|
@ -64,6 +71,7 @@ declare const fullApi: ApiFromModules<{
|
|||
seed: typeof seed;
|
||||
slas: typeof slas;
|
||||
teams: typeof teams;
|
||||
ticketFormSettings: typeof ticketFormSettings;
|
||||
tickets: typeof tickets;
|
||||
users: typeof users;
|
||||
}>;
|
||||
|
|
|
|||
347
convex/deviceExportTemplates.ts
Normal file
347
convex/deviceExportTemplates.ts
Normal 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
271
convex/deviceFields.ts
Normal 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
1
convex/devices.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./machines"
|
||||
|
|
@ -38,8 +38,8 @@ function validateOptions(type: FieldType, options: { value: string; label: strin
|
|||
}
|
||||
|
||||
export const list = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) },
|
||||
handler: async (ctx, { tenantId, viewerId, scope }) => {
|
||||
await requireAdmin(ctx, viewerId, tenantId);
|
||||
const fields = await ctx.db
|
||||
.query("ticketFields")
|
||||
|
|
@ -47,6 +47,12 @@ export const list = query({
|
|||
.collect();
|
||||
|
||||
return fields
|
||||
.filter((field) => {
|
||||
if (!scope) return true;
|
||||
const fieldScope = (field.scope ?? "all").trim();
|
||||
if (fieldScope === "all" || fieldScope.length === 0) return true;
|
||||
return fieldScope === scope;
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((field) => ({
|
||||
id: field._id,
|
||||
|
|
@ -57,6 +63,7 @@ export const list = query({
|
|||
required: field.required,
|
||||
options: field.options ?? [],
|
||||
order: field.order,
|
||||
scope: field.scope ?? "all",
|
||||
createdAt: field.createdAt,
|
||||
updatedAt: field.updatedAt,
|
||||
}));
|
||||
|
|
@ -64,8 +71,8 @@ export const list = query({
|
|||
});
|
||||
|
||||
export const listForTenant = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) },
|
||||
handler: async (ctx, { tenantId, viewerId, scope }) => {
|
||||
await requireUser(ctx, viewerId, tenantId);
|
||||
const fields = await ctx.db
|
||||
.query("ticketFields")
|
||||
|
|
@ -73,6 +80,12 @@ export const listForTenant = query({
|
|||
.collect();
|
||||
|
||||
return fields
|
||||
.filter((field) => {
|
||||
if (!scope) return true;
|
||||
const fieldScope = (field.scope ?? "all").trim();
|
||||
if (fieldScope === "all" || fieldScope.length === 0) return true;
|
||||
return fieldScope === scope;
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((field) => ({
|
||||
id: field._id,
|
||||
|
|
@ -83,6 +96,7 @@ export const listForTenant = query({
|
|||
required: field.required,
|
||||
options: field.options ?? [],
|
||||
order: field.order,
|
||||
scope: field.scope ?? "all",
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
|
@ -103,8 +117,9 @@ export const create = mutation({
|
|||
})
|
||||
)
|
||||
),
|
||||
scope: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, label, description, type, required, options }) => {
|
||||
handler: async (ctx, { tenantId, actorId, label, description, type, required, options, scope }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const normalizedLabel = label.trim();
|
||||
if (normalizedLabel.length < 2) {
|
||||
|
|
@ -116,6 +131,15 @@ export const create = mutation({
|
|||
validateOptions(type as FieldType, options ?? undefined);
|
||||
const key = normalizeKey(normalizedLabel);
|
||||
await ensureUniqueKey(ctx, tenantId, key);
|
||||
const normalizedScope = (() => {
|
||||
const raw = scope?.trim();
|
||||
if (!raw || raw.length === 0) return "all";
|
||||
const safe = raw.toLowerCase();
|
||||
if (!/^[a-z0-9_\-]+$/i.test(safe)) {
|
||||
throw new ConvexError("Escopo inválido para o campo");
|
||||
}
|
||||
return safe;
|
||||
})();
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("ticketFields")
|
||||
|
|
@ -133,6 +157,7 @@ export const create = mutation({
|
|||
required,
|
||||
options,
|
||||
order: maxOrder + 1,
|
||||
scope: normalizedScope,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
|
@ -157,8 +182,9 @@ export const update = mutation({
|
|||
})
|
||||
)
|
||||
),
|
||||
scope: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options }) => {
|
||||
handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options, scope }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const field = await ctx.db.get(fieldId);
|
||||
if (!field || field.tenantId !== tenantId) {
|
||||
|
|
@ -173,6 +199,16 @@ export const update = mutation({
|
|||
}
|
||||
validateOptions(type as FieldType, options ?? undefined);
|
||||
|
||||
const normalizedScope = (() => {
|
||||
const raw = scope?.trim();
|
||||
if (!raw || raw.length === 0) return "all";
|
||||
const safe = raw.toLowerCase();
|
||||
if (!/^[a-z0-9_\-]+$/i.test(safe)) {
|
||||
throw new ConvexError("Escopo inválido para o campo");
|
||||
}
|
||||
return safe;
|
||||
})();
|
||||
|
||||
let key = field.key;
|
||||
if (field.label !== normalizedLabel) {
|
||||
key = normalizeKey(normalizedLabel);
|
||||
|
|
@ -186,6 +222,7 @@ export const update = mutation({
|
|||
type,
|
||||
required,
|
||||
options,
|
||||
scope: normalizedScope,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { randomBytes } from "@noble/hashes/utils"
|
|||
import type { Doc, Id } from "./_generated/dataModel"
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||
import { normalizeStatus } from "./tickets"
|
||||
import { requireAdmin } from "./rbac"
|
||||
|
||||
const DEFAULT_TENANT_ID = "tenant-atlas"
|
||||
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
|
||||
|
|
@ -65,6 +66,14 @@ function normalizeIdentifiers(macAddresses: string[], serialNumbers: string[]):
|
|||
return { macs, serials }
|
||||
}
|
||||
|
||||
function normalizeOptionalIdentifiers(macAddresses?: string[] | null, serialNumbers?: string[] | null): NormalizedIdentifiers {
|
||||
const normalizeMac = (value: string) => value.replace(/[^a-f0-9]/gi, "").toLowerCase()
|
||||
const normalizeSerial = (value: string) => value.trim().toLowerCase()
|
||||
const macs = Array.from(new Set((macAddresses ?? []).map(normalizeMac).filter(Boolean))).sort()
|
||||
const serials = Array.from(new Set((serialNumbers ?? []).map(normalizeSerial).filter(Boolean))).sort()
|
||||
return { macs, serials }
|
||||
}
|
||||
|
||||
async function findActiveMachineToken(ctx: QueryCtx, machineId: Id<"machines">, now: number) {
|
||||
const tokens = await ctx.db
|
||||
.query("machineTokens")
|
||||
|
|
@ -93,6 +102,51 @@ function computeFingerprint(tenantId: string, companySlug: string | undefined, h
|
|||
return toHex(sha256(payload))
|
||||
}
|
||||
|
||||
function generateManualFingerprint(tenantId: string, displayName: string) {
|
||||
const payload = JSON.stringify({
|
||||
tenantId,
|
||||
displayName: displayName.trim().toLowerCase(),
|
||||
nonce: toHex(randomBytes(16)),
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
return toHex(sha256(payload))
|
||||
}
|
||||
|
||||
function formatDeviceCustomFieldDisplay(
|
||||
type: string,
|
||||
value: unknown,
|
||||
options?: Array<{ value: string; label: string }>
|
||||
): string | null {
|
||||
if (value === null || value === undefined) return null
|
||||
switch (type) {
|
||||
case "text":
|
||||
return String(value).trim()
|
||||
case "number": {
|
||||
const num = typeof value === "number" ? value : Number(value)
|
||||
if (!Number.isFinite(num)) return null
|
||||
return String(num)
|
||||
}
|
||||
case "boolean":
|
||||
return value ? "Sim" : "Não"
|
||||
case "date": {
|
||||
const date = value instanceof Date ? value : new Date(String(value))
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
case "select": {
|
||||
const raw = String(value)
|
||||
const option = options?.find((opt) => opt.value === raw || opt.label === raw)
|
||||
return option?.label ?? raw
|
||||
}
|
||||
default:
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractCollaboratorEmail(metadata: unknown): string | null {
|
||||
if (!metadata || typeof metadata !== "object") return null
|
||||
const record = metadata as Record<string, unknown>
|
||||
|
|
@ -126,18 +180,18 @@ async function getActiveToken(
|
|||
.unique()
|
||||
|
||||
if (!token) {
|
||||
throw new ConvexError("Token de máquina inválido")
|
||||
throw new ConvexError("Token de dispositivo inválido")
|
||||
}
|
||||
if (token.revoked) {
|
||||
throw new ConvexError("Token de máquina revogado")
|
||||
throw new ConvexError("Token de dispositivo revogado")
|
||||
}
|
||||
if (token.expiresAt < Date.now()) {
|
||||
throw new ConvexError("Token de máquina expirado")
|
||||
throw new ConvexError("Token de dispositivo expirado")
|
||||
}
|
||||
|
||||
const machine = await ctx.db.get(token.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada para o token fornecido")
|
||||
throw new ConvexError("Dispositivo não encontrada para o token fornecido")
|
||||
}
|
||||
|
||||
return { token, machine }
|
||||
|
|
@ -381,7 +435,7 @@ async function evaluatePostureAndMaybeRaise(
|
|||
if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "false").toLowerCase() !== "true") return
|
||||
if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return
|
||||
|
||||
const subject = `Alerta de máquina: ${machine.hostname}`
|
||||
const subject = `Alerta de dispositivo: ${machine.hostname}`
|
||||
const summary = findings.map((f) => `${f.severity.toUpperCase()}: ${f.message}`).join(" | ")
|
||||
await createTicketForAlert(ctx, machine.tenantId, machine.companyId, subject, summary)
|
||||
}
|
||||
|
|
@ -445,6 +499,7 @@ export const register = mutation({
|
|||
companyId: companyId ?? existing.companyId,
|
||||
companySlug: companySlug ?? existing.companySlug,
|
||||
hostname: args.hostname,
|
||||
displayName: existing.displayName ?? args.hostname,
|
||||
osName: args.os.name,
|
||||
osVersion: args.os.version,
|
||||
architecture: args.os.architecture,
|
||||
|
|
@ -457,6 +512,9 @@ export const register = mutation({
|
|||
status: "online",
|
||||
isActive: true,
|
||||
registeredBy: args.registeredBy ?? existing.registeredBy,
|
||||
deviceType: existing.deviceType ?? "desktop",
|
||||
devicePlatform: args.os.name ?? existing.devicePlatform,
|
||||
managementMode: existing.managementMode ?? "agent",
|
||||
persona: existing.persona,
|
||||
assignedUserId: existing.assignedUserId,
|
||||
assignedUserEmail: existing.assignedUserEmail,
|
||||
|
|
@ -470,6 +528,7 @@ export const register = mutation({
|
|||
companyId,
|
||||
companySlug,
|
||||
hostname: args.hostname,
|
||||
displayName: args.hostname,
|
||||
osName: args.os.name,
|
||||
osVersion: args.os.version,
|
||||
architecture: args.os.architecture,
|
||||
|
|
@ -483,6 +542,9 @@ export const register = mutation({
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
registeredBy: args.registeredBy,
|
||||
deviceType: "desktop",
|
||||
devicePlatform: args.os.name,
|
||||
managementMode: "agent",
|
||||
persona: undefined,
|
||||
assignedUserId: undefined,
|
||||
assignedUserEmail: undefined,
|
||||
|
|
@ -582,6 +644,7 @@ export const upsertInventory = mutation({
|
|||
companyId: companyId ?? existing.companyId,
|
||||
companySlug: companySlug ?? existing.companySlug,
|
||||
hostname: args.hostname,
|
||||
displayName: existing.displayName ?? args.hostname,
|
||||
osName: args.os.name,
|
||||
osVersion: args.os.version,
|
||||
architecture: args.os.architecture,
|
||||
|
|
@ -592,6 +655,9 @@ export const upsertInventory = mutation({
|
|||
updatedAt: now,
|
||||
status: args.metrics ? "online" : existing.status ?? "unknown",
|
||||
registeredBy: args.registeredBy ?? existing.registeredBy,
|
||||
deviceType: existing.deviceType ?? "desktop",
|
||||
devicePlatform: args.os.name ?? existing.devicePlatform,
|
||||
managementMode: existing.managementMode ?? "agent",
|
||||
persona: existing.persona,
|
||||
assignedUserId: existing.assignedUserId,
|
||||
assignedUserEmail: existing.assignedUserEmail,
|
||||
|
|
@ -605,6 +671,7 @@ export const upsertInventory = mutation({
|
|||
companyId,
|
||||
companySlug,
|
||||
hostname: args.hostname,
|
||||
displayName: args.hostname,
|
||||
osName: args.os.name,
|
||||
osVersion: args.os.version,
|
||||
architecture: args.os.architecture,
|
||||
|
|
@ -617,6 +684,9 @@ export const upsertInventory = mutation({
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
registeredBy: args.registeredBy,
|
||||
deviceType: "desktop",
|
||||
devicePlatform: args.os.name,
|
||||
managementMode: "agent",
|
||||
persona: undefined,
|
||||
assignedUserId: undefined,
|
||||
assignedUserEmail: undefined,
|
||||
|
|
@ -673,9 +743,13 @@ export const heartbeat = mutation({
|
|||
|
||||
await ctx.db.patch(machine._id, {
|
||||
hostname: args.hostname ?? machine.hostname,
|
||||
displayName: machine.displayName ?? args.hostname ?? machine.hostname,
|
||||
osName: args.os?.name ?? machine.osName,
|
||||
osVersion: args.os?.version ?? machine.osVersion,
|
||||
architecture: args.os?.architecture ?? machine.architecture,
|
||||
devicePlatform: args.os?.name ?? machine.devicePlatform,
|
||||
deviceType: machine.deviceType ?? "desktop",
|
||||
managementMode: machine.managementMode ?? "agent",
|
||||
lastHeartbeatAt: now,
|
||||
updatedAt: now,
|
||||
status: args.status ?? "online",
|
||||
|
|
@ -839,6 +913,11 @@ export const listByTenant = query({
|
|||
id: machine._id,
|
||||
tenantId: machine.tenantId,
|
||||
hostname: machine.hostname,
|
||||
displayName: machine.displayName ?? null,
|
||||
deviceType: machine.deviceType ?? "desktop",
|
||||
devicePlatform: machine.devicePlatform ?? null,
|
||||
deviceProfile: machine.deviceProfile ?? null,
|
||||
managementMode: machine.managementMode ?? "agent",
|
||||
companyId: machine.companyId ?? null,
|
||||
companySlug: machine.companySlug ?? companyFromId?.slug ?? companyFromSlug?.slug ?? null,
|
||||
companyName: resolvedCompany?.name ?? null,
|
||||
|
|
@ -873,6 +952,7 @@ export const listByTenant = query({
|
|||
inventory,
|
||||
postureAlerts,
|
||||
lastPostureAt,
|
||||
customFields: machine.customFields ?? [],
|
||||
}
|
||||
})
|
||||
)
|
||||
|
|
@ -957,6 +1037,11 @@ export async function getByIdHandler(
|
|||
id: machine._id,
|
||||
tenantId: machine.tenantId,
|
||||
hostname: machine.hostname,
|
||||
displayName: machine.displayName ?? null,
|
||||
deviceType: machine.deviceType ?? "desktop",
|
||||
devicePlatform: machine.devicePlatform ?? null,
|
||||
deviceProfile: machine.deviceProfile ?? null,
|
||||
managementMode: machine.managementMode ?? "agent",
|
||||
companyId: machine.companyId ?? null,
|
||||
companySlug: machine.companySlug ?? resolvedCompany?.slug ?? null,
|
||||
companyName: resolvedCompany?.name ?? null,
|
||||
|
|
@ -992,6 +1077,7 @@ export async function getByIdHandler(
|
|||
postureAlerts,
|
||||
lastPostureAt,
|
||||
remoteAccess: machine.remoteAccess ?? null,
|
||||
customFields: machine.customFields ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1333,7 +1419,7 @@ export async function updatePersonaHandler(
|
|||
) {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
let nextPersona = machine.persona ?? undefined
|
||||
|
|
@ -1343,7 +1429,7 @@ export async function updatePersonaHandler(
|
|||
if (!trimmed) {
|
||||
nextPersona = undefined
|
||||
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) {
|
||||
throw new ConvexError("Perfil inválido para a máquina")
|
||||
throw new ConvexError("Perfil inválido para a dispositivo")
|
||||
} else {
|
||||
nextPersona = trimmed
|
||||
}
|
||||
|
|
@ -1380,7 +1466,7 @@ export async function updatePersonaHandler(
|
|||
}
|
||||
|
||||
if (nextPersona && !nextAssignedUserId) {
|
||||
throw new ConvexError("Associe um usuário ao definir a persona da máquina")
|
||||
throw new ConvexError("Associe um usuário ao definir a persona da dispositivo")
|
||||
}
|
||||
|
||||
if (nextPersona && nextAssignedUserId) {
|
||||
|
|
@ -1435,6 +1521,196 @@ export async function updatePersonaHandler(
|
|||
return { ok: true, persona: nextPersona ?? null }
|
||||
}
|
||||
|
||||
export const saveDeviceCustomFields = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
machineId: v.id("machines"),
|
||||
fields: v.array(
|
||||
v.object({
|
||||
fieldId: v.id("deviceFields"),
|
||||
value: v.any(),
|
||||
})
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine || machine.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Dispositivo não encontrado")
|
||||
}
|
||||
|
||||
const companyId = machine.companyId ?? null
|
||||
const deviceType = (machine.deviceType ?? "desktop").toLowerCase()
|
||||
|
||||
const entries = await Promise.all(
|
||||
args.fields.map(async ({ fieldId, value }) => {
|
||||
const definition = await ctx.db.get(fieldId)
|
||||
if (!definition || definition.tenantId !== args.tenantId) {
|
||||
return null
|
||||
}
|
||||
if (companyId && definition.companyId && definition.companyId !== companyId) {
|
||||
return null
|
||||
}
|
||||
if (!companyId && definition.companyId) {
|
||||
return null
|
||||
}
|
||||
const scope = (definition.scope ?? "all").toLowerCase()
|
||||
if (scope !== "all" && scope !== deviceType) {
|
||||
return null
|
||||
}
|
||||
const displayValue =
|
||||
value === null || value === undefined
|
||||
? null
|
||||
: formatDeviceCustomFieldDisplay(definition.type, value, definition.options ?? undefined)
|
||||
return {
|
||||
fieldId: definition._id,
|
||||
fieldKey: definition.key,
|
||||
label: definition.label,
|
||||
type: definition.type,
|
||||
value: value ?? null,
|
||||
displayValue: displayValue ?? undefined,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const customFields = entries.filter(Boolean) as Array<{
|
||||
fieldId: Id<"deviceFields">
|
||||
fieldKey: string
|
||||
label: string
|
||||
type: string
|
||||
value: unknown
|
||||
displayValue?: string
|
||||
}>
|
||||
|
||||
await ctx.db.patch(args.machineId, {
|
||||
customFields,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const saveDeviceProfile = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
machineId: v.optional(v.id("machines")),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
companySlug: v.optional(v.string()),
|
||||
displayName: v.string(),
|
||||
hostname: v.optional(v.string()),
|
||||
deviceType: v.string(),
|
||||
devicePlatform: v.optional(v.string()),
|
||||
osName: v.optional(v.string()),
|
||||
osVersion: v.optional(v.string()),
|
||||
macAddresses: v.optional(v.array(v.string())),
|
||||
serialNumbers: v.optional(v.array(v.string())),
|
||||
profile: v.optional(v.any()),
|
||||
status: v.optional(v.string()),
|
||||
isActive: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||
const displayName = args.displayName.trim()
|
||||
if (!displayName) {
|
||||
throw new ConvexError("Informe o nome do dispositivo")
|
||||
}
|
||||
const hostname = (args.hostname ?? displayName).trim()
|
||||
if (!hostname) {
|
||||
throw new ConvexError("Informe o identificador do dispositivo")
|
||||
}
|
||||
|
||||
const normalizedType = (() => {
|
||||
const candidate = args.deviceType.trim().toLowerCase()
|
||||
if (["desktop", "mobile", "tablet"].includes(candidate)) return candidate
|
||||
return "desktop"
|
||||
})()
|
||||
const normalizedPlatform = args.devicePlatform?.trim() || args.osName?.trim() || null
|
||||
const normalizedStatus = (args.status ?? "unknown").trim() || "unknown"
|
||||
const normalizedSlug = args.companySlug?.trim() || undefined
|
||||
const osNameInput = args.osName === undefined ? undefined : args.osName.trim()
|
||||
const osVersionInput = args.osVersion === undefined ? undefined : args.osVersion.trim()
|
||||
const now = Date.now()
|
||||
|
||||
if (args.machineId) {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine || machine.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Dispositivo não encontrado para atualização")
|
||||
}
|
||||
if (machine.managementMode && machine.managementMode !== "manual") {
|
||||
throw new ConvexError("Somente dispositivos manuais podem ser editados por esta ação")
|
||||
}
|
||||
|
||||
const normalizedIdentifiers = normalizeOptionalIdentifiers(args.macAddresses, args.serialNumbers)
|
||||
const macAddresses =
|
||||
args.macAddresses === undefined ? machine.macAddresses : normalizedIdentifiers.macs
|
||||
const serialNumbers =
|
||||
args.serialNumbers === undefined ? machine.serialNumbers : normalizedIdentifiers.serials
|
||||
const deviceProfilePatch = args.profile === undefined ? undefined : args.profile ?? null
|
||||
|
||||
const osNameValue = osNameInput === undefined ? machine.osName : osNameInput || machine.osName
|
||||
const osVersionValue =
|
||||
osVersionInput === undefined ? machine.osVersion ?? undefined : osVersionInput || undefined
|
||||
|
||||
await ctx.db.patch(args.machineId, {
|
||||
companyId: args.companyId ?? machine.companyId ?? undefined,
|
||||
companySlug: normalizedSlug ?? machine.companySlug ?? undefined,
|
||||
hostname,
|
||||
displayName,
|
||||
osName: osNameValue,
|
||||
osVersion: osVersionValue,
|
||||
macAddresses,
|
||||
serialNumbers,
|
||||
deviceType: normalizedType,
|
||||
devicePlatform: normalizedPlatform ?? machine.devicePlatform ?? undefined,
|
||||
deviceProfile: deviceProfilePatch,
|
||||
managementMode: "manual",
|
||||
status: normalizedStatus,
|
||||
isActive: args.isActive ?? machine.isActive ?? true,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return { machineId: args.machineId }
|
||||
}
|
||||
|
||||
const normalizedIdentifiers = normalizeOptionalIdentifiers(args.macAddresses, args.serialNumbers)
|
||||
const fingerprint = generateManualFingerprint(args.tenantId, displayName)
|
||||
const deviceProfile = args.profile ?? undefined
|
||||
const osNameValue = osNameInput || normalizedPlatform || "Desconhecido"
|
||||
const osVersionValue = osVersionInput || undefined
|
||||
|
||||
const machineId = await ctx.db.insert("machines", {
|
||||
tenantId: args.tenantId,
|
||||
companyId: args.companyId ?? undefined,
|
||||
companySlug: normalizedSlug ?? undefined,
|
||||
hostname,
|
||||
displayName,
|
||||
osName: osNameValue,
|
||||
osVersion: osVersionValue,
|
||||
macAddresses: normalizedIdentifiers.macs,
|
||||
serialNumbers: normalizedIdentifiers.serials,
|
||||
fingerprint,
|
||||
metadata: undefined,
|
||||
deviceType: normalizedType,
|
||||
devicePlatform: normalizedPlatform ?? undefined,
|
||||
deviceProfile,
|
||||
managementMode: "manual",
|
||||
status: normalizedStatus,
|
||||
isActive: args.isActive ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
registeredBy: "manual",
|
||||
persona: undefined,
|
||||
assignedUserId: undefined,
|
||||
assignedUserEmail: undefined,
|
||||
assignedUserName: undefined,
|
||||
assignedUserRole: undefined,
|
||||
})
|
||||
|
||||
return { machineId }
|
||||
},
|
||||
})
|
||||
|
||||
export const updatePersona = mutation({
|
||||
args: {
|
||||
machineId: v.id("machines"),
|
||||
|
|
@ -1454,7 +1730,7 @@ export const getContext = query({
|
|||
handler: async (ctx, args) => {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
const linkedUserIds = machine.linkedUserIds ?? []
|
||||
|
|
@ -1515,7 +1791,7 @@ export const linkAuthAccount = mutation({
|
|||
handler: async (ctx, args) => {
|
||||
const machine = await ctx.db.get(args.machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
await ctx.db.patch(machine._id, {
|
||||
|
|
@ -1535,7 +1811,7 @@ export const linkUser = mutation({
|
|||
},
|
||||
handler: async (ctx, { machineId, email }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) throw new ConvexError("Máquina não encontrada")
|
||||
if (!machine) throw new ConvexError("Dispositivo não encontrada")
|
||||
const tenantId = machine.tenantId
|
||||
const normalized = email.trim().toLowerCase()
|
||||
|
||||
|
|
@ -1546,7 +1822,7 @@ export const linkUser = mutation({
|
|||
if (!user) throw new ConvexError("Usuário não encontrado")
|
||||
const role = (user.role ?? "").toUpperCase()
|
||||
if (role === 'ADMIN' || role === 'AGENT') {
|
||||
throw new ConvexError('Usuários administrativos não podem ser vinculados a máquinas')
|
||||
throw new ConvexError('Usuários administrativos não podem ser vinculados a dispositivos')
|
||||
}
|
||||
|
||||
const current = new Set<Id<"users">>(machine.linkedUserIds ?? [])
|
||||
|
|
@ -1563,7 +1839,7 @@ export const unlinkUser = mutation({
|
|||
},
|
||||
handler: async (ctx, { machineId, userId }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) throw new ConvexError("Máquina não encontrada")
|
||||
if (!machine) throw new ConvexError("Dispositivo não encontrada")
|
||||
const next = (machine.linkedUserIds ?? []).filter((id) => id !== userId)
|
||||
await ctx.db.patch(machine._id, { linkedUserIds: next, updatedAt: Date.now() })
|
||||
return { ok: true }
|
||||
|
|
@ -1580,25 +1856,29 @@ export const rename = mutation({
|
|||
// Reutiliza requireStaff através de tickets.ts helpers
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
// Verifica permissão no tenant da máquina
|
||||
// Verifica permissão no tenant da dispositivo
|
||||
const viewer = await ctx.db.get(actorId)
|
||||
if (!viewer || viewer.tenantId !== machine.tenantId) {
|
||||
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||
throw new ConvexError("Acesso negado ao tenant da dispositivo")
|
||||
}
|
||||
const normalizedRole = (viewer.role ?? "AGENT").toUpperCase()
|
||||
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
|
||||
if (!STAFF.has(normalizedRole)) {
|
||||
throw new ConvexError("Apenas equipe interna pode renomear máquinas")
|
||||
throw new ConvexError("Apenas equipe interna pode renomear dispositivos")
|
||||
}
|
||||
|
||||
const nextName = hostname.trim()
|
||||
if (nextName.length < 2) {
|
||||
throw new ConvexError("Informe um nome válido para a máquina")
|
||||
throw new ConvexError("Informe um nome válido para a dispositivo")
|
||||
}
|
||||
|
||||
await ctx.db.patch(machineId, { hostname: nextName, updatedAt: Date.now() })
|
||||
await ctx.db.patch(machineId, {
|
||||
hostname: nextName,
|
||||
displayName: nextName,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
return { ok: true }
|
||||
},
|
||||
})
|
||||
|
|
@ -1612,17 +1892,17 @@ export const toggleActive = mutation({
|
|||
handler: async (ctx, { machineId, actorId, active }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
const actor = await ctx.db.get(actorId)
|
||||
if (!actor || actor.tenantId !== machine.tenantId) {
|
||||
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||
throw new ConvexError("Acesso negado ao tenant da dispositivo")
|
||||
}
|
||||
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
||||
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
|
||||
if (!STAFF.has(normalizedRole)) {
|
||||
throw new ConvexError("Apenas equipe interna pode atualizar o status da máquina")
|
||||
throw new ConvexError("Apenas equipe interna pode atualizar o status da dispositivo")
|
||||
}
|
||||
|
||||
await ctx.db.patch(machineId, {
|
||||
|
|
@ -1642,17 +1922,17 @@ export const resetAgent = mutation({
|
|||
handler: async (ctx, { machineId, actorId }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
const actor = await ctx.db.get(actorId)
|
||||
if (!actor || actor.tenantId !== machine.tenantId) {
|
||||
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||
throw new ConvexError("Acesso negado ao tenant da dispositivo")
|
||||
}
|
||||
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
||||
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
|
||||
if (!STAFF.has(normalizedRole)) {
|
||||
throw new ConvexError("Apenas equipe interna pode resetar o agente da máquina")
|
||||
throw new ConvexError("Apenas equipe interna pode resetar o agente da dispositivo")
|
||||
}
|
||||
|
||||
const tokens = await ctx.db
|
||||
|
|
@ -1808,12 +2088,12 @@ export const updateRemoteAccess = mutation({
|
|||
handler: async (ctx, { machineId, actorId, provider, identifier, url, notes, action, entryId, clear }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
const actor = await ctx.db.get(actorId)
|
||||
if (!actor || actor.tenantId !== machine.tenantId) {
|
||||
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||
throw new ConvexError("Acesso negado ao tenant da dispositivo")
|
||||
}
|
||||
|
||||
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
|
||||
|
|
@ -1937,17 +2217,17 @@ export const remove = mutation({
|
|||
handler: async (ctx, { machineId, actorId }) => {
|
||||
const machine = await ctx.db.get(machineId)
|
||||
if (!machine) {
|
||||
throw new ConvexError("Máquina não encontrada")
|
||||
throw new ConvexError("Dispositivo não encontrada")
|
||||
}
|
||||
|
||||
const actor = await ctx.db.get(actorId)
|
||||
if (!actor || actor.tenantId !== machine.tenantId) {
|
||||
throw new ConvexError("Acesso negado ao tenant da máquina")
|
||||
throw new ConvexError("Acesso negado ao tenant da dispositivo")
|
||||
}
|
||||
const role = (actor.role ?? "AGENT").toUpperCase()
|
||||
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
|
||||
if (!STAFF.has(role)) {
|
||||
throw new ConvexError("Apenas equipe interna pode excluir máquinas")
|
||||
throw new ConvexError("Apenas equipe interna pode excluir dispositivos")
|
||||
}
|
||||
|
||||
const tokens = await ctx.db
|
||||
|
|
|
|||
|
|
@ -51,6 +51,39 @@ function extractScore(payload: unknown): number | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function extractMaxScore(payload: unknown): number | null {
|
||||
if (payload && typeof payload === "object" && "maxScore" in payload) {
|
||||
const value = (payload as { maxScore: unknown }).maxScore;
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractComment(payload: unknown): string | null {
|
||||
if (payload && typeof payload === "object" && "comment" in payload) {
|
||||
const value = (payload as { comment: unknown }).comment;
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractAssignee(payload: unknown): { id: string | null; name: string | null } {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return { id: null, name: null }
|
||||
}
|
||||
const record = payload as Record<string, unknown>
|
||||
const rawId = record["assigneeId"]
|
||||
const rawName = record["assigneeName"]
|
||||
const id = typeof rawId === "string" && rawId.trim().length > 0 ? rawId.trim() : null
|
||||
const name = typeof rawName === "string" && rawName.trim().length > 0 ? rawName.trim() : null
|
||||
return { id, name }
|
||||
}
|
||||
|
||||
function isNotNull<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
|
@ -119,6 +152,44 @@ async function fetchScopedTicketsByCreatedRange(
|
|||
.collect();
|
||||
}
|
||||
|
||||
async function fetchScopedTicketsByResolvedRange(
|
||||
ctx: QueryCtx,
|
||||
tenantId: string,
|
||||
viewer: Awaited<ReturnType<typeof requireStaff>>,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
companyId?: Id<"companies">,
|
||||
) {
|
||||
if (viewer.role === "MANAGER") {
|
||||
if (!viewer.user.companyId) {
|
||||
throw new ConvexError("Gestor não possui empresa vinculada");
|
||||
}
|
||||
return ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company_resolved", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("resolvedAt", startMs),
|
||||
)
|
||||
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
|
||||
.collect();
|
||||
}
|
||||
|
||||
if (companyId) {
|
||||
return ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_company_resolved", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("companyId", companyId).gte("resolvedAt", startMs),
|
||||
)
|
||||
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
|
||||
.collect();
|
||||
}
|
||||
|
||||
return ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", startMs))
|
||||
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
|
||||
.collect();
|
||||
}
|
||||
|
||||
async function fetchQueues(ctx: QueryCtx, tenantId: string) {
|
||||
return ctx.db
|
||||
.query("queues")
|
||||
|
|
@ -159,12 +230,47 @@ type CsatSurvey = {
|
|||
ticketId: Id<"tickets">;
|
||||
reference: number;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
comment: string | null;
|
||||
receivedAt: number;
|
||||
assigneeId: string | null;
|
||||
assigneeName: string | null;
|
||||
};
|
||||
|
||||
async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise<CsatSurvey[]> {
|
||||
const perTicket = await Promise.all(
|
||||
tickets.map(async (ticket) => {
|
||||
if (typeof ticket.csatScore === "number") {
|
||||
const snapshot = (ticket.csatAssigneeSnapshot ?? null) as {
|
||||
name?: string
|
||||
email?: string
|
||||
} | null
|
||||
const assigneeId =
|
||||
ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string"
|
||||
? ticket.csatAssigneeId
|
||||
: ticket.csatAssigneeId
|
||||
? String(ticket.csatAssigneeId)
|
||||
: null
|
||||
const assigneeName =
|
||||
snapshot && typeof snapshot.name === "string" && snapshot.name.trim().length > 0
|
||||
? snapshot.name.trim()
|
||||
: null
|
||||
return [
|
||||
{
|
||||
ticketId: ticket._id,
|
||||
reference: ticket.reference,
|
||||
score: ticket.csatScore,
|
||||
maxScore: ticket.csatMaxScore && Number.isFinite(ticket.csatMaxScore) ? (ticket.csatMaxScore as number) : 5,
|
||||
comment:
|
||||
typeof ticket.csatComment === "string" && ticket.csatComment.trim().length > 0
|
||||
? ticket.csatComment.trim()
|
||||
: null,
|
||||
receivedAt: ticket.csatRatedAt ?? ticket.updatedAt ?? ticket.createdAt,
|
||||
assigneeId,
|
||||
assigneeName,
|
||||
} satisfies CsatSurvey,
|
||||
];
|
||||
}
|
||||
const events = await ctx.db
|
||||
.query("ticketEvents")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
|
||||
|
|
@ -174,11 +280,16 @@ async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Pro
|
|||
.map((event) => {
|
||||
const score = extractScore(event.payload);
|
||||
if (score === null) return null;
|
||||
const assignee = extractAssignee(event.payload)
|
||||
return {
|
||||
ticketId: ticket._id,
|
||||
reference: ticket.reference,
|
||||
score,
|
||||
maxScore: extractMaxScore(event.payload) ?? 5,
|
||||
comment: extractComment(event.payload),
|
||||
receivedAt: event.createdAt,
|
||||
assigneeId: assignee.id,
|
||||
assigneeName: assignee.name,
|
||||
} as CsatSurvey;
|
||||
})
|
||||
.filter(isNotNull);
|
||||
|
|
@ -275,12 +386,61 @@ export async function csatOverviewHandler(
|
|||
const startMs = endMs - days * ONE_DAY_MS;
|
||||
const surveys = surveysAll.filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs);
|
||||
|
||||
const averageScore = average(surveys.map((item) => item.score));
|
||||
const normalizeToFive = (value: CsatSurvey) => {
|
||||
if (!value.maxScore || value.maxScore <= 0) return value.score;
|
||||
return Math.min(5, Math.max(1, (value.score / value.maxScore) * 5));
|
||||
};
|
||||
|
||||
const averageScore = average(surveys.map((item) => normalizeToFive(item)));
|
||||
const distribution = [1, 2, 3, 4, 5].map((score) => ({
|
||||
score,
|
||||
total: surveys.filter((item) => item.score === score).length,
|
||||
total: surveys.filter((item) => Math.round(normalizeToFive(item)) === score).length,
|
||||
}));
|
||||
|
||||
const positiveThreshold = 4;
|
||||
const positiveCount = surveys.filter((item) => normalizeToFive(item) >= positiveThreshold).length;
|
||||
const positiveRate = surveys.length > 0 ? positiveCount / surveys.length : null;
|
||||
|
||||
const agentStats = new Map<
|
||||
string,
|
||||
{ id: string; name: string; total: number; sum: number; positive: number }
|
||||
>();
|
||||
|
||||
for (const survey of surveys) {
|
||||
const normalizedScore = normalizeToFive(survey);
|
||||
const key = survey.assigneeId ?? "unassigned";
|
||||
const existing = agentStats.get(key) ?? {
|
||||
id: key,
|
||||
name: survey.assigneeName ?? "Sem responsável",
|
||||
total: 0,
|
||||
sum: 0,
|
||||
positive: 0,
|
||||
};
|
||||
existing.total += 1;
|
||||
existing.sum += normalizedScore;
|
||||
if (normalizedScore >= positiveThreshold) {
|
||||
existing.positive += 1;
|
||||
}
|
||||
if (survey.assigneeName && survey.assigneeName.trim().length > 0) {
|
||||
existing.name = survey.assigneeName.trim();
|
||||
}
|
||||
agentStats.set(key, existing);
|
||||
}
|
||||
|
||||
const byAgent = Array.from(agentStats.values())
|
||||
.map((entry) => ({
|
||||
agentId: entry.id === "unassigned" ? null : entry.id,
|
||||
agentName: entry.id === "unassigned" ? "Sem responsável" : entry.name,
|
||||
totalResponses: entry.total,
|
||||
averageScore: entry.total > 0 ? entry.sum / entry.total : null,
|
||||
positiveRate: entry.total > 0 ? entry.positive / entry.total : null,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const diff = (b.averageScore ?? 0) - (a.averageScore ?? 0);
|
||||
if (Math.abs(diff) > 0.0001) return diff;
|
||||
return (b.totalResponses ?? 0) - (a.totalResponses ?? 0);
|
||||
});
|
||||
|
||||
return {
|
||||
totalSurveys: surveys.length,
|
||||
averageScore,
|
||||
|
|
@ -293,9 +453,15 @@ export async function csatOverviewHandler(
|
|||
ticketId: item.ticketId,
|
||||
reference: item.reference,
|
||||
score: item.score,
|
||||
maxScore: item.maxScore,
|
||||
comment: item.comment,
|
||||
receivedAt: item.receivedAt,
|
||||
assigneeId: item.assigneeId,
|
||||
assigneeName: item.assigneeName,
|
||||
})),
|
||||
rangeDays: days,
|
||||
positiveRate,
|
||||
byAgent,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -309,15 +475,15 @@ export async function openedResolvedByDayHandler(
|
|||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||
) {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||
let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
|
||||
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
|
||||
|
||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
const end = new Date();
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
const endMs = end.getTime() + ONE_DAY_MS;
|
||||
const startMs = endMs - days * ONE_DAY_MS;
|
||||
|
||||
const openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||
const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||
|
||||
const opened: Record<string, number> = {}
|
||||
const resolved: Record<string, number> = {}
|
||||
|
||||
|
|
@ -328,15 +494,19 @@ export async function openedResolvedByDayHandler(
|
|||
resolved[key] = 0
|
||||
}
|
||||
|
||||
for (const t of tickets) {
|
||||
if (t.createdAt >= startMs && t.createdAt < endMs) {
|
||||
const key = formatDateKey(t.createdAt)
|
||||
for (const ticket of openedTickets) {
|
||||
if (ticket.createdAt >= startMs && ticket.createdAt < endMs) {
|
||||
const key = formatDateKey(ticket.createdAt)
|
||||
opened[key] = (opened[key] ?? 0) + 1
|
||||
}
|
||||
if (t.resolvedAt && t.resolvedAt >= startMs && t.resolvedAt < endMs) {
|
||||
const key = formatDateKey(t.resolvedAt)
|
||||
resolved[key] = (resolved[key] ?? 0) + 1
|
||||
}
|
||||
|
||||
for (const ticket of resolvedTickets) {
|
||||
if (typeof ticket.resolvedAt !== "number") {
|
||||
continue
|
||||
}
|
||||
const key = formatDateKey(ticket.resolvedAt)
|
||||
resolved[key] = (resolved[key] ?? 0) + 1
|
||||
}
|
||||
|
||||
const series: Array<{ date: string; opened: number; resolved: number }> = []
|
||||
|
|
|
|||
152
convex/schema.ts
152
convex/schema.ts
|
|
@ -169,6 +169,26 @@ export default defineSchema({
|
|||
})
|
||||
)
|
||||
),
|
||||
csatScore: v.optional(v.number()),
|
||||
csatMaxScore: v.optional(v.number()),
|
||||
csatComment: v.optional(v.string()),
|
||||
csatRatedAt: v.optional(v.number()),
|
||||
csatRatedBy: v.optional(v.id("users")),
|
||||
csatAssigneeId: v.optional(v.id("users")),
|
||||
csatAssigneeSnapshot: v.optional(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
email: v.optional(v.string()),
|
||||
avatarUrl: v.optional(v.string()),
|
||||
teams: v.optional(v.array(v.string())),
|
||||
})
|
||||
),
|
||||
formTemplate: v.optional(v.string()),
|
||||
relatedTicketIds: v.optional(v.array(v.id("tickets"))),
|
||||
resolvedWithTicketId: v.optional(v.id("tickets")),
|
||||
reopenDeadline: v.optional(v.number()),
|
||||
reopenedAt: v.optional(v.number()),
|
||||
chatEnabled: v.optional(v.boolean()),
|
||||
totalWorkedMs: v.optional(v.number()),
|
||||
internalWorkedMs: v.optional(v.number()),
|
||||
externalWorkedMs: v.optional(v.number()),
|
||||
|
|
@ -183,7 +203,9 @@ export default defineSchema({
|
|||
.index("by_tenant_machine", ["tenantId", "machineId"])
|
||||
.index("by_tenant", ["tenantId"])
|
||||
.index("by_tenant_created", ["tenantId", "createdAt"])
|
||||
.index("by_tenant_company_created", ["tenantId", "companyId", "createdAt"]),
|
||||
.index("by_tenant_resolved", ["tenantId", "resolvedAt"])
|
||||
.index("by_tenant_company_created", ["tenantId", "companyId", "createdAt"])
|
||||
.index("by_tenant_company_resolved", ["tenantId", "companyId", "resolvedAt"]),
|
||||
|
||||
ticketComments: defineTable({
|
||||
ticketId: v.id("tickets"),
|
||||
|
|
@ -221,6 +243,45 @@ export default defineSchema({
|
|||
createdAt: v.number(),
|
||||
}).index("by_ticket", ["ticketId"]),
|
||||
|
||||
ticketChatMessages: defineTable({
|
||||
ticketId: v.id("tickets"),
|
||||
authorId: v.id("users"),
|
||||
authorSnapshot: v.optional(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
email: v.optional(v.string()),
|
||||
avatarUrl: v.optional(v.string()),
|
||||
teams: v.optional(v.array(v.string())),
|
||||
})
|
||||
),
|
||||
body: v.string(),
|
||||
attachments: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
storageId: v.id("_storage"),
|
||||
name: v.string(),
|
||||
size: v.optional(v.number()),
|
||||
type: v.optional(v.string()),
|
||||
})
|
||||
)
|
||||
),
|
||||
notifiedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
tenantId: v.string(),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
readBy: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
userId: v.id("users"),
|
||||
readAt: v.number(),
|
||||
})
|
||||
)
|
||||
),
|
||||
})
|
||||
.index("by_ticket_created", ["ticketId", "createdAt"])
|
||||
.index("by_tenant_created", ["tenantId", "createdAt"]),
|
||||
|
||||
commentTemplates: defineTable({
|
||||
tenantId: v.string(),
|
||||
kind: v.optional(v.string()),
|
||||
|
|
@ -291,11 +352,29 @@ export default defineSchema({
|
|||
})
|
||||
)
|
||||
),
|
||||
scope: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_tenant_key", ["tenantId", "key"])
|
||||
.index("by_tenant_order", ["tenantId", "order"])
|
||||
.index("by_tenant_scope", ["tenantId", "scope"])
|
||||
.index("by_tenant", ["tenantId"]),
|
||||
|
||||
ticketFormSettings: defineTable({
|
||||
tenantId: v.string(),
|
||||
template: v.string(),
|
||||
scope: v.string(), // tenant | company | user
|
||||
companyId: v.optional(v.id("companies")),
|
||||
userId: v.optional(v.id("users")),
|
||||
enabled: v.boolean(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
actorId: v.optional(v.id("users")),
|
||||
})
|
||||
.index("by_tenant_template_scope", ["tenantId", "template", "scope"])
|
||||
.index("by_tenant_template_company", ["tenantId", "template", "companyId"])
|
||||
.index("by_tenant_template_user", ["tenantId", "template", "userId"])
|
||||
.index("by_tenant", ["tenantId"]),
|
||||
|
||||
userInvites: defineTable({
|
||||
|
|
@ -339,6 +418,23 @@ export default defineSchema({
|
|||
serialNumbers: v.array(v.string()),
|
||||
fingerprint: v.string(),
|
||||
metadata: v.optional(v.any()),
|
||||
displayName: v.optional(v.string()),
|
||||
deviceType: v.optional(v.string()),
|
||||
devicePlatform: v.optional(v.string()),
|
||||
deviceProfile: v.optional(v.any()),
|
||||
managementMode: v.optional(v.string()),
|
||||
customFields: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
fieldId: v.id("deviceFields"),
|
||||
fieldKey: v.string(),
|
||||
label: v.string(),
|
||||
type: v.string(),
|
||||
value: v.any(),
|
||||
displayValue: v.optional(v.string()),
|
||||
})
|
||||
)
|
||||
),
|
||||
lastHeartbeatAt: v.optional(v.number()),
|
||||
status: v.optional(v.string()),
|
||||
isActive: v.optional(v.boolean()),
|
||||
|
|
@ -382,4 +478,58 @@ export default defineSchema({
|
|||
.index("by_tenant_machine", ["tenantId", "machineId"])
|
||||
.index("by_machine_created", ["machineId", "createdAt"])
|
||||
.index("by_machine_revoked_expires", ["machineId", "revoked", "expiresAt"]),
|
||||
|
||||
deviceFields: defineTable({
|
||||
tenantId: v.string(),
|
||||
key: v.string(),
|
||||
label: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
type: v.string(),
|
||||
required: v.optional(v.boolean()),
|
||||
options: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
value: v.string(),
|
||||
label: v.string(),
|
||||
})
|
||||
)
|
||||
),
|
||||
scope: v.optional(v.string()),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
order: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
createdBy: v.optional(v.id("users")),
|
||||
updatedBy: v.optional(v.id("users")),
|
||||
})
|
||||
.index("by_tenant_order", ["tenantId", "order"])
|
||||
.index("by_tenant_key", ["tenantId", "key"])
|
||||
.index("by_tenant_company", ["tenantId", "companyId"])
|
||||
.index("by_tenant_scope", ["tenantId", "scope"])
|
||||
.index("by_tenant", ["tenantId"]),
|
||||
|
||||
deviceExportTemplates: defineTable({
|
||||
tenantId: v.string(),
|
||||
name: v.string(),
|
||||
slug: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
columns: v.array(
|
||||
v.object({
|
||||
key: v.string(),
|
||||
label: v.optional(v.string()),
|
||||
})
|
||||
),
|
||||
filters: v.optional(v.any()),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
isDefault: v.optional(v.boolean()),
|
||||
isActive: v.optional(v.boolean()),
|
||||
createdBy: v.optional(v.id("users")),
|
||||
updatedBy: v.optional(v.id("users")),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_tenant_slug", ["tenantId", "slug"])
|
||||
.index("by_tenant_company", ["tenantId", "companyId"])
|
||||
.index("by_tenant_default", ["tenantId", "isDefault"])
|
||||
.index("by_tenant", ["tenantId"]),
|
||||
});
|
||||
|
|
|
|||
149
convex/ticketFormSettings.ts
Normal file
149
convex/ticketFormSettings.ts
Normal 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
|
||||
}
|
||||
|
|
@ -41,6 +41,23 @@ const missingCommentAuthorLogCache = new Set<string>();
|
|||
// Character limits (generous but bounded)
|
||||
const MAX_SUMMARY_CHARS = 600;
|
||||
const MAX_COMMENT_CHARS = 20000;
|
||||
const DEFAULT_REOPEN_DAYS = 7;
|
||||
const MAX_REOPEN_DAYS = 14;
|
||||
|
||||
const TICKET_FORM_CONFIG = [
|
||||
{
|
||||
key: "admissao" as const,
|
||||
label: "Admissão de colaborador",
|
||||
description: "Coleta dados completos para novos colaboradores, incluindo informações pessoais e provisionamento de acesso.",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
key: "desligamento" as const,
|
||||
label: "Desligamento de colaborador",
|
||||
description: "Checklist de desligamento com orientações para revogar acessos e coletar equipamentos.",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
function plainTextLength(html: string): number {
|
||||
try {
|
||||
|
|
@ -63,6 +80,110 @@ function escapeHtml(input: string): string {
|
|||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function normalizeFormTemplateKey(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
const normalized = trimmed
|
||||
.normalize("NFD")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function resolveReopenWindowDays(input?: number | null): number {
|
||||
if (typeof input !== "number" || !Number.isFinite(input)) {
|
||||
return DEFAULT_REOPEN_DAYS;
|
||||
}
|
||||
const rounded = Math.round(input);
|
||||
if (rounded < 1) return 1;
|
||||
if (rounded > MAX_REOPEN_DAYS) return MAX_REOPEN_DAYS;
|
||||
return rounded;
|
||||
}
|
||||
|
||||
function computeReopenDeadline(now: number, windowDays: number): number {
|
||||
return now + windowDays * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function inferExistingReopenDeadline(ticket: Doc<"tickets">): number | null {
|
||||
if (typeof ticket.reopenDeadline === "number") {
|
||||
return ticket.reopenDeadline;
|
||||
}
|
||||
if (typeof ticket.closedAt === "number") {
|
||||
return ticket.closedAt + DEFAULT_REOPEN_DAYS * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
if (typeof ticket.resolvedAt === "number") {
|
||||
return ticket.resolvedAt + DEFAULT_REOPEN_DAYS * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isWithinReopenWindow(ticket: Doc<"tickets">, now: number): boolean {
|
||||
const deadline = inferExistingReopenDeadline(ticket);
|
||||
if (!deadline) {
|
||||
return true;
|
||||
}
|
||||
return now <= deadline;
|
||||
}
|
||||
|
||||
function findLatestSetting<T extends { updatedAt: number }>(entries: T[], predicate: (entry: T) => boolean): T | null {
|
||||
let latest: T | null = null;
|
||||
for (const entry of entries) {
|
||||
if (!predicate(entry)) continue;
|
||||
if (!latest || entry.updatedAt > latest.updatedAt) {
|
||||
latest = entry;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function resolveFormEnabled(
|
||||
template: string,
|
||||
baseEnabled: boolean,
|
||||
settings: Doc<"ticketFormSettings">[],
|
||||
context: { companyId?: Id<"companies"> | null; userId: Id<"users"> }
|
||||
): boolean {
|
||||
const scoped = settings.filter((setting) => setting.template === template)
|
||||
if (scoped.length === 0) {
|
||||
return baseEnabled
|
||||
}
|
||||
const userSetting = findLatestSetting(scoped, (setting) => {
|
||||
if (setting.scope !== "user") {
|
||||
return false
|
||||
}
|
||||
if (!setting.userId) {
|
||||
return false
|
||||
}
|
||||
return String(setting.userId) === String(context.userId)
|
||||
})
|
||||
if (userSetting) {
|
||||
return userSetting.enabled ?? baseEnabled
|
||||
}
|
||||
const companyId = context.companyId ? String(context.companyId) : null
|
||||
if (companyId) {
|
||||
const companySetting = findLatestSetting(scoped, (setting) => {
|
||||
if (setting.scope !== "company") {
|
||||
return false
|
||||
}
|
||||
if (!setting.companyId) {
|
||||
return false
|
||||
}
|
||||
return String(setting.companyId) === companyId
|
||||
})
|
||||
if (companySetting) {
|
||||
return companySetting.enabled ?? baseEnabled
|
||||
}
|
||||
}
|
||||
const tenantSetting = findLatestSetting(scoped, (setting) => setting.scope === "tenant")
|
||||
if (tenantSetting) {
|
||||
return tenantSetting.enabled ?? baseEnabled
|
||||
}
|
||||
return baseEnabled
|
||||
}
|
||||
|
||||
export function buildAssigneeChangeComment(
|
||||
reason: string,
|
||||
context: { previousName: string; nextName: string },
|
||||
|
|
@ -313,6 +434,35 @@ async function requireTicketStaff(
|
|||
return viewer
|
||||
}
|
||||
|
||||
type TicketChatParticipant = {
|
||||
user: Doc<"users">;
|
||||
role: string | null;
|
||||
kind: "staff" | "manager" | "requester";
|
||||
};
|
||||
|
||||
async function requireTicketChatParticipant(
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
actorId: Id<"users">,
|
||||
ticket: Doc<"tickets">
|
||||
): Promise<TicketChatParticipant> {
|
||||
const viewer = await requireUser(ctx, actorId, ticket.tenantId);
|
||||
const normalizedRole = viewer.role ?? "";
|
||||
if (normalizedRole === "ADMIN" || normalizedRole === "AGENT") {
|
||||
return { user: viewer.user, role: normalizedRole, kind: "staff" };
|
||||
}
|
||||
if (normalizedRole === "MANAGER") {
|
||||
await ensureManagerTicketAccess(ctx, viewer.user, ticket);
|
||||
return { user: viewer.user, role: normalizedRole, kind: "manager" };
|
||||
}
|
||||
if (normalizedRole === "COLLABORATOR") {
|
||||
if (String(ticket.requesterId) !== String(viewer.user._id)) {
|
||||
throw new ConvexError("Apenas o solicitante pode conversar neste chamado");
|
||||
}
|
||||
return { user: viewer.user, role: normalizedRole, kind: "requester" };
|
||||
}
|
||||
throw new ConvexError("Usuário não possui acesso ao chat deste chamado");
|
||||
}
|
||||
|
||||
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
||||
"Suporte N1": "Chamados",
|
||||
"suporte-n1": "Chamados",
|
||||
|
|
@ -590,16 +740,29 @@ function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { val
|
|||
async function normalizeCustomFieldValues(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
tenantId: string,
|
||||
inputs: CustomFieldInput[] | undefined
|
||||
inputs: CustomFieldInput[] | undefined,
|
||||
scope?: string | null
|
||||
): Promise<NormalizedCustomField[]> {
|
||||
const normalizedScope = scope?.trim() ? scope.trim().toLowerCase() : null;
|
||||
const definitions = await ctx.db
|
||||
.query("ticketFields")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
|
||||
if (!definitions.length) {
|
||||
const scopedDefinitions = definitions.filter((definition) => {
|
||||
const fieldScope = (definition.scope ?? "all").toLowerCase();
|
||||
if (fieldScope === "all" || fieldScope.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (!normalizedScope) {
|
||||
return false;
|
||||
}
|
||||
return fieldScope === normalizedScope;
|
||||
});
|
||||
|
||||
if (!scopedDefinitions.length) {
|
||||
if (inputs && inputs.length > 0) {
|
||||
throw new ConvexError("Nenhum campo personalizado configurado para este tenant");
|
||||
throw new ConvexError("Campos personalizados não configurados para este formulário");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
|
@ -611,7 +774,7 @@ async function normalizeCustomFieldValues(
|
|||
|
||||
const normalized: NormalizedCustomField[] = [];
|
||||
|
||||
for (const definition of definitions.sort((a, b) => a.order - b.order)) {
|
||||
for (const definition of scopedDefinitions.sort((a, b) => a.order - b.order)) {
|
||||
const raw = provided.has(definition._id) ? provided.get(definition._id) : undefined;
|
||||
const isMissing =
|
||||
raw === undefined ||
|
||||
|
|
@ -1181,6 +1344,11 @@ export const getById = query({
|
|||
tags: t.tags ?? [],
|
||||
lastTimelineEntry: null,
|
||||
metrics: null,
|
||||
csatScore: typeof t.csatScore === "number" ? t.csatScore : null,
|
||||
csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null,
|
||||
csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null,
|
||||
csatRatedAt: t.csatRatedAt ?? null,
|
||||
csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null,
|
||||
machine: machineSummary,
|
||||
category: category
|
||||
? {
|
||||
|
|
@ -1218,6 +1386,12 @@ export const getById = query({
|
|||
externalWorkedMs: item.externalWorkedMs,
|
||||
})),
|
||||
},
|
||||
formTemplate: t.formTemplate ?? null,
|
||||
chatEnabled: Boolean(t.chatEnabled),
|
||||
relatedTicketIds: Array.isArray(t.relatedTicketIds) ? t.relatedTicketIds.map((id) => String(id)) : [],
|
||||
resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null,
|
||||
reopenDeadline: t.reopenDeadline ?? null,
|
||||
reopenedAt: t.reopenedAt ?? null,
|
||||
description: undefined,
|
||||
customFields: customFieldsRecord,
|
||||
timeline: timelineRecords.map((ev) => {
|
||||
|
|
@ -1262,6 +1436,8 @@ export const create = mutation({
|
|||
})
|
||||
)
|
||||
),
|
||||
formTemplate: v.optional(v.string()),
|
||||
chatEnabled: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId)
|
||||
|
|
@ -1323,12 +1499,19 @@ export const create = mutation({
|
|||
if (args.machineId) {
|
||||
const machine = (await ctx.db.get(args.machineId)) as Doc<"machines"> | null
|
||||
if (!machine || machine.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Máquina inválida para este chamado")
|
||||
throw new ConvexError("Dispositivo inválida para este chamado")
|
||||
}
|
||||
machineDoc = machine
|
||||
}
|
||||
|
||||
const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined);
|
||||
const formTemplateKey = normalizeFormTemplateKey(args.formTemplate ?? null);
|
||||
const chatEnabled = typeof args.chatEnabled === "boolean" ? args.chatEnabled : true;
|
||||
const normalizedCustomFields = await normalizeCustomFieldValues(
|
||||
ctx,
|
||||
args.tenantId,
|
||||
args.customFields ?? undefined,
|
||||
formTemplateKey,
|
||||
);
|
||||
// compute next reference (simple monotonic counter per tenant)
|
||||
const existing = await ctx.db
|
||||
.query("tickets")
|
||||
|
|
@ -1404,6 +1587,8 @@ export const create = mutation({
|
|||
status: machineDoc.status ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
formTemplate: formTemplateKey ?? undefined,
|
||||
chatEnabled,
|
||||
working: false,
|
||||
activeSessionId: undefined,
|
||||
totalWorkedMs: 0,
|
||||
|
|
@ -1703,12 +1888,201 @@ export const updateStatus = mutation({
|
|||
},
|
||||
});
|
||||
|
||||
export async function resolveTicketHandler(
|
||||
ctx: MutationCtx,
|
||||
{ ticketId, actorId, resolvedWithTicketId, relatedTicketIds, reopenWindowDays }: {
|
||||
ticketId: Id<"tickets">
|
||||
actorId: Id<"users">
|
||||
resolvedWithTicketId?: Id<"tickets">
|
||||
relatedTicketIds?: Id<"tickets">[]
|
||||
reopenWindowDays?: number | null
|
||||
}
|
||||
) {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
const ticketDoc = ticket as Doc<"tickets">
|
||||
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||
const now = Date.now()
|
||||
|
||||
const baseRelated = new Set<string>()
|
||||
for (const rel of relatedTicketIds ?? []) {
|
||||
if (String(rel) === String(ticketId)) continue
|
||||
baseRelated.add(String(rel))
|
||||
}
|
||||
if (resolvedWithTicketId && String(resolvedWithTicketId) !== String(ticketId)) {
|
||||
baseRelated.add(String(resolvedWithTicketId))
|
||||
}
|
||||
|
||||
const linkedTickets: Doc<"tickets">[] = []
|
||||
for (const id of baseRelated) {
|
||||
const related = await ctx.db.get(id as Id<"tickets">)
|
||||
if (!related) continue
|
||||
if (related.tenantId !== ticketDoc.tenantId) {
|
||||
throw new ConvexError("Chamado vinculado pertence a outro tenant")
|
||||
}
|
||||
linkedTickets.push(related as Doc<"tickets">)
|
||||
}
|
||||
|
||||
const resolvedWith =
|
||||
resolvedWithTicketId && String(resolvedWithTicketId) !== String(ticketId)
|
||||
? (await ctx.db.get(resolvedWithTicketId)) ?? null
|
||||
: null
|
||||
if (resolvedWith && resolvedWith.tenantId !== ticketDoc.tenantId) {
|
||||
throw new ConvexError("Chamado vinculado pertence a outro tenant")
|
||||
}
|
||||
if (resolvedWithTicketId && !resolvedWith) {
|
||||
throw new ConvexError("Chamado vinculado não encontrado")
|
||||
}
|
||||
|
||||
const reopenDays = resolveReopenWindowDays(reopenWindowDays)
|
||||
const reopenDeadline = computeReopenDeadline(now, reopenDays)
|
||||
const normalizedStatus = "RESOLVED"
|
||||
const relatedIdList = Array.from(
|
||||
new Set<string>(
|
||||
linkedTickets.map((rel) => String(rel._id)),
|
||||
),
|
||||
).map((id) => id as Id<"tickets">)
|
||||
|
||||
await ctx.db.patch(ticketId, {
|
||||
status: normalizedStatus,
|
||||
resolvedAt: now,
|
||||
closedAt: now,
|
||||
updatedAt: now,
|
||||
reopenDeadline,
|
||||
reopenedAt: undefined,
|
||||
resolvedWithTicketId: resolvedWith ? resolvedWith._id : undefined,
|
||||
relatedTicketIds: relatedIdList.length ? relatedIdList : undefined,
|
||||
activeSessionId: undefined,
|
||||
working: false,
|
||||
})
|
||||
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "STATUS_CHANGED",
|
||||
payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId },
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
for (const rel of linkedTickets) {
|
||||
const existing = new Set<string>((rel.relatedTicketIds ?? []).map((value) => String(value)))
|
||||
existing.add(String(ticketId))
|
||||
await ctx.db.patch(rel._id, {
|
||||
relatedTicketIds: Array.from(existing).map((value) => value as Id<"tickets">),
|
||||
updatedAt: now,
|
||||
})
|
||||
const linkKind = resolvedWith && String(resolvedWith._id) === String(rel._id) ? "resolved_with" : "related"
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "TICKET_LINKED",
|
||||
payload: {
|
||||
actorId,
|
||||
actorName: viewer.user.name,
|
||||
linkedTicketId: rel._id,
|
||||
linkedReference: rel.reference ?? null,
|
||||
linkedSubject: rel.subject ?? null,
|
||||
kind: linkKind,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: rel._id,
|
||||
type: "TICKET_LINKED",
|
||||
payload: {
|
||||
actorId,
|
||||
actorName: viewer.user.name,
|
||||
linkedTicketId: ticketId,
|
||||
linkedReference: ticketDoc.reference ?? null,
|
||||
linkedSubject: ticketDoc.subject ?? null,
|
||||
kind: linkKind === "resolved_with" ? "resolution_parent" : "related",
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true, reopenDeadline, reopenWindowDays: reopenDays }
|
||||
}
|
||||
|
||||
export const resolveTicket = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
actorId: v.id("users"),
|
||||
resolvedWithTicketId: v.optional(v.id("tickets")),
|
||||
relatedTicketIds: v.optional(v.array(v.id("tickets"))),
|
||||
reopenWindowDays: v.optional(v.number()),
|
||||
},
|
||||
handler: resolveTicketHandler,
|
||||
})
|
||||
|
||||
export async function reopenTicketHandler(
|
||||
ctx: MutationCtx,
|
||||
{ ticketId, actorId }: { ticketId: Id<"tickets">; actorId: Id<"users"> }
|
||||
) {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
const ticketDoc = ticket as Doc<"tickets">
|
||||
const viewer = await requireUser(ctx, actorId, ticketDoc.tenantId)
|
||||
const normalizedRole = viewer.role ?? ""
|
||||
const now = Date.now()
|
||||
const status = normalizeStatus(ticketDoc.status)
|
||||
if (status !== "RESOLVED") {
|
||||
throw new ConvexError("Somente chamados resolvidos podem ser reabertos")
|
||||
}
|
||||
if (!isWithinReopenWindow(ticketDoc, now)) {
|
||||
throw new ConvexError("O prazo para reabrir este chamado expirou")
|
||||
}
|
||||
if (normalizedRole === "COLLABORATOR") {
|
||||
if (String(ticketDoc.requesterId) !== String(actorId)) {
|
||||
throw new ConvexError("Somente o solicitante pode reabrir este chamado")
|
||||
}
|
||||
} else if (normalizedRole === "MANAGER") {
|
||||
await ensureManagerTicketAccess(ctx, viewer.user, ticketDoc)
|
||||
} else if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
|
||||
throw new ConvexError("Usuário não possui permissão para reabrir este chamado")
|
||||
}
|
||||
|
||||
await ctx.db.patch(ticketId, {
|
||||
status: "AWAITING_ATTENDANCE",
|
||||
reopenedAt: now,
|
||||
resolvedAt: undefined,
|
||||
closedAt: undefined,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "TICKET_REOPENED",
|
||||
payload: { actorId, actorName: viewer.user.name, actorRole: normalizedRole },
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "STATUS_CHANGED",
|
||||
payload: { to: "AWAITING_ATTENDANCE", toLabel: STATUS_LABELS.AWAITING_ATTENDANCE, actorId },
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return { ok: true, reopenedAt: now }
|
||||
}
|
||||
|
||||
export const reopenTicket = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
actorId: v.id("users"),
|
||||
},
|
||||
handler: reopenTicketHandler,
|
||||
})
|
||||
|
||||
export const changeAssignee = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
assigneeId: v.id("users"),
|
||||
actorId: v.id("users"),
|
||||
reason: v.string(),
|
||||
reason: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { ticketId, assigneeId, actorId, reason }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
|
|
@ -1735,9 +2109,9 @@ export const changeAssignee = mutation({
|
|||
throw new ConvexError("Somente o responsável atual pode reatribuir este chamado")
|
||||
}
|
||||
|
||||
const normalizedReason = reason.replace(/\r\n/g, "\n").trim()
|
||||
if (normalizedReason.length < 5) {
|
||||
throw new ConvexError("Informe um motivo para registrar a troca de responsável")
|
||||
const normalizedReason = (typeof reason === "string" ? reason : "").replace(/\r\n/g, "\n").trim()
|
||||
if (normalizedReason.length > 0 && normalizedReason.length < 5) {
|
||||
throw new ConvexError("Informe um motivo com pelo menos 5 caracteres ou deixe em branco")
|
||||
}
|
||||
if (normalizedReason.length > 1000) {
|
||||
throw new ConvexError("Motivo muito longo (máx. 1000 caracteres)")
|
||||
|
|
@ -1746,14 +2120,6 @@ export const changeAssignee = mutation({
|
|||
((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ??
|
||||
"Não atribuído"
|
||||
const nextAssigneeName = assignee.name ?? assignee.email ?? "Responsável"
|
||||
const commentBody = buildAssigneeChangeComment(normalizedReason, {
|
||||
previousName: previousAssigneeName,
|
||||
nextName: nextAssigneeName,
|
||||
})
|
||||
const commentPlainLength = plainTextLength(commentBody)
|
||||
if (commentPlainLength > MAX_COMMENT_CHARS) {
|
||||
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const assigneeSnapshot = {
|
||||
|
|
@ -1772,11 +2138,20 @@ export const changeAssignee = mutation({
|
|||
actorId,
|
||||
previousAssigneeId: currentAssigneeId,
|
||||
previousAssigneeName,
|
||||
reason: normalizedReason,
|
||||
reason: normalizedReason.length > 0 ? normalizedReason : undefined,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
if (normalizedReason.length > 0) {
|
||||
const commentBody = buildAssigneeChangeComment(normalizedReason, {
|
||||
previousName: previousAssigneeName,
|
||||
nextName: nextAssigneeName,
|
||||
})
|
||||
const commentPlainLength = plainTextLength(commentBody)
|
||||
if (commentPlainLength > MAX_COMMENT_CHARS) {
|
||||
throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
|
||||
}
|
||||
const authorSnapshot: CommentAuthorSnapshot = {
|
||||
name: viewerUser.name,
|
||||
email: viewerUser.email,
|
||||
|
|
@ -1799,9 +2174,455 @@ export const changeAssignee = mutation({
|
|||
payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl },
|
||||
createdAt: now,
|
||||
})
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const listChatMessages = query({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
viewerId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, { ticketId, viewerId }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
const ticketDoc = ticket as Doc<"tickets">
|
||||
await requireTicketChatParticipant(ctx, viewerId, ticketDoc)
|
||||
const now = Date.now()
|
||||
const status = normalizeStatus(ticketDoc.status)
|
||||
const chatEnabled = Boolean(ticketDoc.chatEnabled)
|
||||
const withinWindow = isWithinReopenWindow(ticketDoc, now)
|
||||
const canPost = chatEnabled && (status !== "RESOLVED" || withinWindow)
|
||||
const messages = await ctx.db
|
||||
.query("ticketChatMessages")
|
||||
.withIndex("by_ticket_created", (q) => q.eq("ticketId", ticketId))
|
||||
.collect()
|
||||
|
||||
return {
|
||||
ticketId: String(ticketId),
|
||||
chatEnabled,
|
||||
status,
|
||||
canPost,
|
||||
reopenDeadline: ticketDoc.reopenDeadline ?? null,
|
||||
messages: messages
|
||||
.sort((a, b) => a.createdAt - b.createdAt)
|
||||
.map((message) => ({
|
||||
id: message._id,
|
||||
body: message.body,
|
||||
createdAt: message.createdAt,
|
||||
updatedAt: message.updatedAt,
|
||||
authorId: String(message.authorId),
|
||||
authorName: message.authorSnapshot?.name ?? null,
|
||||
authorEmail: message.authorSnapshot?.email ?? null,
|
||||
attachments: (message.attachments ?? []).map((attachment) => ({
|
||||
storageId: attachment.storageId,
|
||||
name: attachment.name,
|
||||
size: attachment.size ?? null,
|
||||
type: attachment.type ?? null,
|
||||
})),
|
||||
readBy: (message.readBy ?? []).map((entry) => ({
|
||||
userId: String(entry.userId),
|
||||
readAt: entry.readAt,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const listTicketForms = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, companyId }) => {
|
||||
const viewer = await requireUser(ctx, viewerId, tenantId)
|
||||
const viewerCompanyId = companyId ?? viewer.user.companyId ?? null
|
||||
|
||||
const settings = await ctx.db
|
||||
.query("ticketFormSettings")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
|
||||
const fieldDefinitions = await ctx.db
|
||||
.query("ticketFields")
|
||||
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
|
||||
const fieldsByScope = new Map<string, Doc<"ticketFields">[]>()
|
||||
for (const definition of fieldDefinitions) {
|
||||
const scope = (definition.scope ?? "all").trim()
|
||||
if (!fieldsByScope.has(scope)) {
|
||||
fieldsByScope.set(scope, [])
|
||||
}
|
||||
fieldsByScope.get(scope)!.push(definition)
|
||||
}
|
||||
|
||||
const forms = [] as Array<{
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
fields: Array<{
|
||||
id: Id<"ticketFields">
|
||||
key: string
|
||||
label: string
|
||||
type: string
|
||||
required: boolean
|
||||
description: string
|
||||
options: { value: string; label: string }[]
|
||||
}>
|
||||
}>
|
||||
|
||||
for (const template of TICKET_FORM_CONFIG) {
|
||||
const enabled = resolveFormEnabled(template.key, template.defaultEnabled, settings as Doc<"ticketFormSettings">[], {
|
||||
companyId: viewerCompanyId,
|
||||
userId: viewer.user._id,
|
||||
})
|
||||
if (!enabled) {
|
||||
continue
|
||||
}
|
||||
const scopedFields = fieldsByScope.get(template.key) ?? []
|
||||
forms.push({
|
||||
key: template.key,
|
||||
label: template.label,
|
||||
description: template.description,
|
||||
fields: scopedFields
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((field) => ({
|
||||
id: field._id,
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
required: Boolean(field.required),
|
||||
description: field.description ?? "",
|
||||
options: field.options ?? [],
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return forms
|
||||
},
|
||||
})
|
||||
|
||||
export const findByReference = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
reference: v.number(),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, reference }) => {
|
||||
const viewer = await requireUser(ctx, viewerId, tenantId)
|
||||
const ticket = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", tenantId).eq("reference", reference))
|
||||
.first()
|
||||
if (!ticket) {
|
||||
return null
|
||||
}
|
||||
const normalizedRole = viewer.role ?? ""
|
||||
if (normalizedRole === "MANAGER") {
|
||||
await ensureManagerTicketAccess(ctx, viewer.user, ticket as Doc<"tickets">)
|
||||
} else if (normalizedRole === "COLLABORATOR") {
|
||||
if (String(ticket.requesterId) !== String(viewer.user._id)) {
|
||||
return null
|
||||
}
|
||||
} else if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: ticket._id,
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const postChatMessage = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
actorId: v.id("users"),
|
||||
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()),
|
||||
})
|
||||
)
|
||||
),
|
||||
},
|
||||
handler: async (ctx, { ticketId, actorId, body, attachments }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
const ticketDoc = ticket as Doc<"tickets">
|
||||
if (!ticketDoc.chatEnabled) {
|
||||
throw new ConvexError("Chat não habilitado para este chamado")
|
||||
}
|
||||
const participant = await requireTicketChatParticipant(ctx, actorId, ticketDoc)
|
||||
const now = Date.now()
|
||||
if (!isWithinReopenWindow(ticketDoc, now) && normalizeStatus(ticketDoc.status) === "RESOLVED") {
|
||||
throw new ConvexError("O chat deste chamado está encerrado")
|
||||
}
|
||||
|
||||
const trimmedBody = body.replace(/\r\n/g, "\n").trim()
|
||||
if (trimmedBody.length === 0) {
|
||||
throw new ConvexError("Digite uma mensagem para enviar no chat")
|
||||
}
|
||||
if (trimmedBody.length > 4000) {
|
||||
throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)")
|
||||
}
|
||||
|
||||
const files = attachments ?? []
|
||||
if (files.length > 5) {
|
||||
throw new ConvexError("Envie até 5 arquivos por mensagem")
|
||||
}
|
||||
const maxAttachmentSize = 5 * 1024 * 1024
|
||||
for (const file of files) {
|
||||
if (typeof file.size === "number" && file.size > maxAttachmentSize) {
|
||||
throw new ConvexError("Cada arquivo pode ter até 5MB")
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedBody = await normalizeTicketMentions(ctx, trimmedBody, { user: participant.user, role: participant.role ?? "" }, ticketDoc.tenantId)
|
||||
const plainLength = plainTextLength(normalizedBody)
|
||||
if (plainLength === 0) {
|
||||
throw new ConvexError("A mensagem está vazia após a formatação")
|
||||
}
|
||||
if (plainLength > 4000) {
|
||||
throw new ConvexError("Mensagem muito longa (máx. 4000 caracteres)")
|
||||
}
|
||||
|
||||
const authorSnapshot: CommentAuthorSnapshot = {
|
||||
name: participant.user.name,
|
||||
email: participant.user.email,
|
||||
avatarUrl: participant.user.avatarUrl ?? undefined,
|
||||
teams: participant.user.teams ?? undefined,
|
||||
}
|
||||
|
||||
const messageId = await ctx.db.insert("ticketChatMessages", {
|
||||
ticketId,
|
||||
tenantId: ticketDoc.tenantId,
|
||||
companyId: ticketDoc.companyId ?? undefined,
|
||||
authorId: actorId,
|
||||
authorSnapshot,
|
||||
body: normalizedBody,
|
||||
attachments: files,
|
||||
notifiedAt: undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
readBy: [{ userId: actorId, readAt: now }],
|
||||
})
|
||||
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "CHAT_MESSAGE_ADDED",
|
||||
payload: {
|
||||
messageId,
|
||||
authorId: actorId,
|
||||
authorName: participant.user.name,
|
||||
actorRole: participant.role ?? null,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
await ctx.db.patch(ticketId, { updatedAt: now })
|
||||
|
||||
return { ok: true, messageId }
|
||||
},
|
||||
})
|
||||
|
||||
export const markChatRead = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
actorId: v.id("users"),
|
||||
messageIds: v.array(v.id("ticketChatMessages")),
|
||||
},
|
||||
handler: async (ctx, { ticketId, actorId, messageIds }) => {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
const ticketDoc = ticket as Doc<"tickets">
|
||||
await requireTicketChatParticipant(ctx, actorId, ticketDoc)
|
||||
const uniqueIds = Array.from(new Set(messageIds.map((id) => String(id))))
|
||||
const now = Date.now()
|
||||
for (const id of uniqueIds) {
|
||||
const message = await ctx.db.get(id as Id<"ticketChatMessages">)
|
||||
if (!message || String(message.ticketId) !== String(ticketId)) {
|
||||
continue
|
||||
}
|
||||
const readBy = new Map<string, { userId: Id<"users">; readAt: number }>()
|
||||
for (const entry of message.readBy ?? []) {
|
||||
readBy.set(String(entry.userId), { userId: entry.userId, readAt: entry.readAt })
|
||||
}
|
||||
readBy.set(String(actorId), { userId: actorId, readAt: now })
|
||||
await ctx.db.patch(id as Id<"ticketChatMessages">, {
|
||||
readBy: Array.from(readBy.values()),
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
})
|
||||
|
||||
export async function submitCsatHandler(
|
||||
ctx: MutationCtx,
|
||||
{ ticketId, actorId, score, maxScore, comment }: { ticketId: Id<"tickets">; actorId: Id<"users">; score: number; maxScore?: number | null; comment?: string | null }
|
||||
) {
|
||||
const ticket = await ctx.db.get(ticketId)
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
|
||||
const normalizedStatus = normalizeStatus(ticket.status)
|
||||
if (normalizedStatus !== "RESOLVED") {
|
||||
throw new ConvexError("Avaliações só são permitidas após o encerramento do chamado")
|
||||
}
|
||||
|
||||
const viewer = await requireUser(ctx, actorId, ticket.tenantId)
|
||||
const normalizedRole = (viewer.role ?? "").toUpperCase()
|
||||
if (normalizedRole !== "COLLABORATOR") {
|
||||
throw new ConvexError("Somente o solicitante pode avaliar o chamado")
|
||||
}
|
||||
|
||||
const viewerEmail = viewer.user.email.trim().toLowerCase()
|
||||
const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase() ?? null
|
||||
const isOwnerById = String(ticket.requesterId) === String(viewer.user._id)
|
||||
const isOwnerByEmail = snapshotEmail ? snapshotEmail === viewerEmail : false
|
||||
if (!isOwnerById && !isOwnerByEmail) {
|
||||
throw new ConvexError("Avaliação permitida apenas ao solicitante deste chamado")
|
||||
}
|
||||
|
||||
if (typeof ticket.csatScore === "number") {
|
||||
throw new ConvexError("Este chamado já possui uma avaliação registrada")
|
||||
}
|
||||
|
||||
if (!Number.isFinite(score)) {
|
||||
throw new ConvexError("Pontuação inválida")
|
||||
}
|
||||
const resolvedMaxScore =
|
||||
Number.isFinite(maxScore) && maxScore && maxScore > 0 ? Math.min(10, Math.round(maxScore)) : 5
|
||||
const normalizedScore = Math.max(1, Math.min(resolvedMaxScore, Math.round(score)))
|
||||
const normalizedComment =
|
||||
typeof comment === "string"
|
||||
? comment
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.join("\n")
|
||||
.trim()
|
||||
: ""
|
||||
if (normalizedComment.length > 2000) {
|
||||
throw new ConvexError("Comentário muito longo (máx. 2000 caracteres)")
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
let csatAssigneeId: Id<"users"> | undefined
|
||||
let csatAssigneeSnapshot:
|
||||
| {
|
||||
name: string
|
||||
email?: string
|
||||
avatarUrl?: string
|
||||
teams?: string[]
|
||||
}
|
||||
| undefined
|
||||
|
||||
if (ticket.assigneeId) {
|
||||
const assigneeDoc = (await ctx.db.get(ticket.assigneeId)) as Doc<"users"> | null
|
||||
if (assigneeDoc) {
|
||||
csatAssigneeId = assigneeDoc._id
|
||||
csatAssigneeSnapshot = {
|
||||
name: assigneeDoc.name,
|
||||
email: assigneeDoc.email,
|
||||
avatarUrl: assigneeDoc.avatarUrl ?? undefined,
|
||||
teams: Array.isArray(assigneeDoc.teams) ? assigneeDoc.teams : undefined,
|
||||
}
|
||||
} else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") {
|
||||
const snapshot = ticket.assigneeSnapshot as {
|
||||
name?: string
|
||||
email?: string
|
||||
avatarUrl?: string
|
||||
teams?: string[]
|
||||
}
|
||||
if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) {
|
||||
csatAssigneeId = ticket.assigneeId
|
||||
csatAssigneeSnapshot = {
|
||||
name: snapshot.name,
|
||||
email: snapshot.email ?? undefined,
|
||||
avatarUrl: snapshot.avatarUrl ?? undefined,
|
||||
teams: snapshot.teams ?? undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (ticket.assigneeSnapshot && typeof ticket.assigneeSnapshot === "object") {
|
||||
const snapshot = ticket.assigneeSnapshot as {
|
||||
name?: string
|
||||
email?: string
|
||||
avatarUrl?: string
|
||||
teams?: string[]
|
||||
}
|
||||
if (typeof snapshot.name === "string" && snapshot.name.trim().length > 0) {
|
||||
csatAssigneeSnapshot = {
|
||||
name: snapshot.name,
|
||||
email: snapshot.email ?? undefined,
|
||||
avatarUrl: snapshot.avatarUrl ?? undefined,
|
||||
teams: snapshot.teams ?? undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.patch(ticketId, {
|
||||
csatScore: normalizedScore,
|
||||
csatMaxScore: resolvedMaxScore,
|
||||
csatComment: normalizedComment.length > 0 ? normalizedComment : undefined,
|
||||
csatRatedAt: now,
|
||||
csatRatedBy: actorId,
|
||||
csatAssigneeId,
|
||||
csatAssigneeSnapshot,
|
||||
})
|
||||
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId,
|
||||
type: "CSAT_RATED",
|
||||
payload: {
|
||||
score: normalizedScore,
|
||||
maxScore: resolvedMaxScore,
|
||||
comment: normalizedComment.length > 0 ? normalizedComment : undefined,
|
||||
ratedBy: actorId,
|
||||
assigneeId: csatAssigneeId ?? null,
|
||||
assigneeName: csatAssigneeSnapshot?.name ?? null,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
score: normalizedScore,
|
||||
maxScore: resolvedMaxScore,
|
||||
comment: normalizedComment.length > 0 ? normalizedComment : null,
|
||||
ratedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
export const submitCsat = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
actorId: v.id("users"),
|
||||
score: v.number(),
|
||||
maxScore: v.optional(v.number()),
|
||||
comment: v.optional(v.string()),
|
||||
},
|
||||
handler: submitCsatHandler,
|
||||
})
|
||||
|
||||
export const changeRequester = mutation({
|
||||
args: { ticketId: v.id("tickets"), requesterId: v.id("users"), actorId: v.id("users") },
|
||||
handler: async (ctx, { ticketId, requesterId, actorId }) => {
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ Nota: este documento foi substituído por `docs/operations.md` e permanece aqui
|
|||
- Seeds prontos (Better Auth e dados demo Convex).
|
||||
- Seeds Better Auth automáticos: o container do web executa `pnpm auth:seed` após `prisma migrate deploy`, garantindo usuários padrão em toda inicialização (sem resetar senha existente por padrão).
|
||||
|
||||
### Sessão de máquina (Desktop/Tauri)
|
||||
### Sessão de dispositivo (Desktop/Tauri)
|
||||
- A rota `GET /machines/handshake?token=...&redirect=/portal|/dashboard` é pública no middleware para permitir a criação da sessão "machine" a partir do agente desktop, mesmo sem login prévio.
|
||||
- Após o handshake, o servidor grava cookies de sessão (Better Auth) e um cookie `machine_ctx` com o vínculo (persona, assignedUser*, etc.). Em seguida, o App carrega `/api/machines/session` para preencher o contexto no cliente (`machineContext`).
|
||||
- Com esse contexto, o Portal exibe corretamente o nome/e‑mail do colaborador/gestor no cabeçalho e permite abrir chamados em nome do usuário vinculado.
|
||||
- Em sessões de máquina, o botão "Encerrar sessão" no menu do usuário é ocultado por padrão na UI interna.
|
||||
- Em sessões de dispositivo, o botão "Encerrar sessão" no menu do usuário é ocultado por padrão na UI interna.
|
||||
|
||||
#### Detalhes importantes (aprendidos em produção)
|
||||
- CORS com credenciais: as rotas `POST /api/machines/sessions` e `GET /machines/handshake` precisam enviar `Access-Control-Allow-Credentials: true` para que os cookies do Better Auth sejam aceitos na WebView.
|
||||
|
|
@ -72,7 +72,7 @@ SMTP_ENABLE_STARTTLS_AUTO=false
|
|||
SMTP_TLS=true
|
||||
MAILER_SENDER_EMAIL="Nome <no-reply@seu-dominio.com>"
|
||||
|
||||
# Máquina/inventário
|
||||
# Dispositivo/inventário
|
||||
MACHINE_PROVISIONING_SECRET=<hex forte>
|
||||
MACHINE_TOKEN_TTL_MS=2592000000
|
||||
FLEET_SYNC_SECRET=<hex forte ou igual ao de provisionamento>
|
||||
|
|
@ -239,7 +239,7 @@ docker run --rm -it \
|
|||
|
||||
### Smoke test pós‑deploy (CI)
|
||||
O pipeline executa um teste rápido após o deploy do Web:
|
||||
- Registra uma máquina fake usando `MACHINE_PROVISIONING_SECRET` do `/srv/apps/sistema/.env`
|
||||
- Registra uma dispositivo fake usando `MACHINE_PROVISIONING_SECRET` do `/srv/apps/sistema/.env`
|
||||
- Espera `HTTP 201` e extrai `machineToken`
|
||||
- Envia `heartbeat` e espera `HTTP 200`
|
||||
- Se falhar, o job é marcado como erro (evita regressões silenciosas)
|
||||
|
|
|
|||
|
|
@ -59,28 +59,28 @@ Este documento consolida as mudanças recentes, o racional por trás delas e o p
|
|||
- “Bring convex.json from live app if present” (usa o arquivo de link do projeto em `/srv/apps/sistema`).
|
||||
- “convex env list” e “convex deploy” com `CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY`.
|
||||
|
||||
## 4) Troca de colaborador / reaproveitamento de máquina
|
||||
## 4) Troca de colaborador / reaproveitamento de dispositivo
|
||||
|
||||
Quando um computador muda de dono (ex.: João entrega o equipamento antigo para Maria e recebe uma máquina nova), siga este checklist para manter o inventário consistente:
|
||||
Quando um computador muda de dono (ex.: João entrega o equipamento antigo para Maria e recebe uma dispositivo nova), siga este checklist para manter o inventário consistente:
|
||||
|
||||
1. **No painel (Admin → Máquinas)**
|
||||
- Abra os detalhes da máquina que será reaproveitada (ex.: a “amarela” que passará da TI/João para a Maria).
|
||||
1. **No painel (Admin → Dispositivos)**
|
||||
- Abra os detalhes da dispositivo que será reaproveitada (ex.: a “amarela” que passará da TI/João para a Maria).
|
||||
- Clique em **Resetar agente**. Isso revoga todos os tokens gerados para aquele equipamento; ele precisará ser reprovisionado antes de voltar a reportar dados.
|
||||
- Abra **Ajustar acesso** e altere o e-mail para o do novo usuário (Maria). Assim, quando o agente se registrar novamente, o painel já mostrará a responsável correta.
|
||||
|
||||
2. **Na máquina física que ficará com o novo colaborador**
|
||||
2. **Na dispositivo física que ficará com o novo colaborador**
|
||||
- Desinstale o desktop agent (Painel de Controle → remover programas).
|
||||
- Instale novamente o desktop agent. Use o mesmo **código da empresa/tenant** e informe o **e-mail do novo usuário** (Maria). O backend emite um token novo e reaproveita o registro da máquina, mantendo o histórico.
|
||||
- Instale novamente o desktop agent. Use o mesmo **código da empresa/tenant** e informe o **e-mail do novo usuário** (Maria). O backend emite um token novo e reaproveita o registro da dispositivo, mantendo o histórico.
|
||||
|
||||
3. **Máquina nova para o colaborador antigo**
|
||||
- Instale o desktop agent do zero na máquina que o João vai usar (ex.: a “azul”). Utilize o mesmo código da empresa e o e-mail do João.
|
||||
- A máquina azul aparecerá como um **novo registro** no painel (inventário/tickets começarão do zero). Renomeie/associe conforme necessário.
|
||||
3. **Dispositivo nova para o colaborador antigo**
|
||||
- Instale o desktop agent do zero na dispositivo que o João vai usar (ex.: a “azul”). Utilize o mesmo código da empresa e o e-mail do João.
|
||||
- A dispositivo azul aparecerá como um **novo registro** no painel (inventário/tickets começarão do zero). Renomeie/associe conforme necessário.
|
||||
|
||||
4. **Verificação final**
|
||||
- A máquina antiga (amarela) continua listada, agora vinculada à Maria, com seus tickets históricos.
|
||||
- A máquina nova (azul) aparece como um segundo registro para o João. Ajuste hostname/descrição para facilitar a identificação.
|
||||
- A dispositivo antiga (amarela) continua listada, agora vinculada à Maria, com seus tickets históricos.
|
||||
- A dispositivo nova (azul) aparece como um segundo registro para o João. Ajuste hostname/descrição para facilitar a identificação.
|
||||
|
||||
> Não é necessário excluir registros. Cada máquina mantém seu histórico; o reset garante apenas que o token antigo não volte a sobrescrever dados quando o hardware mudar de mãos.
|
||||
> Não é necessário excluir registros. Cada dispositivo mantém seu histórico; o reset garante apenas que o token antigo não volte a sobrescrever dados quando o hardware mudar de mãos.
|
||||
- Importante: não usar `CONVEX_DEPLOYMENT` em conjunto com URL + ADMIN_KEY.
|
||||
|
||||
- Como forçar o deploy do Convex
|
||||
|
|
@ -161,7 +161,7 @@ Depois disso, o job “Deploy Convex functions” funciona em modo não interati
|
|||
- Front envia `assigneeId` e o backend Convex deve aceitar esse parâmetro (função `tickets.list`).
|
||||
- Se necessário, forçar redeploy das funções (`convex/**`).
|
||||
|
||||
- Admin ▸ Máquinas travado em skeleton infinito
|
||||
- Admin ▸ Dispositivos travado em skeleton infinito
|
||||
- Abra o DevTools (console) e filtre por `admin-machine-details`. Se o log mostrar `machineId: undefined`, o componente não recebeu o parâmetro da rota.
|
||||
- Verifique se o `page.tsx` está passando `params.id` corretamente ou se o componente client-side usa `useParams()` / `machineId` opcional.
|
||||
- Deploys antigos antes de `fix(machines): derive machine id from router params` precisam desse patch; sem ele o fallback nunca dispara e o skeleton permanece.
|
||||
|
|
@ -170,30 +170,30 @@ Depois disso, o job “Deploy Convex functions” funciona em modo não interati
|
|||
|
||||
Última atualização: sincronizado após o deploy bem‑sucedido do Convex e do Front (20/10/2025).
|
||||
|
||||
## 9) Admin ▸ Usuários e Máquinas — Unificação e UX
|
||||
## 9) Admin ▸ Usuários e Dispositivos — Unificação e UX
|
||||
|
||||
Resumo das mudanças aplicadas no painel administrativo para simplificar “Usuários” e “Agentes de máquina” e melhorar o filtro em Máquinas:
|
||||
Resumo das mudanças aplicadas no painel administrativo para simplificar “Usuários” e “Agentes de dispositivo” e melhorar o filtro em Dispositivos:
|
||||
|
||||
- Unificação de “Usuários” e “Agentes de máquina”
|
||||
- Antes: abas separadas “Usuários” (pessoas) e “Agentes de máquina”.
|
||||
- Agora: uma só aba “Usuários” com filtro de tipo (Todos | Pessoas | Máquinas).
|
||||
- Unificação de “Usuários” e “Agentes de dispositivo”
|
||||
- Antes: abas separadas “Usuários” (pessoas) e “Agentes de dispositivo”.
|
||||
- Agora: uma só aba “Usuários” com filtro de tipo (Todos | Pessoas | Dispositivos).
|
||||
- Onde: `src/components/admin/admin-users-manager.tsx:923`, aba `value="users"` em `:1147`.
|
||||
- Motivo: evitar confusão entre “usuário” e “agente”; agentes são um tipo especial de usuário (role=machine). A unificação torna “Convites e Acessos” mais direta.
|
||||
|
||||
- Máquinas ▸ Filtro por Empresa com busca e remoção do filtro de SO
|
||||
- Dispositivos ▸ Filtro por Empresa com busca e remoção do filtro de SO
|
||||
- Adicionado dropdown de “Empresa” com busca (Popover + Input) e removido o filtro por Sistema Operacional.
|
||||
- Onde: `src/components/admin/machines/admin-machines-overview.tsx:1038` e `:1084`.
|
||||
- Onde: `src/components/admin/devices/admin-devices-overview.tsx:1038` e `:1084`.
|
||||
- Motivo: fluxo real usa empresas com mais frequência; filtro por SO era menos útil agora.
|
||||
|
||||
- Windows ▸ Rótulo do sistema sem duplicidade do “major”
|
||||
- Exemplo: “Windows 11 Pro (26100)” em vez de “Windows 11 Pro 11 (26100)”.
|
||||
- Onde: `src/components/admin/machines/admin-machines-overview.tsx` (função `formatOsVersionDisplay`).
|
||||
- Onde: `src/components/admin/devices/admin-devices-overview.tsx` (função `formatOsVersionDisplay`).
|
||||
- Motivo: legibilidade e padronização em chips/cartões.
|
||||
|
||||
- Vínculos visuais entre máquinas e pessoas
|
||||
- Cards de máquinas mostram “Usuário vinculado” quando disponível (assignment/metadata): `src/components/admin/machines/admin-machines-overview.tsx:3198`.
|
||||
- Editor de usuário exibe “Máquinas vinculadas” (derivado de assign/metadata): `src/components/admin/admin-users-manager.tsx` (seção “Máquinas vinculadas” no sheet de edição).
|
||||
- Observação: por ora é leitura; ajustes detalhados de vínculo permanecem em Admin ▸ Máquinas.
|
||||
- Vínculos visuais entre dispositivos e pessoas
|
||||
- Cards de dispositivos mostram “Usuário vinculado” quando disponível (assignment/metadata): `src/components/admin/devices/admin-devices-overview.tsx:3198`.
|
||||
- Editor de usuário exibe “Dispositivos vinculadas” (derivado de assign/metadata): `src/components/admin/admin-users-manager.tsx` (seção “Dispositivos vinculadas” no sheet de edição).
|
||||
- Observação: por ora é leitura; ajustes detalhados de vínculo permanecem em Admin ▸ Dispositivos.
|
||||
|
||||
### Identidade, e‑mail e histórico (reinstalação)
|
||||
|
||||
|
|
@ -202,20 +202,20 @@ Resumo das mudanças aplicadas no painel administrativo para simplificar “Usu
|
|||
- Novo e‑mail 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 e‑mail da mesma conta.
|
||||
|
||||
### Vínculos múltiplos de usuários por máquina (Fase 2)
|
||||
### Vínculos múltiplos de usuários por dispositivo (Fase 2)
|
||||
|
||||
- Estrutura (Convex):
|
||||
- `machines.linkedUserIds: Id<"users">[]` — lista de vínculos adicionais além do `assignedUserId` (principal).
|
||||
- Mutations: `machines.linkUser(machineId, email)`, `machines.unlinkUser(machineId, userId)`.
|
||||
- APIs admin: `POST /api/admin/machines/links` (body: `{ machineId, email }`), `DELETE /api/admin/machines/links?machineId=..&userId=..`.
|
||||
- APIs admin: `POST /api/admin/devices/links` (body: `{ machineId, email }`), `DELETE /api/admin/devices/links?machineId=..&userId=..`.
|
||||
- UI:
|
||||
- Detalhes da máquina mostram “Usuários vinculados” com remoção por item e campo para adicionar por e‑mail.
|
||||
- Editor de usuário mostra “Máquinas vinculadas” consolidando assignment, metadata e `linkedUserIds`.
|
||||
- Racional: permitir que uma máquina tenha mais de um colaborador/gestor associado, mantendo um “principal” (persona) para políticas e contexto.
|
||||
- Detalhes da dispositivo mostram “Usuários vinculados” com remoção por item e campo para adicionar por e‑mail.
|
||||
- Editor de usuário mostra “Dispositivos vinculadas” consolidando assignment, metadata e `linkedUserIds`.
|
||||
- Racional: permitir que uma dispositivo tenha mais de um colaborador/gestor associado, mantendo um “principal” (persona) para políticas e contexto.
|
||||
|
||||
### Onde editar
|
||||
|
||||
- Usuários (pessoas): editar nome, e‑mail, 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/e‑mail 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/e‑mail técnico), e o acesso web segue apenas para pessoas. A unificação é de UX/gestão.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Admin UI — Inventário por máquina
|
||||
# Admin UI — Inventário por dispositivo
|
||||
|
||||
A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável do parque, com filtros e exportação.
|
||||
A página Admin > Dispositivos agora exibe um inventário detalhado e pesquisável do parque, com filtros e exportação.
|
||||
|
||||
## Filtros e busca
|
||||
- Busca livre por hostname, e-mail, MAC e número de série.
|
||||
|
|
@ -19,15 +19,15 @@ A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável
|
|||
- Windows: informações de SO (edição, versão, build, data de instalação, experiência, ativação), resumo de hardware (CPU/Memória/GPU/Discos físicos com suporte a payloads únicos), serviços, lista completa de softwares com ação “Ver todos”, Defender.
|
||||
- macOS: pacotes (`pkgutil`), serviços (`launchctl`).
|
||||
- Postura/Alertas: CPU alta, serviço parado, SMART em falha com severidade e última avaliação.
|
||||
- Zona perigosa: ação para excluir a máquina (revoga tokens e remove inventário).
|
||||
- Ação administrativa extra: botão “Ajustar acesso” permite trocar colaborador/gestor e e-mail vinculados sem re-provisionar a máquina.
|
||||
- Zona perigosa: ação para excluir a dispositivo (revoga tokens e remove inventário).
|
||||
- Ação administrativa extra: botão “Ajustar acesso” permite trocar colaborador/gestor e e-mail vinculados sem re-provisionar a dispositivo.
|
||||
|
||||
## Exportação
|
||||
- Exportar CSV de softwares ou serviços diretamente da seção detalhada (quando disponíveis).
|
||||
- Exportar planilha XLSX completa (`/admin/machines/:id/inventory.xlsx`). A partir de 31/10/2025 a planilha contém:
|
||||
- Exportar planilha XLSX completa (`/admin/devices/:id/inventory.xlsx`). A partir de 31/10/2025 a planilha contém:
|
||||
- **Resumo**: data de geração, filtros aplicados, contagem por status e total de acessos remotos/alertas.
|
||||
- **Inventário**: colunas principais exibidas na UI (status, persona, hardware, token, build/licença do SO, domínio, colaborador, Fleet, etc.).
|
||||
- **Vínculos**: usuários associados à máquina.
|
||||
- **Vínculos**: usuários associados à dispositivo.
|
||||
- **Softwares**: lista deduplicada (nome + versão + origem/publisher). A coluna “Softwares instalados” no inventário bate com o total desta aba.
|
||||
- **Partições**: nome/mount/FS/capacidade/livre, convertendo unidades (ex.: 447 GB → bytes).
|
||||
- **Discos físicos**: modelo, tamanho, interface, tipo e serial de cada drive.
|
||||
|
|
@ -36,7 +36,7 @@ A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável
|
|||
- **Serviços**: serviços coletados (Windows/Linux) com nome, display name e status.
|
||||
- **Alertas**: postura recente (tipo, mensagem, severidade, criado em).
|
||||
- **Métricas**: CPU/Memória/Disco/GPU com timestamp coletado.
|
||||
- **Labels**: tags aplicadas à máquina.
|
||||
- **Labels**: tags aplicadas à dispositivo.
|
||||
- **Sistema**: visão categorizada (Sistema, Dispositivo, Hardware, Acesso, Token, Fleet) contendo build, licença, domínio, fabricante, serial, colaborador, contagem de acessos, etc.
|
||||
|
||||
## Notas
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Este documento resume a ampliação do cadastro de empresas (dados fiscais, cont
|
|||
- **Nova UI de Empresas** (`AdminCompaniesManager`): substituir pelo layout com listagem filtrável (lista + quadro) e formulário seccional ligado a `companyFormSchema` / `sanitizeCompanyInput`.
|
||||
- **Form dinâmico**: montar o formulário com React Hook Form + zod resolver usando os schemas/validações já prontos no backend.
|
||||
- **Área de Clientes → Usuários**: renomear a seção, carregar os novos campos (contatos, localizações, contratos) e reaproveitar as transformações do serviço.
|
||||
- **Máquinas**: expor o novo identificador de acesso remoto previsto no schema Convex/Prisma.
|
||||
- **Dispositivos**: expor o novo identificador de acesso remoto previsto no schema Convex/Prisma.
|
||||
- **Qualidade**: ajustar lint/testes após a nova UI e cobrir o fluxo de criação/edição com testes de integração.
|
||||
|
||||
> Até que a nova interface seja publicada, a API já aceita todos os campos e qualquer cliente (front, automação, seed) deve usar `company-service.ts` para converter dados de/para Prisma, evitando divergências.
|
||||
|
|
|
|||
27
docs/alteracoes-2025-11-03.md
Normal file
27
docs/alteracoes-2025-11-03.md
Normal 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).
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
# Plano Integrado – App Desktop & Inventário por Máquina (Arquivo)
|
||||
# Plano Integrado – App Desktop & Inventário por Dispositivo (Arquivo)
|
||||
|
||||
> Documento vivo. Atualize após cada marco relevante.
|
||||
|
||||
## Contexto
|
||||
- **Objetivo:** Expandir o Sistema de Chamados (Next.js + Convex + Better Auth) para suportar:
|
||||
- Cliente desktop nativo (Tauri) mantendo UI web e realtime.
|
||||
- Autenticação máquina-a-máquina usando tokens derivados do inventário.
|
||||
- Autenticação dispositivo-a-dispositivo usando tokens derivados do inventário.
|
||||
- Integração com agente de inventário (osquery/FleetDM) para registrar hardware, software e heartbeats.
|
||||
- Pipeline de distribuição para Windows/macOS/Linux.
|
||||
- **Escopo inicial:** Focar no fluxo mínimo viável com inventário básico (hostname, OS, identificadores, carga resumida). Métricas avançadas e distribuição automatizada ficam para iteração seguinte.
|
||||
|
|
@ -21,12 +21,12 @@
|
|||
| --- | --- | --- |
|
||||
| Documento de arquitetura e roadmap | 🔄 Em andamento | Estrutura criada, aguardando detalhamento incremental a cada etapa. |
|
||||
| Projeto Tauri inicial apontando para UI Next | 🔄 Em andamento | Estrutura `apps/desktop` criada; pendente testar build após instalar toolchain Rust. |
|
||||
| Schema Convex + tokens de máquina | ✅ Concluído | Tabelas `machines` / `machineTokens` criadas com TTL e fingerprint. |
|
||||
| Schema Convex + tokens de dispositivo | ✅ Concluído | Tabelas `machines` / `machineTokens` criadas com TTL e fingerprint. |
|
||||
| API de registro/heartbeat e exchange Better Auth | 🔄 Em andamento | Endpoints `/api/machines/*` disponíveis; falta testar fluxo end-to-end com app desktop. |
|
||||
| Endpoint upsert de inventário dedicado | ✅ Concluído | `POST /api/machines/inventory` (modo por token ou provisioningSecret). |
|
||||
| Integração FleetDM → Convex (inventário básico) | 🔄 Em andamento | Endpoint `/api/integrations/fleet/hosts` criado; falta validar payload real e ajustes de métricas/empresa. |
|
||||
| Admin > Máquinas (listagem, detalhes, métricas) | ✅ Concluído | Página `/admin/machines` exibe parque completo com status ao vivo, inventário e métricas. |
|
||||
| Ajustes na UI/Next para sessão por máquina | ⏳ A fazer | Detectar token e exibir info da máquina em tickets. |
|
||||
| Admin > Dispositivos (listagem, detalhes, métricas) | ✅ Concluído | Página `/admin/devices` exibe parque completo com status ao vivo, inventário e métricas. |
|
||||
| Ajustes na UI/Next para sessão por dispositivo | ⏳ A fazer | Detectar token e exibir info da dispositivo em tickets. |
|
||||
| Pipeline de build/distribuição Tauri | ⏳ A fazer | Definir estratégia CI/CD + auto-update. |
|
||||
| Guia operacional (instalação, uso, suporte) | ⏳ A fazer | Gerar instruções finais com casos de uso. |
|
||||
|
||||
|
|
@ -48,23 +48,23 @@ Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer.
|
|||
|
||||
## Notas de Implementação (Atual)
|
||||
- Criada pasta `apps/desktop` via `create-tauri-app` com template `vanilla-ts`.
|
||||
- O agente desktop agora possui fluxo próprio: coleta inventário local via comandos Rust, solicita o código de provisionamento, registra a máquina e inicia heartbeats periódicos (`src-tauri/src/agent.rs` + `src/main.ts`).
|
||||
- O agente desktop agora possui fluxo próprio: coleta inventário local via comandos Rust, solicita o código de provisionamento, registra a dispositivo e inicia heartbeats periódicos (`src-tauri/src/agent.rs` + `src/main.ts`).
|
||||
- Formulário inicial exibe resumo de hardware/OS e salva o token em `~/.config/Sistema de Chamados Desktop/machine-agent.json` (ou equivalente por SO) para reaproveitamento em relançamentos.
|
||||
- URLs configuráveis via `.env` do app desktop:
|
||||
- `VITE_APP_URL` → aponta para a interface Next (padrao produção: `https://tickets.esdrasrenan.com.br`).
|
||||
- `VITE_API_BASE_URL` → base usada nas chamadas REST (`/api/machines/*`), normalmente igual ao `APP_URL`.
|
||||
- Após provisionar ou encontrar token válido, o agente dispara `/machines/handshake?token=...` que autentica a máquina no Better Auth, devolve cookies e redireciona para a UI. Em produção, mantemos a navegação top‑level 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 top‑level pelo handshake para garantir a aceitação de cookies na WebView (mesmo quando `POST /api/machines/sessions` é tentado antes).
|
||||
- `apps/desktop/src-tauri/tauri.conf.json` ajustado para rodar `pnpm run dev/build`, servir `dist/` e abrir janela 1100x720.
|
||||
- Novas tabelas Convex: `machines` (fingerprint, heartbeat, vínculo com AuthUser) e `machineTokens` (hash + TTL).
|
||||
- Novos endpoints Next:
|
||||
- `POST /api/machines/register` — provisiona máquina, gera token e usuário Better Auth (role `machine`).
|
||||
- `POST /api/machines/register` — provisiona dispositivo, gera token e usuário Better Auth (role `machine`).
|
||||
- `POST /api/machines/heartbeat` — atualiza estado, métricas e renova TTL.
|
||||
- `POST /api/machines/sessions` — troca `machineToken` por sessão Better Auth e devolve cookies.
|
||||
- As rotas `/api/machines/*` respondem a preflight `OPTIONS` com CORS liberado para o agente (`https://tickets.esdrasrenan.com.br`, `tauri://localhost`, `http://localhost:1420`).
|
||||
- Rota `GET /machines/handshake` realiza o login automático da máquina (seta cookies e redireciona).
|
||||
- Rota `GET /machines/handshake` realiza o login automático da dispositivo (seta cookies e redireciona).
|
||||
- As rotas `sessions/handshake` foram ajustadas para usar `NextResponse.cookies.set(...)`, aplicando cada cookie da Better Auth (sessão e assinatura) individualmente.
|
||||
- CORS: as respostas incluem `Access-Control-Allow-Credentials: true` para origens permitidas (Tauri WebView e app).
|
||||
- Página de diagnóstico: `/portal/debug` exibe `get-session` e `machines/session` com os mesmos cookies da aba — útil para validar se o desktop está autenticado como máquina.
|
||||
- Página de diagnóstico: `/portal/debug` exibe `get-session` e `machines/session` com os mesmos cookies da aba — útil para validar se o desktop está autenticado como dispositivo.
|
||||
- O desktop pode redirecionar automaticamente para essa página durante os testes.
|
||||
- Webhook FleetDM: `POST /api/integrations/fleet/hosts` (header `x-fleet-secret`) sincroniza inventário/métricas utilizando `machines.upsertInventory`.
|
||||
- Script `ensureMachineAccount` garante usuário `AuthUser` e senha sincronizada com o token atual.
|
||||
|
|
@ -73,7 +73,7 @@ Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer.
|
|||
- O agente coleta `extended.windows.osInfo` via PowerShell/WMI. Caso o script falhe (política ou permissão), aplicamos um fallback com `sysinfo` para preencher ao menos `ProductName`, `Version` e `BuildNumber` — evitando bloco vazio no inventário exibido.
|
||||
|
||||
### Portal do Cliente (UX)
|
||||
- Quando autenticado como máquina (colaborador/gestor):
|
||||
- Quando autenticado como dispositivo (colaborador/gestor):
|
||||
- O portal deriva o papel a partir do `machineContext` (mesmo se `/api/auth/get-session` vier `null`).
|
||||
- A listagem exibe apenas os tickets onde o colaborador é o solicitante.
|
||||
- Informações internas (Fila, Prioridade) são ocultadas; o responsável aparece quando definido.
|
||||
|
|
@ -81,7 +81,7 @@ Legenda: ✅ concluído · 🔄 em andamento · ⏳ a fazer.
|
|||
- O botão “Sair” é ocultado no desktop (faz sentido apenas fechar o app).
|
||||
- Variáveis `.env` novas: `MACHINE_PROVISIONING_SECRET` (obrigatória) e `MACHINE_TOKEN_TTL_MS` (opcional, padrão 30 dias).
|
||||
- Variável adicional `FLEET_SYNC_SECRET` (opcional) para autenticar webhook do Fleet; se ausente, reutiliza `MACHINE_PROVISIONING_SECRET`.
|
||||
- Dashboard administrativo: `/admin/machines` usa `AdminMachinesOverview` com dados em tempo real (status, heartbeat, token, inventário enviado pelo agente/Fleet).
|
||||
- Dashboard administrativo: `/admin/devices` usa `AdminMachinesOverview` com dados em tempo real (status, heartbeat, token, inventário enviado pelo agente/Fleet).
|
||||
|
||||
### Checklist de dependências Tauri (Linux)
|
||||
```bash
|
||||
|
|
@ -13,8 +13,8 @@ Documento de referência sobre o estado atual do sistema (web + desktop), melhor
|
|||
|
||||
| Item | Descrição | Impacto |
|
||||
| --- | --- | --- |
|
||||
| **Centralização Convex** | Helpers `createConvexClient` e normalização do cookie da máquina (`src/server/convex-client.ts`, `src/server/machines/context.ts`). | Código das rotas `/api/machines/*` ficou mais enxuto e resiliente a erros de configuração. |
|
||||
| **Auth/Login redirect** | Redirecionamento baseado em role/persona sem uso de `any`, com dependências explícitas (`src/app/login/login-page-client.tsx`). | Evita warnings de hooks e garante rota correta para máquinas/colaboradores. |
|
||||
| **Centralização Convex** | Helpers `createConvexClient` e normalização do cookie da dispositivo (`src/server/convex-client.ts`, `src/server/machines/context.ts`). | Código das rotas `/api/machines/*` ficou mais enxuto e resiliente a erros de configuração. |
|
||||
| **Auth/Login redirect** | Redirecionamento baseado em role/persona sem uso de `any`, com dependências explícitas (`src/app/login/login-page-client.tsx`). | Evita warnings de hooks e garante rota correta para dispositivos/colaboradores. |
|
||||
| **Ticket header** | Sincronização do responsável com dependências completas (`ticket-summary-header.tsx`). | Removeu warning do lint e previne estados inconsistentes. |
|
||||
| **Upgrade para Next.js 16 beta** | Dependências atualizadas (`next@16.0.0-beta.0`, `eslint-config-next@16.0.0-beta.0`), cache de filesystem do Turbopack habilitado, scripts de lint/test/build ajustados ao novo fluxo. | Projeto pronto para validar as novidades do Next 16 (React Compiler opcional, prefetch incremental, etc.); builds e testes já rodando com sucesso. |
|
||||
| **Posture / inventário** | Type guards e normalização de métricas SMART/serviços (`convex/machines.ts`). | Reduziu `any`, melhorou detecção de alertas e consistência do metadata. |
|
||||
|
|
@ -39,7 +39,7 @@ Documento de referência sobre o estado atual do sistema (web + desktop), melhor
|
|||
|
||||
| Cenário | Sintoma | Como resolver |
|
||||
| --- | --- | --- |
|
||||
| Token de máquina revogado | POST `/api/machines/sessions` retorna 401 e desktop volta ao onboarding | Reprovisionar pela UI do agente; garantir que `machineToken` foi atualizado. |
|
||||
| Token de dispositivo revogado | POST `/api/machines/sessions` retorna 401 e desktop volta ao onboarding | Reprovisionar pela UI do agente; garantir que `machineToken` foi atualizado. |
|
||||
| Falha de heartbeat | Logs com `Falha ao registrar heartbeat` + status 500 | Verificar `NEXT_PUBLIC_CONVEX_URL` e conectividade. Roda `pnpm convext:dev` em DEV para confirmar schema. |
|
||||
| Updater sem atualização | Desktop fica em “Procurando atualização” indefinidamente | Confirmar release publicado com `latest.json` apontando para URLs públicas do bundle e assinaturas válidas. |
|
||||
|
||||
|
|
@ -47,6 +47,6 @@ Documento de referência sobre o estado atual do sistema (web + desktop), melhor
|
|||
|
||||
- Monitorar execução do novo workflow de qualidade em PRs.
|
||||
- Garantir que a equipe esteja ciente do procedimento atualizado de deploy (symlink + service update).
|
||||
- Revisar backlog acima e priorizar smoke tests para o fluxo da máquina.
|
||||
- Revisar backlog acima e priorizar smoke tests para o fluxo da dispositivo.
|
||||
|
||||
_Última atualização: 16/10/2025 (UTC-3)._
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
# Desktop (Tauri) — Handshake, Sessão de Máquina e Antivírus
|
||||
# Desktop (Tauri) — Handshake, Sessão de Dispositivo e Antivírus
|
||||
|
||||
Este documento consolida as orientações e diagnósticos sobre o fluxo do agente desktop, handshake na web e possíveis interferências de antivírus.
|
||||
|
||||
## Sintomas observados
|
||||
- Ao clicar em “Registrar máquina”, o antivírus aciona (ex.: ATC.SuspiciousBehavior) e o processo é interrompido.
|
||||
- Ao clicar em “Registrar dispositivo”, o antivírus aciona (ex.: ATC.SuspiciousBehavior) e o processo é interrompido.
|
||||
- Após o registro, ao abrir a UI web: cabeçalho mostra “Cliente / Sem e‑mail definido” e o Portal não permite abrir chamados.
|
||||
- No passado, mesmo quando o app “entrava direto”, o Portal não refletia o colaborador/gestor vinculado (sem assignedUser); receio de repetir o problema.
|
||||
|
||||
|
|
@ -19,11 +19,11 @@ Este documento consolida as orientações e diagnósticos sobre o fluxo do agent
|
|||
## O que já foi aplicado no projeto
|
||||
- Middleware permite `GET /machines/handshake` sem exigir login (rota pública).
|
||||
- Frontend preenche `machineContext` chamando `GET /api/machines/session` (assignedUserId/email/nome/persona) e usa esse ID ao abrir chamados.
|
||||
- UI oculta “Sair” quando a sessão é de máquina (portal e shell interno).
|
||||
- UI oculta “Sair” quando a sessão é de dispositivo (portal e shell interno).
|
||||
- DevTools habilitado no desktop (F12, Ctrl+Shift+I ou botão direito com Ctrl/Shift).
|
||||
- Desktop salva dados em `C:\\Raven\\data\\machine-agent.json` (ou equivalente ao lado do executável), com fallback para AppData se a pasta do app não permitir escrita.
|
||||
|
||||
## Validação rápida (após “Registrar máquina”)
|
||||
## Validação rápida (após “Registrar dispositivo”)
|
||||
1) No executável, com DevTools:
|
||||
```js
|
||||
fetch('/api/machines/session').then(r => r.status).then(console.log)
|
||||
|
|
@ -33,7 +33,7 @@ Este documento consolida as orientações e diagnósticos sobre o fluxo do agent
|
|||
```
|
||||
2) Na UI (Portal/Topo):
|
||||
- Mostrar nome/e‑mail do colaborador/gestor (não “Cliente / Sem e‑mail definido”).
|
||||
- Sem botão “Sair” (sessão de máquina).
|
||||
- Sem botão “Sair” (sessão de dispositivo).
|
||||
3) No Portal, o formulário “Abrir chamado” deve habilitar normalmente (usa `machineContext.assignedUserId`).
|
||||
|
||||
Se `GET /api/machines/session` retornar 403:
|
||||
|
|
@ -68,4 +68,4 @@ Se `GET /api/machines/session` retornar 403:
|
|||
|
||||
---
|
||||
|
||||
Última atualização: automatização do handshake no middleware, ocultação de “Sair” em sessão de máquina, dados persistidos junto ao executável e DevTools habilitado.
|
||||
Última atualização: automatização do handshake no middleware, ocultação de “Sair” em sessão de dispositivo, dados persistidos junto ao executável e DevTools habilitado.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Este guia consolida tudo o que precisa ser feito para que o auto-update do Tauri
|
|||
```
|
||||
- Privada: `~/.tauri/raven.key` (nunca compartilhar)
|
||||
- Pública: `~/.tauri/raven.key.pub` (cole em `tauri.conf.json > plugins.updater.pubkey`)
|
||||
- Se for buildar em outra máquina (ex.: Windows), copie os dois arquivos para `C:\Users\<usuario>\.tauri\raven.key(.pub)`.
|
||||
- Se for buildar em outra dispositivo (ex.: Windows), copie os dois arquivos para `C:\Users\<usuario>\.tauri\raven.key(.pub)`.
|
||||
|
||||
2. **Verificar o `tauri.conf.json`**
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ Referências úteis:
|
|||
- Abrir no navegador padrão com `openUrl` (`apps/desktop/src/main.ts:694`).
|
||||
- Se necessário, limpar Store via botão "Reprovisionar" (Configurações) ou removendo o arquivo `machine-agent.json` no diretório de dados do app.
|
||||
- Mensagem de erro genérica no desktop:
|
||||
- Antes: "Erro desconhecido ao registrar a máquina".
|
||||
- Agora: exibe `Falha ao registrar máquina (STATUS): mensagem — detalhes` (quando disponíveis), facilitando diagnóstico.
|
||||
- Antes: "Erro desconhecido ao registrar a dispositivo".
|
||||
- Agora: exibe `Falha ao registrar dispositivo (STATUS): mensagem — detalhes` (quando disponíveis), facilitando diagnóstico.
|
||||
|
||||
## Provisionamento — segredo e boas práticas
|
||||
- Variável: `MACHINE_PROVISIONING_SECRET` (VPS/Convex backend).
|
||||
|
|
@ -74,7 +74,7 @@ Referências úteis:
|
|||
docker service update --force sistema_convex_backend
|
||||
```
|
||||
3. Validar com `POST /api/machines/register` (esperado 201).
|
||||
- Máquinas já registradas não são afetadas (token delas continua válido).
|
||||
- Dispositivos já registradas não são afetadas (token delas continua válido).
|
||||
|
||||
## Pendências e próximos passos
|
||||
- Mapear erros "esperados" para HTTP adequado no web (Next):
|
||||
|
|
@ -90,9 +90,9 @@ Referências úteis:
|
|||
|
||||
## Checklist rápido de verificação (QA)
|
||||
- `.env` do desktop contém apenas `VITE_APP_URL` e `VITE_API_BASE_URL` apontando para produção.
|
||||
- Primeiro registro sem empresa retorna 201 e aparece "Máquina provisionada" nas abas.
|
||||
- Primeiro registro sem empresa retorna 201 e aparece "Dispositivo provisionada" nas abas.
|
||||
- "Ambiente" e "API" em Configurações exibem `https://tickets.esdrasrenan.com.br`.
|
||||
- "Abrir sistema" abre o navegador com `/machines/handshake?token=...` e loga a máquina.
|
||||
- "Abrir sistema" abre o navegador com `/machines/handshake?token=...` e loga a dispositivo.
|
||||
- Reprovisionar limpa a Store e volta ao formulário inicial.
|
||||
|
||||
---
|
||||
|
|
|
|||
20
src/app/admin/devices/[id]/page.tsx
Normal file
20
src/app/admin/devices/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/app/admin/devices/[id]/tickets/page.tsx
Normal file
26
src/app/admin/devices/[id]/tickets/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { AppShell } from "@/components/app-shell"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { AdminMachinesOverview } from "@/components/admin/machines/admin-machines-overview"
|
||||
import { AdminDevicesOverview } from "@/components/admin/devices/admin-devices-overview"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminMachinesPage({
|
||||
export default async function AdminDevicesPage({
|
||||
searchParams,
|
||||
}: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const params = await searchParams
|
||||
|
|
@ -16,13 +16,13 @@ export default async function AdminMachinesPage({
|
|||
<AppShell
|
||||
header={
|
||||
<SiteHeader
|
||||
title="Parque de máquinas"
|
||||
lead="Acompanhe quais dispositivos estão ativos, métricas recentes e a sincronização do agente."
|
||||
title="Parque de dispositivos"
|
||||
lead="Acompanhe computadores e celulares monitorados, métricas recentes e a sincronização do agente."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
|
||||
<AdminMachinesOverview tenantId={DEFAULT_TENANT_ID} initialCompanyFilterSlug={company ?? "all"} />
|
||||
<AdminDevicesOverview tenantId={DEFAULT_TENANT_ID} initialCompanyFilterSlug={company ?? "all"} />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
|
|||
const client = createConvexClient()
|
||||
const { id } = await ctx.params
|
||||
const machineId = id as Id<"machines">
|
||||
const data = (await client.query(api.machines.getById, { id: machineId, includeMetadata: true })) as unknown
|
||||
const data = (await client.query(api.devices.getById, { id: machineId, includeMetadata: true })) as unknown
|
||||
if (!data) return NextResponse.json({ error: "Not found" }, { status: 404 })
|
||||
return NextResponse.json(data, { status: 200 })
|
||||
} catch (err) {
|
||||
|
|
@ -36,13 +36,13 @@ export async function GET(_request: Request, context: RouteContext) {
|
|||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
try {
|
||||
const machine = (await client.query(api.machines.getById, {
|
||||
const machine = (await client.query(api.devices.getById, {
|
||||
id: machineId,
|
||||
includeMetadata: true,
|
||||
})) as MachineInventoryRecord | null
|
||||
|
||||
if (!machine || machine.tenantId !== tenantId) {
|
||||
return NextResponse.json({ error: "Máquina não encontrada" }, { status: 404 })
|
||||
return NextResponse.json({ error: "Dispositivo não encontrada" }, { status: 404 })
|
||||
}
|
||||
|
||||
const workbook = buildMachinesInventoryWorkbook([machine], {
|
||||
|
|
@ -64,6 +64,6 @@ export async function GET(_request: Request, context: RouteContext) {
|
|||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to export machine inventory", error)
|
||||
return NextResponse.json({ error: "Falha ao gerar planilha da máquina" }, { status: 500 })
|
||||
return NextResponse.json({ error: "Falha ao gerar planilha da dispositivo" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ export async function POST(request: Request) {
|
|||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const machine = (await client.query(api.machines.getContext, {
|
||||
const machine = (await client.query(api.devices.getContext, {
|
||||
machineId: parsed.machineId as Id<"machines">,
|
||||
})) as {
|
||||
id: string
|
||||
|
|
@ -47,7 +47,7 @@ export async function POST(request: Request) {
|
|||
} | null
|
||||
|
||||
if (!machine) {
|
||||
return NextResponse.json({ error: "Máquina não encontrada" }, { status: 404 })
|
||||
return NextResponse.json({ error: "Dispositivo não encontrada" }, { status: 404 })
|
||||
}
|
||||
|
||||
const tenantId = machine.tenantId ?? session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
|
@ -61,7 +61,7 @@ export async function POST(request: Request) {
|
|||
companyId: machine.companyId ? (machine.companyId as Id<"companies">) : undefined,
|
||||
})) as { _id?: Id<"users"> } | null
|
||||
|
||||
await client.mutation(api.machines.updatePersona, {
|
||||
await client.mutation(api.devices.updatePersona, {
|
||||
machineId: parsed.machineId as Id<"machines">,
|
||||
persona: parsed.persona,
|
||||
assignedUserId: ensuredUser?._id,
|
||||
|
|
@ -73,6 +73,6 @@ export async function POST(request: Request) {
|
|||
return NextResponse.json({ ok: true })
|
||||
} catch (error) {
|
||||
console.error("[machines.access]", error)
|
||||
return NextResponse.json({ error: "Falha ao atualizar acesso da máquina" }, { status: 500 })
|
||||
return NextResponse.json({ error: "Falha ao atualizar acesso da dispositivo" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ vi.mock("@/lib/auth-server", () => ({
|
|||
assertAuthenticatedSession: assertAuthenticatedSession,
|
||||
}))
|
||||
|
||||
describe("POST /api/admin/machines/delete", () => {
|
||||
describe("POST /api/admin/devices/delete", () => {
|
||||
const originalEnv = process.env.NEXT_PUBLIC_CONVEX_URL
|
||||
|
||||
let restoreConsole: (() => void) | undefined
|
||||
|
|
@ -65,7 +65,7 @@ describe("POST /api/admin/machines/delete", () => {
|
|||
it("returns ok when the machine removal succeeds", async () => {
|
||||
const { POST } = await import("./route")
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/admin/machines/delete", {
|
||||
new Request("http://localhost/api/admin/devices/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ machineId: "jn_machine" }),
|
||||
})
|
||||
|
|
@ -81,13 +81,13 @@ describe("POST /api/admin/machines/delete", () => {
|
|||
it("still succeeds when the Convex machine is already missing", async () => {
|
||||
mutationMock.mockImplementation(async (_ctx, payload) => {
|
||||
if (payload && typeof payload === "object" && "machineId" in payload) {
|
||||
throw new Error("Máquina não encontrada")
|
||||
throw new Error("Dispositivo não encontrada")
|
||||
}
|
||||
return { _id: "user_123" }
|
||||
})
|
||||
const { POST } = await import("./route")
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/admin/machines/delete", {
|
||||
new Request("http://localhost/api/admin/devices/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ machineId: "jn_machine" }),
|
||||
})
|
||||
|
|
@ -107,14 +107,14 @@ describe("POST /api/admin/machines/delete", () => {
|
|||
})
|
||||
const { POST } = await import("./route")
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/admin/machines/delete", {
|
||||
new Request("http://localhost/api/admin/devices/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ machineId: "jn_machine" }),
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
await expect(response.json()).resolves.toEqual({ error: "Falha ao remover máquina no Convex" })
|
||||
await expect(response.json()).resolves.toEqual({ error: "Falha ao remover dispositivo no Convex" })
|
||||
expect(deleteManyMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -50,17 +50,17 @@ export async function POST(request: Request) {
|
|||
|
||||
let machineMissing = false
|
||||
try {
|
||||
await convex.mutation(api.machines.remove, {
|
||||
await convex.mutation(api.devices.remove, {
|
||||
machineId: parsed.data.machineId as Id<"machines">,
|
||||
actorId,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : ""
|
||||
if (message.includes("Máquina não encontrada")) {
|
||||
if (message.includes("Dispositivo não encontrada")) {
|
||||
machineMissing = true
|
||||
} else {
|
||||
console.error("[machines.delete] Convex failure", error)
|
||||
return NextResponse.json({ error: "Falha ao remover máquina no Convex" }, { status: 500 })
|
||||
return NextResponse.json({ error: "Falha ao remover dispositivo no Convex" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +70,6 @@ export async function POST(request: Request) {
|
|||
return NextResponse.json({ ok: true, machineMissing })
|
||||
} catch (error) {
|
||||
console.error("[machines.delete] Falha ao excluir", error)
|
||||
return NextResponse.json({ error: "Falha ao excluir máquina" }, { status: 500 })
|
||||
return NextResponse.json({ error: "Falha ao excluir dispositivo" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ export async function POST(request: Request) {
|
|||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
await client.mutation(api.machines.linkUser, {
|
||||
await client.mutation(api.devices.linkUser, {
|
||||
machineId: parsed.machineId as Id<"machines">,
|
||||
email: parsed.email,
|
||||
})
|
||||
|
|
@ -53,7 +53,7 @@ export async function DELETE(request: Request) {
|
|||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
await client.mutation(api.machines.unlinkUser, {
|
||||
await client.mutation(api.devices.unlinkUser, {
|
||||
machineId: parsed.data.machineId as Id<"machines">,
|
||||
userId: parsed.data.userId as Id<"users">,
|
||||
})
|
||||
|
|
@ -59,7 +59,7 @@ export async function POST(request: Request) {
|
|||
return NextResponse.json({ ok: true })
|
||||
} catch (error) {
|
||||
console.error("[machines.rename] Falha ao renomear", error)
|
||||
return NextResponse.json({ error: "Falha ao renomear máquina" }, { status: 500 })
|
||||
return NextResponse.json({ error: "Falha ao renomear dispositivo" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ export async function POST(request: Request) {
|
|||
return NextResponse.json({ ok: true, revoked: result?.revoked ?? 0 })
|
||||
} catch (error) {
|
||||
console.error("[machines.resetAgent] Falha ao resetar agente", error)
|
||||
return NextResponse.json({ error: "Falha ao resetar agente da máquina" }, { status: 500 })
|
||||
return NextResponse.json({ error: "Falha ao resetar agente da dispositivo" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +56,6 @@ export async function POST(request: Request) {
|
|||
return NextResponse.json({ ok: true })
|
||||
} catch (error) {
|
||||
console.error("[machines.toggleActive] Falha ao atualizar status", error)
|
||||
return NextResponse.json({ error: "Falha ao atualizar status da máquina" }, { status: 500 })
|
||||
return NextResponse.json({ error: "Falha ao atualizar status da dispositivo" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
|||
}
|
||||
|
||||
if (targetRole === "machine") {
|
||||
return NextResponse.json({ error: "Contas de máquina não possuem senha web" }, { status: 400 })
|
||||
return NextResponse.json({ error: "Contas de dispositivo não possuem senha web" }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as { password?: string } | null
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
|
|||
}
|
||||
|
||||
if ((user.role ?? "").toLowerCase() === "machine") {
|
||||
return NextResponse.json({ error: "Ajustes de máquinas devem ser feitos em Admin ▸ Máquinas" }, { status: 400 })
|
||||
return NextResponse.json({ error: "Ajustes de dispositivos devem ser feitos em Admin ▸ Dispositivos" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!sessionIsAdmin && !canManageRole(nextRole)) {
|
||||
|
|
@ -356,7 +356,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
|||
}
|
||||
|
||||
if (target.role === "machine") {
|
||||
return NextResponse.json({ error: "Os agentes de máquina devem ser removidos via módulo de máquinas." }, { status: 400 })
|
||||
return NextResponse.json({ error: "Os agentes de dispositivo devem ser removidos via módulo de dispositivos." }, { status: 400 })
|
||||
}
|
||||
|
||||
if (target.email === session.user.email) {
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ export async function POST(request: Request) {
|
|||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const result = await client.mutation(api.machines.upsertInventory, {
|
||||
const result = await client.mutation(api.devices.upsertInventory, {
|
||||
provisioningCode: fleetSecret,
|
||||
hostname,
|
||||
os: osInfo,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ describe("POST /api/machines/heartbeat", () => {
|
|||
expect(response.status).toBe(200)
|
||||
const body = await response.json()
|
||||
expect(body).toEqual({ ok: true })
|
||||
expect(mutationMock).toHaveBeenCalledWith(api.machines.heartbeat, payload)
|
||||
expect(mutationMock).toHaveBeenCalledWith(api.devices.heartbeat, payload)
|
||||
})
|
||||
|
||||
it("rejects an invalid payload", async () => {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export async function POST(request: Request) {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await client.mutation(api.machines.heartbeat, payload)
|
||||
const response = await client.mutation(api.devices.heartbeat, payload)
|
||||
return jsonWithCors(response, 200, origin, CORS_METHODS)
|
||||
} catch (error) {
|
||||
console.error("[machines.heartbeat] Falha ao registrar heartbeat", error)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ describe("POST /api/machines/inventory", () => {
|
|||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mutationMock).toHaveBeenCalledWith(
|
||||
api.machines.heartbeat,
|
||||
api.devices.heartbeat,
|
||||
expect.objectContaining({
|
||||
machineToken: "token-123",
|
||||
hostname: "machine",
|
||||
|
|
@ -67,7 +67,7 @@ describe("POST /api/machines/inventory", () => {
|
|||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mutationMock).toHaveBeenCalledWith(
|
||||
api.machines.upsertInventory,
|
||||
api.devices.upsertInventory,
|
||||
expect.objectContaining({
|
||||
provisioningCode: "a".repeat(32),
|
||||
hostname: "machine",
|
||||
|
|
|
|||
|
|
@ -64,11 +64,11 @@ export async function POST(request: Request) {
|
|||
)
|
||||
}
|
||||
|
||||
// Modo A: com token da máquina (usa heartbeat para juntar inventário)
|
||||
// Modo A: com token da dispositivo (usa heartbeat para juntar inventário)
|
||||
const tokenParsed = tokenModeSchema.safeParse(raw)
|
||||
if (tokenParsed.success) {
|
||||
try {
|
||||
const result = await client.mutation(api.machines.heartbeat, {
|
||||
const result = await client.mutation(api.devices.heartbeat, {
|
||||
machineToken: tokenParsed.data.machineToken,
|
||||
hostname: tokenParsed.data.hostname,
|
||||
os: tokenParsed.data.os,
|
||||
|
|
@ -87,7 +87,7 @@ export async function POST(request: Request) {
|
|||
const provParsed = provisioningModeSchema.safeParse(raw)
|
||||
if (provParsed.success) {
|
||||
try {
|
||||
const result = await client.mutation(api.machines.upsertInventory, {
|
||||
const result = await client.mutation(api.devices.upsertInventory, {
|
||||
provisioningCode: provParsed.data.provisioningCode.trim().toLowerCase(),
|
||||
hostname: provParsed.data.hostname,
|
||||
os: provParsed.data.os,
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export async function POST(request: Request) {
|
|||
provisioningCode: companyRecord.provisioningCode,
|
||||
})
|
||||
|
||||
const registration = await client.mutation(api.machines.register, {
|
||||
const registration = await client.mutation(api.devices.register, {
|
||||
provisioningCode,
|
||||
hostname: payload.hostname,
|
||||
os: payload.os,
|
||||
|
|
@ -138,7 +138,7 @@ export async function POST(request: Request) {
|
|||
persona,
|
||||
})
|
||||
|
||||
await client.mutation(api.machines.linkAuthAccount, {
|
||||
await client.mutation(api.devices.linkAuthAccount, {
|
||||
machineId: registration.machineId as Id<"machines">,
|
||||
authUserId: account.authUserId,
|
||||
authEmail: account.authEmail,
|
||||
|
|
@ -165,7 +165,7 @@ export async function POST(request: Request) {
|
|||
|
||||
if (persona) {
|
||||
assignedUserId = ensuredUser?._id
|
||||
await client.mutation(api.machines.updatePersona, {
|
||||
await client.mutation(api.devices.updatePersona, {
|
||||
machineId: registration.machineId as Id<"machines">,
|
||||
persona,
|
||||
...(assignedUserId ? { assignedUserId } : {}),
|
||||
|
|
@ -174,13 +174,13 @@ export async function POST(request: Request) {
|
|||
assignedUserRole: persona === "manager" ? "MANAGER" : "COLLABORATOR",
|
||||
})
|
||||
} else {
|
||||
await client.mutation(api.machines.updatePersona, {
|
||||
await client.mutation(api.devices.updatePersona, {
|
||||
machineId: registration.machineId as Id<"machines">,
|
||||
persona: "",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await client.mutation(api.machines.updatePersona, {
|
||||
await client.mutation(api.devices.updatePersona, {
|
||||
machineId: registration.machineId as Id<"machines">,
|
||||
persona: "",
|
||||
})
|
||||
|
|
@ -211,7 +211,7 @@ export async function POST(request: Request) {
|
|||
const isCompanyNotFound = msg.includes("empresa não encontrada")
|
||||
const isConvexError = msg.includes("convexerror")
|
||||
const status = isInvalidCode ? 401 : isCompanyNotFound ? 404 : isConvexError ? 400 : 500
|
||||
const payload = { error: "Falha ao provisionar máquina", details }
|
||||
const payload = { error: "Falha ao provisionar dispositivo", details }
|
||||
return jsonWithCors(payload, status, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ describe("GET /api/machines/session", () => {
|
|||
|
||||
expect(response.status).toBe(403)
|
||||
const payload = await response.json()
|
||||
expect(payload).toEqual({ error: "Sessão de máquina não encontrada." })
|
||||
expect(payload).toEqual({ error: "Sessão de dispositivo não encontrada." })
|
||||
expect(mockCreateConvexClient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const runtime = "nodejs"
|
|||
export async function GET(request: NextRequest) {
|
||||
const session = await assertAuthenticatedSession()
|
||||
if (!session || session.user?.role !== "machine") {
|
||||
return NextResponse.json({ error: "Sessão de máquina não encontrada." }, { status: 403 })
|
||||
return NextResponse.json({ error: "Sessão de dispositivo não encontrada." }, { status: 403 })
|
||||
}
|
||||
|
||||
let client
|
||||
|
|
@ -42,23 +42,23 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
if (!machineId) {
|
||||
try {
|
||||
const lookup = (await client.query(api.machines.findByAuthEmail, {
|
||||
const lookup = (await client.query(api.devices.findByAuthEmail, {
|
||||
authEmail: session.user.email.toLowerCase(),
|
||||
})) as { id: string } | null
|
||||
|
||||
if (!lookup?.id) {
|
||||
return NextResponse.json({ error: "Máquina não vinculada à sessão atual." }, { status: 404 })
|
||||
return NextResponse.json({ error: "Dispositivo não vinculada à sessão atual." }, { status: 404 })
|
||||
}
|
||||
|
||||
machineId = lookup.id as Id<"machines">
|
||||
} catch (error) {
|
||||
console.error("[machines.session] Falha ao localizar máquina por e-mail", error)
|
||||
return NextResponse.json({ error: "Não foi possível localizar a máquina." }, { status: 500 })
|
||||
console.error("[machines.session] Falha ao localizar dispositivo por e-mail", error)
|
||||
return NextResponse.json({ error: "Não foi possível localizar a dispositivo." }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let context = (await client.query(api.machines.getContext, {
|
||||
let context = (await client.query(api.devices.getContext, {
|
||||
machineId,
|
||||
})) as {
|
||||
id: string
|
||||
|
|
@ -109,7 +109,7 @@ export async function GET(request: NextRequest) {
|
|||
ensuredAssignedUserRole = ensuredUser.role ?? ensuredAssignedUserRole ?? assignedRole
|
||||
ensuredPersona = normalizedPersona
|
||||
|
||||
await client.mutation(api.machines.updatePersona, {
|
||||
await client.mutation(api.devices.updatePersona, {
|
||||
machineId: machineId as Id<"machines">,
|
||||
persona: normalizedPersona,
|
||||
assignedUserId: ensuredUser._id as Id<"users">,
|
||||
|
|
@ -118,7 +118,7 @@ export async function GET(request: NextRequest) {
|
|||
assignedUserRole: (ensuredAssignedUserRole ?? assignedRole).toUpperCase(),
|
||||
})
|
||||
|
||||
context = (await client.query(api.machines.getContext, {
|
||||
context = (await client.query(api.devices.getContext, {
|
||||
machineId,
|
||||
})) as typeof context
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[machines.session] Falha ao obter contexto da máquina", error)
|
||||
return NextResponse.json({ error: "Falha ao obter contexto da máquina." }, { status: 500 })
|
||||
console.error("[machines.session] Falha ao obter contexto da dispositivo", error)
|
||||
return NextResponse.json({ error: "Falha ao obter contexto da dispositivo." }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,13 +127,13 @@ export async function POST(request: Request) {
|
|||
} catch (error) {
|
||||
if (error instanceof MachineInactiveError) {
|
||||
return jsonWithCors(
|
||||
{ error: "Máquina desativada. Entre em contato com o suporte da Rever para reativar o acesso." },
|
||||
{ error: "Dispositivo desativada. Entre em contato com o suporte da Rever para reativar o acesso." },
|
||||
423,
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
console.error("[machines.sessions] Falha ao criar sessão", error)
|
||||
return jsonWithCors({ error: "Falha ao autenticar máquina" }, 500, origin, CORS_METHODS)
|
||||
return jsonWithCors({ error: "Falha ao autenticar dispositivo" }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { env } from "@/lib/env"
|
|||
import { assertAuthenticatedSession } from "@/lib/auth-server"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { buildMachinesInventoryWorkbook, type MachineInventoryRecord } from "@/server/machines/inventory-export"
|
||||
import type { DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
|
|
@ -22,6 +23,31 @@ export async function GET(request: Request) {
|
|||
const companyId = searchParams.get("companyId") ?? undefined
|
||||
const machineIdParams = searchParams.getAll("machineId").filter(Boolean)
|
||||
const machineIdFilter = machineIdParams.length > 0 ? new Set(machineIdParams) : null
|
||||
const columnsParam = searchParams.get("columns")
|
||||
let columnConfig: DeviceInventoryColumnConfig[] | undefined
|
||||
if (columnsParam) {
|
||||
try {
|
||||
const parsed = JSON.parse(columnsParam)
|
||||
if (Array.isArray(parsed)) {
|
||||
columnConfig = parsed
|
||||
.map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return { key: item }
|
||||
}
|
||||
if (item && typeof item === "object" && typeof item.key === "string") {
|
||||
return {
|
||||
key: item.key,
|
||||
label: typeof item.label === "string" && item.label.length > 0 ? item.label : undefined,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((item): item is DeviceInventoryColumnConfig => item !== null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Invalid columns parameter for machines export", error)
|
||||
}
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
|
@ -46,7 +72,7 @@ export async function GET(request: Request) {
|
|||
}
|
||||
|
||||
try {
|
||||
const machines = (await client.query(api.machines.listByTenant, {
|
||||
const machines = (await client.query(api.devices.listByTenant, {
|
||||
tenantId,
|
||||
includeMetadata: true,
|
||||
})) as MachineInventoryRecord[]
|
||||
|
|
@ -77,6 +103,7 @@ export async function GET(request: Request) {
|
|||
generatedBy: session.user.name ?? session.user.email,
|
||||
companyFilterLabel,
|
||||
generatedAt: new Date(),
|
||||
columns: columnConfig,
|
||||
})
|
||||
|
||||
const body = new Uint8Array(workbook)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const ERROR_TEMPLATE = `
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Falha na autenticação da máquina</title>
|
||||
<title>Falha na autenticação da dispositivo</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background-color: #0f172a; color: #e2e8f0; margin: 0; display: grid; place-items: center; height: 100vh; }
|
||||
main { max-width: 480px; padding: 32px; border-radius: 16px; background-color: rgba(15, 23, 42, 0.65); box-shadow: 0 18px 42px rgba(15, 23, 42, 0.45); text-align: center; backdrop-filter: blur(10px); }
|
||||
|
|
@ -21,8 +21,8 @@ const ERROR_TEMPLATE = `
|
|||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Não foi possível autenticar esta máquina</h1>
|
||||
<p>O token informado é inválido, expirou ou não está mais associado a uma máquina ativa.</p>
|
||||
<h1>Não foi possível autenticar esta dispositivo</h1>
|
||||
<p>O token informado é inválido, expirou ou não está mais associado a uma dispositivo ativa.</p>
|
||||
<p>Volte ao agente desktop, gere um novo token ou realize o provisionamento novamente.</p>
|
||||
<a href="/">Voltar para o Raven</a>
|
||||
</main>
|
||||
|
|
@ -36,7 +36,7 @@ const INACTIVE_TEMPLATE = `
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Máquina desativada</title>
|
||||
<title>Dispositivo desativada</title>
|
||||
<style>
|
||||
:root { color-scheme: dark light; }
|
||||
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background-color: #0f172a; color: #e2e8f0; margin: 0; display: grid; place-items: center; min-height: 100vh; padding: 24px; }
|
||||
|
|
@ -51,9 +51,9 @@ const INACTIVE_TEMPLATE = `
|
|||
<body>
|
||||
<main>
|
||||
<div class="badge">Acesso bloqueado</div>
|
||||
<h1>Esta máquina está desativada</h1>
|
||||
<h1>Esta dispositivo está desativada</h1>
|
||||
<p>O acesso ao portal foi suspenso pelos administradores da Rever. Enquanto isso, você não poderá abrir chamados ou enviar atualizações.</p>
|
||||
<p>Entre em contato com a equipe da Rever para solicitar a reativação desta máquina.</p>
|
||||
<p>Entre em contato com a equipe da Rever para solicitar a reativação desta dispositivo.</p>
|
||||
<a href="mailto:suporte@rever.com.br">Falar com o suporte</a>
|
||||
</main>
|
||||
</body>
|
||||
|
|
@ -162,7 +162,7 @@ export async function GET(request: NextRequest) {
|
|||
},
|
||||
})
|
||||
}
|
||||
console.error("[machines.handshake] Falha ao autenticar máquina", error)
|
||||
console.error("[machines.handshake] Falha ao autenticar dispositivo", error)
|
||||
return new NextResponse(ERROR_TEMPLATE, {
|
||||
status: 500,
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ const ROLE_LABELS: Record<string, string> = {
|
|||
manager: "Gestor",
|
||||
agent: "Agente",
|
||||
collaborator: "Colaborador",
|
||||
machine: "Agente de máquina",
|
||||
machine: "Agente de dispositivo",
|
||||
}
|
||||
|
||||
function formatRole(role: string) {
|
||||
|
|
@ -305,7 +305,7 @@ export function AdminUsersManager({
|
|||
() => [
|
||||
{ value: "all", label: "Todos" },
|
||||
{ value: "people", label: "Pessoas" },
|
||||
{ value: "machines", label: "Máquinas" },
|
||||
{ value: "machines", label: "Dispositivos" },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
|
@ -327,8 +327,8 @@ export function AdminUsersManager({
|
|||
const [teamSelection, setTeamSelection] = useState<Set<string>>(new Set())
|
||||
const [isBulkDeletingTeam, setIsBulkDeletingTeam] = useState(false)
|
||||
const [bulkDeleteTeamOpen, setBulkDeleteTeamOpen] = useState(false)
|
||||
// Removidos filtros antigos de Pessoas/Máquinas (agora unificado)
|
||||
// Unificado (pessoas + máquinas)
|
||||
// Removidos filtros antigos de Pessoas/Dispositivos (agora unificado)
|
||||
// Unificado (pessoas + dispositivos)
|
||||
const [usersSearch, setUsersSearch] = useState("")
|
||||
const [usersTypeFilter, setUsersTypeFilter] = useState<"all" | "people" | "machines">("people")
|
||||
const [usersCompanyFilter, setUsersCompanyFilter] = useState<string>("all")
|
||||
|
|
@ -366,7 +366,7 @@ export function AdminUsersManager({
|
|||
|
||||
const cleanupPreview = useMemo(() => Array.from(buildKeepEmailSet()).join(", "), [buildKeepEmailSet])
|
||||
|
||||
// Máquinas (para listar vínculos por usuário)
|
||||
// Dispositivos (para listar vínculos por usuário)
|
||||
type MachinesListItem = {
|
||||
id: string
|
||||
hostname?: string
|
||||
|
|
@ -375,7 +375,7 @@ export function AdminUsersManager({
|
|||
linkedUsers?: Array<{ id: string; email: string; name: string }>
|
||||
}
|
||||
const machinesList = useQuery(
|
||||
api.machines.listByTenant,
|
||||
api.devices.listByTenant,
|
||||
convexUserId ? { tenantId: defaultTenantId, includeMetadata: true } : "skip"
|
||||
) as MachinesListItem[] | undefined
|
||||
|
||||
|
|
@ -907,7 +907,7 @@ export function AdminUsersManager({
|
|||
|
||||
// Removido: seleção específica de Pessoas (uso substituído pelo unificado)
|
||||
|
||||
// Removido: seleção específica de Máquinas (uso substituído pelo unificado)
|
||||
// Removido: seleção específica de Dispositivos (uso substituído pelo unificado)
|
||||
|
||||
const [inviteSelection, setInviteSelection] = useState<Set<string>>(new Set())
|
||||
const selectedInvites = useMemo(() => invites.filter((i) => inviteSelection.has(i.id)), [invites, inviteSelection])
|
||||
|
|
@ -951,7 +951,7 @@ export function AdminUsersManager({
|
|||
if (!user) return
|
||||
const machineId = extractMachineId(user.email)
|
||||
if (!machineId) return
|
||||
const response = await fetch(`/api/admin/machines/delete`, {
|
||||
const response = await fetch(`/api/admin/devices/delete`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
|
|
@ -1098,9 +1098,9 @@ async function handleDeleteUser() {
|
|||
if (isMachine) {
|
||||
const machineId = extractMachineId(deleteTarget.email)
|
||||
if (!machineId) {
|
||||
throw new Error("Não foi possível identificar a máquina associada.")
|
||||
throw new Error("Não foi possível identificar a dispositivo associada.")
|
||||
}
|
||||
const response = await fetch("/api/admin/machines/delete", {
|
||||
const response = await fetch("/api/admin/devices/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ machineId }),
|
||||
|
|
@ -1108,9 +1108,9 @@ async function handleDeleteUser() {
|
|||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error ?? "Falha ao remover agente de máquina")
|
||||
throw new Error(data.error ?? "Falha ao remover agente de dispositivo")
|
||||
}
|
||||
toast.success("Agente de máquina removido")
|
||||
toast.success("Agente de dispositivo removido")
|
||||
} else {
|
||||
const response = await fetch(`/api/admin/users/${deleteTarget.id}`, {
|
||||
method: "DELETE",
|
||||
|
|
@ -1457,7 +1457,7 @@ async function handleDeleteUser() {
|
|||
<Input
|
||||
value={usersSearch}
|
||||
onChange={(event) => setUsersSearch(event.target.value)}
|
||||
placeholder="Buscar por nome, e-mail, empresa ou máquina..."
|
||||
placeholder="Buscar por nome, e-mail, empresa ou dispositivo..."
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1513,7 +1513,7 @@ async function handleDeleteUser() {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usuários</CardTitle>
|
||||
<CardDescription>Pessoas e máquinas com acesso ao sistema.</CardDescription>
|
||||
<CardDescription>Pessoas e dispositivos com acesso ao sistema.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="w-full overflow-x-auto">
|
||||
|
|
@ -1564,11 +1564,11 @@ async function handleDeleteUser() {
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 font-medium text-neutral-800">
|
||||
{user.name || (user.role === "machine" ? "Máquina" : "—")}
|
||||
{user.name || (user.role === "machine" ? "Dispositivo" : "—")}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-neutral-600">{user.email}</TableCell>
|
||||
<TableCell className="px-4 text-neutral-600">
|
||||
{user.role === "machine" ? "Máquina" : "Pessoa"}
|
||||
{user.role === "machine" ? "Dispositivo" : "Pessoa"}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-neutral-600">
|
||||
{user.role === "machine" ? (
|
||||
|
|
@ -1606,11 +1606,11 @@ async function handleDeleteUser() {
|
|||
<Link
|
||||
href={
|
||||
extractMachineId(user.email)
|
||||
? `/admin/machines/${extractMachineId(user.email)}`
|
||||
: "/admin/machines"
|
||||
? `/admin/devices/${extractMachineId(user.email)}`
|
||||
: "/admin/devices"
|
||||
}
|
||||
>
|
||||
Detalhes da máquina
|
||||
Detalhes da dispositivo
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
|
|
@ -2113,7 +2113,7 @@ async function handleDeleteUser() {
|
|||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remover usuários selecionados</DialogTitle>
|
||||
<DialogDescription>Pessoas perderão o acesso e máquinas serão desconectadas.</DialogDescription>
|
||||
<DialogDescription>Pessoas perderão o acesso e dispositivos serão desconectadas.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-64 space-y-2 overflow-auto">
|
||||
{Array.from(usersSelection).slice(0, 5).map((id) => {
|
||||
|
|
@ -2240,27 +2240,27 @@ async function handleDeleteUser() {
|
|||
if (r === 'admin' || r === 'agent') return null
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>Máquinas vinculadas</Label>
|
||||
<Label>Dispositivos vinculadas</Label>
|
||||
{linkedMachinesForEditUser.length > 0 ? (
|
||||
<ul className="divide-y divide-slate-200 rounded-md border border-slate-200 bg-slate-50/60">
|
||||
{linkedMachinesForEditUser.map((m) => (
|
||||
<li key={`linked-m-${m.id}`} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||
<span className="truncate">{m.hostname || m.id}</span>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link href={`/admin/machines/${m.id}`}>Abrir</Link>
|
||||
<Link href={`/admin/devices/${m.id}`}>Abrir</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-neutral-500">Nenhuma máquina vinculada a este usuário.</p>
|
||||
<p className="text-xs text-neutral-500">Nenhuma dispositivo vinculada a este usuário.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{isMachineEditing ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-3 text-sm text-neutral-600">
|
||||
Os ajustes detalhados de agentes de máquina são feitos em <Link href="/admin/machines" className="underline underline-offset-4">Admin ▸ Máquinas</Link>.
|
||||
Os ajustes detalhados de agentes de dispositivo são feitos em <Link href="/admin/devices" className="underline underline-offset-4">Admin ▸ Dispositivos</Link>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
|
||||
|
|
@ -2314,7 +2314,7 @@ async function handleDeleteUser() {
|
|||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{deleteTarget?.role === "machine" ? "Remover agente de máquina" : "Remover colaborador"}
|
||||
{deleteTarget?.role === "machine" ? "Remover agente de dispositivo" : "Remover colaborador"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteTarget?.role === "machine"
|
||||
|
|
@ -2328,7 +2328,7 @@ async function handleDeleteUser() {
|
|||
</p>
|
||||
{deleteTarget?.role === "machine" ? (
|
||||
<p>
|
||||
A máquina correspondente perderá imediatamente o token ativo e voltará para a tela de provisionamento.
|
||||
A dispositivo correspondente perderá imediatamente o token ativo e voltará para a tela de provisionamento.
|
||||
</p>
|
||||
) : (
|
||||
<p>Esse usuário não poderá mais acessar o painel até receber um novo convite.</p>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/
|
|||
import { useQuery } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { MultiValueInput } from "@/components/ui/multi-value-input"
|
||||
import { AdminMachinesOverview } from "@/components/admin/machines/admin-machines-overview"
|
||||
import { AdminDevicesOverview } from "@/components/admin/devices/admin-devices-overview"
|
||||
|
||||
type LastAlertInfo = { createdAt: number; usagePct: number; threshold: number } | null
|
||||
|
||||
|
|
@ -283,8 +283,8 @@ export function AdminCompaniesManager({ initialCompanies, tenantId }: Props) {
|
|||
|
||||
const effectiveTenantId = tenantId ?? companies[0]?.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
// Máquinas por empresa para contagem rápida
|
||||
const machines = useQuery(api.machines.listByTenant, {
|
||||
// Dispositivos por empresa para contagem rápida
|
||||
const machines = useQuery(api.devices.listByTenant, {
|
||||
tenantId: effectiveTenantId,
|
||||
includeMetadata: false,
|
||||
}) as unknown[] | undefined
|
||||
|
|
@ -513,10 +513,10 @@ export function AdminCompaniesManager({ initialCompanies, tenantId }: Props) {
|
|||
type="button"
|
||||
className="inline-flex items-center gap-1 text-muted-foreground transition hover:text-foreground"
|
||||
onClick={() => {
|
||||
window.location.href = `/admin/machines?company=${company.slug}`
|
||||
window.location.href = `/admin/devices?company=${company.slug}`
|
||||
}}
|
||||
>
|
||||
<IconDeviceDesktop className="size-3.5" /> Máquinas
|
||||
<IconDeviceDesktop className="size-3.5" /> Dispositivos
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -754,7 +754,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
|
|||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Contratos ativos</TableHead>
|
||||
<TableHead>Contatos</TableHead>
|
||||
<TableHead>Máquinas</TableHead>
|
||||
<TableHead>Dispositivos</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -864,9 +864,9 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
|
|||
<IconCopy className="mr-2 size-3.5" />
|
||||
Código
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="whitespace-nowrap" onClick={() => { window.location.href = `/admin/machines?company=${company.slug}` }}>
|
||||
<Button size="sm" variant="outline" className="whitespace-nowrap" onClick={() => { window.location.href = `/admin/devices?company=${company.slug}` }}>
|
||||
<IconDeviceDesktop className="mr-2 size-3.5" />
|
||||
Máquinas
|
||||
Dispositivos
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
|
|
@ -1687,10 +1687,10 @@ function CompanySheet({ tenantId, editor, onClose, onCreated, onUpdated }: Compa
|
|||
|
||||
{editor?.mode === "edit" ? (
|
||||
<AccordionItem value="machines" className="rounded-lg border border-border/60 bg-muted/20 px-4">
|
||||
<AccordionTrigger className="py-3 font-semibold">Máquinas vinculadas</AccordionTrigger>
|
||||
<AccordionTrigger className="py-3 font-semibold">Dispositivos vinculadas</AccordionTrigger>
|
||||
<AccordionContent className="pb-5">
|
||||
<div className="rounded-lg border border-border/60 bg-background p-3">
|
||||
<AdminMachinesOverview tenantId={tenantId} initialCompanyFilterSlug={editor.company.slug} />
|
||||
<AdminDevicesOverview tenantId={tenantId} initialCompanyFilterSlug={editor.company.slug} />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
|
|
|||
|
|
@ -5,28 +5,28 @@ import { useQuery } from "convex/react"
|
|||
import { useParams, useRouter } from "next/navigation"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import {
|
||||
MachineDetails,
|
||||
normalizeMachineItem,
|
||||
type MachinesQueryItem,
|
||||
} from "@/components/admin/machines/admin-machines-overview"
|
||||
DeviceDetails,
|
||||
normalizeDeviceItem,
|
||||
type DevicesQueryItem,
|
||||
} from "@/components/admin/devices/admin-devices-overview"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: { tenantId: string; machineId?: string }) {
|
||||
export function AdminDeviceDetailsClient({ tenantId: _tenantId, deviceId }: { tenantId: string; deviceId?: string }) {
|
||||
const router = useRouter()
|
||||
const params = useParams<{ id?: string | string[] }>()
|
||||
const routeMachineId = Array.isArray(params?.id) ? params?.id[0] : params?.id
|
||||
const effectiveMachineId = machineId ?? routeMachineId ?? ""
|
||||
const routeDeviceId = Array.isArray(params?.id) ? params?.id[0] : params?.id
|
||||
const effectiveDeviceId = deviceId ?? routeDeviceId ?? ""
|
||||
|
||||
const canLoadMachine = Boolean(effectiveMachineId)
|
||||
const canLoadDevice = Boolean(effectiveDeviceId)
|
||||
|
||||
const single = useQuery(
|
||||
api.machines.getById,
|
||||
canLoadMachine
|
||||
? ({ id: effectiveMachineId as Id<"machines">, includeMetadata: true } as const)
|
||||
api.devices.getById,
|
||||
canLoadDevice
|
||||
? ({ id: effectiveDeviceId as Id<"machines">, includeMetadata: true } as const)
|
||||
: "skip"
|
||||
)
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
|
|||
const [fallback, setFallback] = useState<Record<string, unknown> | null | undefined>(undefined)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [retryTick, setRetryTick] = useState(0)
|
||||
const shouldLoad = fallback === undefined && Boolean(effectiveMachineId)
|
||||
const shouldLoad = fallback === undefined && Boolean(effectiveDeviceId)
|
||||
const [isHydrated, setIsHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -51,8 +51,8 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
|
|||
if (convexUrl) {
|
||||
try {
|
||||
const http = new ConvexHttpClient(convexUrl)
|
||||
const data = (await http.query(api.machines.getById, {
|
||||
id: effectiveMachineId as Id<"machines">,
|
||||
const data = (await http.query(api.devices.getById, {
|
||||
id: effectiveDeviceId as Id<"machines">,
|
||||
includeMetadata: true,
|
||||
})) as Record<string, unknown> | null
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
|
|||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/machines/${effectiveMachineId}/details`, {
|
||||
const res = await fetch(`/api/admin/devices/${effectiveDeviceId}/details`, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
})
|
||||
|
|
@ -108,29 +108,29 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
|
|||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error("[admin-machine-details] API fallback fetch failed", err)
|
||||
setLoadError("Erro de rede ao carregar os dados da máquina.")
|
||||
console.error("[admin-device-details] API fallback fetch failed", err)
|
||||
setLoadError("Erro de rede ao carregar os dados da dispositivo.")
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error("[admin-machine-details] Unexpected probe failure", err)
|
||||
setLoadError("Erro de rede ao carregar os dados da máquina.")
|
||||
console.error("[admin-device-details] Unexpected probe failure", err)
|
||||
setLoadError("Erro de rede ao carregar os dados da dispositivo.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
probe().catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.error("[admin-machine-details] Probe promise rejected", err)
|
||||
setLoadError("Erro de rede ao carregar os dados da máquina.")
|
||||
console.error("[admin-device-details] Probe promise rejected", err)
|
||||
setLoadError("Erro de rede ao carregar os dados da dispositivo.")
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [shouldLoad, effectiveMachineId, retryTick])
|
||||
}, [shouldLoad, effectiveDeviceId, retryTick])
|
||||
|
||||
// Timeout de proteção: se depois de X segundos ainda estiver carregando e sem fallback, mostra erro claro
|
||||
useEffect(() => {
|
||||
|
|
@ -141,12 +141,12 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
|
|||
)
|
||||
}, 10_000)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [shouldLoad, effectiveMachineId, retryTick])
|
||||
}, [shouldLoad, effectiveDeviceId, retryTick])
|
||||
|
||||
const machine: MachinesQueryItem | null = useMemo(() => {
|
||||
const device: DevicesQueryItem | null = useMemo(() => {
|
||||
const source = single ?? (fallback === undefined ? undefined : fallback)
|
||||
if (source === undefined || source === null) return source as null
|
||||
return normalizeMachineItem(source)
|
||||
return normalizeDeviceItem(source)
|
||||
}, [single, fallback])
|
||||
const isLoading = single === undefined && fallback === undefined && !loadError
|
||||
const isNotFound = (single === null || fallback === null) && !loadError
|
||||
|
|
@ -174,11 +174,11 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
|
|||
// ignore
|
||||
}
|
||||
}
|
||||
if (loadError && !machine) {
|
||||
if (loadError && !device) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<p className="text-sm font-medium text-red-600">Falha ao carregar os dados da máquina</p>
|
||||
<p className="text-sm font-medium text-red-600">Falha ao carregar os dados da dispositivo</p>
|
||||
<p className="text-sm text-muted-foreground">{loadError}</p>
|
||||
<div className="pt-2 flex items-center gap-2">
|
||||
<Button size="sm" onClick={onRetry}>Tentar novamente</Button>
|
||||
|
|
@ -204,7 +204,7 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
|
|||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<p className="text-sm font-medium text-red-600">Máquina não encontrada</p>
|
||||
<p className="text-sm font-medium text-red-600">Dispositivo não encontrada</p>
|
||||
<p className="text-sm text-muted-foreground">Verifique o identificador e tente novamente.</p>
|
||||
<div className="pt-2 flex items-center gap-2">
|
||||
<Button size="sm" onClick={onRetry}>Recarregar</Button>
|
||||
|
|
@ -214,5 +214,5 @@ export function AdminMachineDetailsClient({ tenantId: _tenantId, machineId }: {
|
|||
)
|
||||
}
|
||||
|
||||
return <MachineDetails machine={machine} />
|
||||
return <DeviceDetails device={device} />
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -12,33 +12,33 @@ type BreadcrumbSegment = {
|
|||
href?: string | null
|
||||
}
|
||||
|
||||
type MachineBreadcrumbsProps = {
|
||||
type DeviceBreadcrumbsProps = {
|
||||
tenantId: string
|
||||
machineId: string
|
||||
machineHref?: string | null
|
||||
deviceId: string
|
||||
deviceHref?: string | null
|
||||
extra?: BreadcrumbSegment[]
|
||||
}
|
||||
|
||||
export function MachineBreadcrumbs({ tenantId: _tenantId, machineId, machineHref, extra }: MachineBreadcrumbsProps) {
|
||||
export function DeviceBreadcrumbs({ tenantId: _tenantId, deviceId, deviceHref, extra }: DeviceBreadcrumbsProps) {
|
||||
const { convexUserId } = useAuth()
|
||||
const canLoadMachine = Boolean(machineId && convexUserId)
|
||||
const canLoadDevice = Boolean(deviceId && convexUserId)
|
||||
const item = useQuery(
|
||||
api.machines.getById,
|
||||
canLoadMachine
|
||||
? ({ id: machineId as Id<"machines">, includeMetadata: false } as const)
|
||||
api.devices.getById,
|
||||
canLoadDevice
|
||||
? ({ id: deviceId as Id<"machines">, includeMetadata: false } as const)
|
||||
: "skip"
|
||||
)
|
||||
const hostname = useMemo(() => item?.hostname ?? "Detalhe", [item])
|
||||
const segments = useMemo(() => {
|
||||
const trail: BreadcrumbSegment[] = [
|
||||
{ label: "Máquinas", href: "/admin/machines" },
|
||||
{ label: hostname, href: machineHref ?? undefined },
|
||||
{ label: "Dispositivos", href: "/admin/devices" },
|
||||
{ label: hostname, href: deviceHref ?? undefined },
|
||||
]
|
||||
if (Array.isArray(extra) && extra.length > 0) {
|
||||
trail.push(...extra.filter((segment): segment is BreadcrumbSegment => Boolean(segment?.label)))
|
||||
}
|
||||
return trail
|
||||
}, [hostname, machineHref, extra])
|
||||
}, [hostname, deviceHref, extra])
|
||||
|
||||
return (
|
||||
<nav className="mb-4 text-sm text-neutral-600">
|
||||
|
|
@ -27,7 +27,7 @@ import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
|||
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
|
||||
import { EmptyIndicator } from "@/components/ui/empty-indicator"
|
||||
|
||||
type MachineTicketHistoryItem = {
|
||||
type DeviceTicketHistoryItem = {
|
||||
id: string
|
||||
reference: number
|
||||
subject: string
|
||||
|
|
@ -40,7 +40,7 @@ type MachineTicketHistoryItem = {
|
|||
assignee: { name: string | null; email: string | null } | null
|
||||
}
|
||||
|
||||
type MachineTicketsHistoryArgs = {
|
||||
type DeviceTicketsHistoryArgs = {
|
||||
machineId: Id<"machines">
|
||||
status?: "open" | "resolved"
|
||||
priority?: string
|
||||
|
|
@ -49,7 +49,7 @@ type MachineTicketsHistoryArgs = {
|
|||
to?: number
|
||||
}
|
||||
|
||||
type MachineTicketsHistoryStats = {
|
||||
type DeviceTicketsHistoryStats = {
|
||||
total: number
|
||||
openCount: number
|
||||
resolvedCount: number
|
||||
|
|
@ -142,7 +142,7 @@ function getPriorityMeta(priority: TicketPriority | string | null | undefined) {
|
|||
}
|
||||
}
|
||||
|
||||
export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }: { tenantId: string; machineId: string }) {
|
||||
export function DeviceTicketsHistoryClient({ tenantId: _tenantId, deviceId }: { tenantId: string; deviceId: string }) {
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all")
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>("ALL")
|
||||
const [periodPreset, setPeriodPreset] = useState<PeriodPreset>("90d")
|
||||
|
|
@ -168,8 +168,8 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
|
|||
const range = useMemo(() => computeRange(periodPreset, customFrom, customTo), [periodPreset, customFrom, customTo])
|
||||
|
||||
const queryArgs = useMemo(() => {
|
||||
const args: MachineTicketsHistoryArgs = {
|
||||
machineId: machineId as Id<"machines">,
|
||||
const args: DeviceTicketsHistoryArgs = {
|
||||
machineId: deviceId as Id<"machines">,
|
||||
}
|
||||
if (statusFilter !== "all") {
|
||||
args.status = statusFilter
|
||||
|
|
@ -187,15 +187,15 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
|
|||
args.to = range.to
|
||||
}
|
||||
return args
|
||||
}, [debouncedSearch, machineId, priorityFilter, range.from, range.to, statusFilter])
|
||||
}, [debouncedSearch, deviceId, priorityFilter, range.from, range.to, statusFilter])
|
||||
|
||||
const { results: tickets, status: paginationStatus, loadMore } = usePaginatedQuery(
|
||||
api.machines.listTicketsHistory,
|
||||
api.devices.listTicketsHistory,
|
||||
queryArgs,
|
||||
{ initialNumItems: 25 }
|
||||
)
|
||||
|
||||
const stats = useQuery(api.machines.getTicketsHistoryStats, queryArgs) as MachineTicketsHistoryStats | undefined
|
||||
const stats = useQuery(api.devices.getTicketsHistoryStats, queryArgs) as DeviceTicketsHistoryStats | undefined
|
||||
const totalTickets = stats?.total ?? 0
|
||||
const openTickets = stats?.openCount ?? 0
|
||||
const resolvedTickets = stats?.resolvedCount ?? 0
|
||||
|
|
@ -321,7 +321,7 @@ export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }:
|
|||
<EmptyHeader>
|
||||
<EmptyTitle>Nenhum chamado encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste os filtros ou expanda o período para visualizar o histórico de chamados desta máquina.
|
||||
Ajuste os filtros ou expanda o período para visualizar o histórico de chamados desta dispositivo.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
|
|
@ -109,7 +109,7 @@ const navigation: NavigationGroup[] = [
|
|||
requiredRole: "admin",
|
||||
},
|
||||
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },
|
||||
{ title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" },
|
||||
{ title: "Dispositivos", url: "/admin/devices", icon: MonitorCog, requiredRole: "admin" },
|
||||
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (machineInactive) {
|
||||
toast.error("Esta máquina está desativada. Reative-a para enviar novas mensagens.")
|
||||
toast.error("Esta dispositivo está desativada. Reative-a para enviar novas mensagens.")
|
||||
return
|
||||
}
|
||||
if (!convexUserId || !ticket) return
|
||||
|
|
@ -328,7 +328,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
<CardContent className="space-y-6 px-5 pb-6">
|
||||
{machineInactive ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
Esta máquina está desativada. Ative-a novamente para enviar novas mensagens.
|
||||
Esta dispositivo está desativada. Ative-a novamente para enviar novas mensagens.
|
||||
</div>
|
||||
) : null}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -64,13 +64,13 @@ export function PortalTicketForm() {
|
|||
event.preventDefault()
|
||||
if (isSubmitting || !isFormValid) return
|
||||
if (machineInactive) {
|
||||
toast.error("Esta máquina está desativada no momento. Reative-a para abrir novos chamados.", { id: "portal-new-ticket" })
|
||||
toast.error("Esta dispositivo está desativada no momento. Reative-a para abrir novos chamados.", { id: "portal-new-ticket" })
|
||||
return
|
||||
}
|
||||
if (!viewerId) {
|
||||
const detail = viewerErrorMessage ? ` Detalhes: ${viewerErrorMessage}` : ""
|
||||
toast.error(
|
||||
`Não foi possível identificar o colaborador vinculado a esta máquina. Tente abrir novamente o portal ou contate o suporte.${detail}`,
|
||||
`Não foi possível identificar o colaborador vinculado a esta dispositivo. Tente abrir novamente o portal ou contate o suporte.${detail}`,
|
||||
{ id: "portal-new-ticket" }
|
||||
)
|
||||
return
|
||||
|
|
@ -145,12 +145,12 @@ export function PortalTicketForm() {
|
|||
<CardContent className="space-y-6 px-5 pb-6">
|
||||
{machineInactive ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
Esta máquina foi desativada pelos administradores e não pode abrir novos chamados até ser reativada.
|
||||
Esta dispositivo foi desativada pelos administradores e não pode abrir novos chamados até ser reativada.
|
||||
</div>
|
||||
) : null}
|
||||
{!isViewerReady ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
Vincule esta máquina a um colaborador na aplicação desktop para enviar chamados em nome dele.
|
||||
Vincule esta dispositivo a um colaborador na aplicação desktop para enviar chamados em nome dele.
|
||||
{machineContextLoading ? (
|
||||
<p className="mt-2 text-xs text-amber-600">Carregando informa<EFBFBD><EFBFBD>es da m<EFBFBD>quina...</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ export function CompanyReport() {
|
|||
</Card>
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Máquinas monitoradas</CardTitle>
|
||||
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Dispositivos monitoradas</CardTitle>
|
||||
<CardDescription className="text-neutral-600">Inventário registrado nesta empresa.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-semibold text-neutral-900">{report.machines.total}</CardContent>
|
||||
|
|
@ -291,7 +291,7 @@ export function CompanyReport() {
|
|||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">Sistemas operacionais</CardTitle>
|
||||
<CardDescription className="text-neutral-600">Inventário das máquinas desta empresa.</CardDescription>
|
||||
<CardDescription className="text-neutral-600">Inventário das dispositivos desta empresa.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-6">
|
||||
<ChartContainer config={MACHINE_STATUS_CONFIG} className="mx-auto aspect-square max-h-[240px]">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery } from "convex/react"
|
||||
import { IconMoodSmile, IconStars, IconMessageCircle2 } from "@tabler/icons-react"
|
||||
import { IconMoodSmile, IconStars, IconMessageCircle2, IconTarget } from "@tabler/icons-react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
@ -13,7 +13,7 @@ import { useState } from "react"
|
|||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||
|
||||
|
|
@ -49,33 +49,56 @@ export function CsatReport() {
|
|||
)
|
||||
}
|
||||
|
||||
const companyOptions = (companies ?? []).map<SearchableComboboxOption>((company) => ({
|
||||
value: company.id,
|
||||
label: company.name,
|
||||
}))
|
||||
|
||||
const comboboxOptions: SearchableComboboxOption[] = [
|
||||
{ value: "all", label: "Todas as empresas" },
|
||||
...companyOptions,
|
||||
]
|
||||
|
||||
const handleCompanyChange = (value: string | null) => {
|
||||
setCompanyId(value ?? "all")
|
||||
}
|
||||
|
||||
const selectedCompany = companyId === "all" ? "all" : companyId
|
||||
const averageScore = typeof data.averageScore === "number" ? data.averageScore : null
|
||||
const positiveRate = typeof data.positiveRate === "number" ? data.positiveRate : null
|
||||
const agentStats = Array.isArray(data.byAgent) ? data.byAgent : []
|
||||
const topAgent = agentStats[0] ?? null
|
||||
|
||||
const agentChartData = agentStats.map((agent: { agentName: string; averageScore: number | null; totalResponses: number; positiveRate: number | null }) => ({
|
||||
agent: agent.agentName ?? "Sem responsável",
|
||||
average: agent.averageScore ?? 0,
|
||||
total: agent.totalResponses ?? 0,
|
||||
positive: agent.positiveRate ? Math.round(agent.positiveRate * 1000) / 10 : 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<IconMoodSmile className="size-4 text-teal-500" /> CSAT médio
|
||||
</CardTitle>
|
||||
<CardDescription className="text-neutral-600">Média das respostas recebidas.</CardDescription>
|
||||
<CardAction>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="Todas as empresas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{(companies ?? []).map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<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={setTimeRange}
|
||||
onValueChange={(value) => {
|
||||
if (value) setTimeRange(value)
|
||||
}}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
|
||||
>
|
||||
|
|
@ -83,19 +106,27 @@ export function CsatReport() {
|
|||
<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>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<IconMoodSmile className="size-4 text-teal-500" /> CSAT médio
|
||||
</CardTitle>
|
||||
<CardDescription className="text-neutral-600">Média das respostas recebidas.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-semibold text-neutral-900">
|
||||
{formatScore(data.averageScore)}
|
||||
{formatScore(averageScore)} <span className="text-base font-normal text-neutral-500">/ 5</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
|
|
@ -105,30 +136,104 @@ export function CsatReport() {
|
|||
</CardHeader>
|
||||
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totalSurveys}</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<IconMessageCircle2 className="size-4 text-sky-500" /> Últimas avaliações
|
||||
<IconTarget className="size-4 text-amber-500" /> Avaliações positivas
|
||||
</CardTitle>
|
||||
<CardDescription className="text-neutral-600">Até 10 registros mais recentes.</CardDescription>
|
||||
<CardDescription className="text-neutral-600">Notas iguais ou superiores a 4.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{data.recent.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">Ainda não coletamos nenhuma avaliação.</p>
|
||||
<CardContent className="text-3xl font-semibold text-neutral-900">
|
||||
{positiveRate === null ? "—" : `${(positiveRate * 100).toFixed(1).replace(".0", "")}%`}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
<IconMessageCircle2 className="size-4 text-sky-500" /> Destaque do período
|
||||
</CardTitle>
|
||||
<CardDescription className="text-neutral-600">Agente com melhor média no recorte selecionado.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
{topAgent ? (
|
||||
<>
|
||||
<p className="text-base font-semibold text-neutral-900">{topAgent.agentName}</p>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{topAgent.averageScore ? `${topAgent.averageScore.toFixed(2)} / 5` : "Sem notas suficientes"}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">{topAgent.totalResponses} avaliação{topAgent.totalResponses === 1 ? "" : "s"}</p>
|
||||
</>
|
||||
) : (
|
||||
data.recent.map((item: { ticketId: string; reference: number; score: number; receivedAt: number }) => (
|
||||
<div key={`${item.ticketId}-${item.receivedAt}`} className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||
<span>#{item.reference}</span>
|
||||
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
||||
Nota {item.score}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
<p className="text-sm text-neutral-500">Ainda não há avaliações suficientes.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">Últimas avaliações</CardTitle>
|
||||
<CardDescription className="text-neutral-600">
|
||||
Até 10 registros mais recentes enviados pelos usuários.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{data.recent.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
|
||||
Ainda não coletamos nenhuma avaliação no período selecionado.
|
||||
</p>
|
||||
) : (
|
||||
data.recent.map(
|
||||
(item: {
|
||||
ticketId: string
|
||||
reference: number
|
||||
score: number
|
||||
maxScore?: number | null
|
||||
comment?: string | null
|
||||
receivedAt: number
|
||||
assigneeName?: string | null
|
||||
}) => {
|
||||
const normalized =
|
||||
item.maxScore && item.maxScore > 0
|
||||
? Math.round(((item.score / item.maxScore) * 5) * 10) / 10
|
||||
: item.score
|
||||
const badgeLabel =
|
||||
item.maxScore && item.maxScore !== 5
|
||||
? `${item.score}/${item.maxScore}`
|
||||
: normalized.toFixed(1).replace(/\.0$/, "")
|
||||
return (
|
||||
<div
|
||||
key={`${item.ticketId}-${item.receivedAt}`}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-semibold text-neutral-800">#{item.reference}</span>
|
||||
{item.assigneeName ? (
|
||||
<Badge variant="outline" className="w-fit rounded-full border-neutral-200 text-xs text-neutral-600">
|
||||
{item.assigneeName}
|
||||
</Badge>
|
||||
) : null}
|
||||
{item.comment ? (
|
||||
<span className="text-xs text-neutral-500 line-clamp-2">“{item.comment}”</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
|
||||
Nota {badgeLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">Distribuição das notas</CardTitle>
|
||||
|
|
@ -140,17 +245,80 @@ export function CsatReport() {
|
|||
{data.totalSurveys === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem respostas no período.</p>
|
||||
) : (
|
||||
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
||||
<ChartContainer config={{ total: { label: "Respostas" } }} className="aspect-auto h-[260px] w-full">
|
||||
<BarChart data={data.distribution.map((d: { score: number; total: number }) => ({ score: `Nota ${d.score}`, total: d.total }))}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="score" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<Bar dataKey="total" fill="var(--chart-3)" radius={[4, 4, 0, 0]} />
|
||||
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="Respostas" />} />
|
||||
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="total" />} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">Desempenho por agente</CardTitle>
|
||||
<CardDescription className="text-neutral-600">
|
||||
Média ponderada (1 a 5) e volume de avaliações recebidas por integrante da equipe.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 lg:grid-cols-[3fr_2fr]">
|
||||
{agentChartData.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
|
||||
Ainda não há 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox"
|
|||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
type ClosingTemplate = { id: string; title: string; body: string }
|
||||
|
||||
|
|
@ -45,7 +46,7 @@ const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
|
|||
body: sanitizeTemplate(`
|
||||
<p>Olá {{cliente}},</p>
|
||||
<p>A equipe da ${DEFAULT_COMPANY_NAME} agradece o contato. Este ticket está sendo encerrado.</p>
|
||||
<p>Se surgirem novas questões, você pode reabrir o ticket em até 7 dias ou nos contatar pelo número <strong>${DEFAULT_PHONE_NUMBER}</strong>. Obrigado.</p>
|
||||
<p>Se surgirem novas questões, você pode reabrir o ticket em até 14 dias ou nos contatar pelo número <strong>${DEFAULT_PHONE_NUMBER}</strong>. Obrigado.</p>
|
||||
<p>{{agente}} · ${DEFAULT_COMPANY_NAME}</p>
|
||||
`),
|
||||
},
|
||||
|
|
@ -67,7 +68,7 @@ const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
|
|||
body: sanitizeTemplate(`
|
||||
<p>Prezado(a) {{cliente}},</p>
|
||||
<p>Esse ticket está sendo encerrado pois realizamos 3 tentativas sem retorno.</p>
|
||||
<p>Você pode reabrir este ticket em até 7 dias ou entrar em contato pelo telefone <strong>${DEFAULT_PHONE_NUMBER}</strong> quando preferir.</p>
|
||||
<p>Você pode reabrir este ticket em até 14 dias ou entrar em contato pelo telefone <strong>${DEFAULT_PHONE_NUMBER}</strong> quando preferir.</p>
|
||||
<p>{{agente}} · ${DEFAULT_COMPANY_NAME}</p>
|
||||
`),
|
||||
},
|
||||
|
|
@ -105,6 +106,7 @@ export function CloseTicketDialog({
|
|||
ticketId,
|
||||
tenantId,
|
||||
actorId,
|
||||
ticketReference,
|
||||
requesterName,
|
||||
agentName,
|
||||
onSuccess,
|
||||
|
|
@ -117,6 +119,7 @@ export function CloseTicketDialog({
|
|||
ticketId: string
|
||||
tenantId: string
|
||||
actorId: Id<"users"> | null
|
||||
ticketReference?: number | null
|
||||
requesterName?: string | null
|
||||
agentName?: string | null
|
||||
onSuccess: () => void
|
||||
|
|
@ -128,7 +131,7 @@ export function CloseTicketDialog({
|
|||
onWorkSummaryAdjusted?: (result: AdjustWorkSummaryResult) => void
|
||||
canAdjustTime?: boolean
|
||||
}) {
|
||||
const updateStatus = useMutation(api.tickets.updateStatus)
|
||||
const resolveTicketMutation = useMutation(api.tickets.resolveTicket)
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
const adjustWorkSummary = useMutation(api.tickets.adjustWorkSummary)
|
||||
|
||||
|
|
@ -160,6 +163,24 @@ export function CloseTicketDialog({
|
|||
const [externalMinutes, setExternalMinutes] = useState<string>("0")
|
||||
const [adjustReason, setAdjustReason] = useState<string>("")
|
||||
const enableAdjustment = Boolean(canAdjustTime && workSummary)
|
||||
const [linkedReference, setLinkedReference] = useState<string>("")
|
||||
const [reopenWindowDays, setReopenWindowDays] = useState<string>("14")
|
||||
|
||||
const normalizedReference = useMemo(() => {
|
||||
const digits = linkedReference.replace(/[^0-9]/g, "").trim()
|
||||
if (!digits) return null
|
||||
const parsed = Number(digits)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
||||
if (ticketReference && parsed === ticketReference) return null
|
||||
return parsed
|
||||
}, [linkedReference, ticketReference])
|
||||
|
||||
const linkedTicket = useQuery(
|
||||
api.tickets.findByReference,
|
||||
actorId && normalizedReference ? { tenantId, viewerId: actorId, reference: normalizedReference } : "skip"
|
||||
) as { id: Id<"tickets">; reference: number; subject: string; status: string } | null | undefined
|
||||
const isLinkLoading = Boolean(actorId && normalizedReference && linkedTicket === undefined)
|
||||
const linkNotFound = Boolean(normalizedReference && linkedTicket === null && !isLinkLoading)
|
||||
|
||||
const hydrateTemplateBody = useCallback((templateHtml: string) => {
|
||||
const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName)
|
||||
|
|
@ -269,6 +290,21 @@ export function CloseTicketDialog({
|
|||
setIsSubmitting(true)
|
||||
toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" })
|
||||
try {
|
||||
if (linkedReference.trim().length > 0) {
|
||||
if (isLinkLoading) {
|
||||
toast.error("Aguarde carregar o ticket vinculado antes de encerrar.", { id: "close-ticket" })
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
if (linkNotFound || !linkedTicket) {
|
||||
toast.error("Não encontramos o ticket informado para vincular. Verifique o número e tente novamente.", {
|
||||
id: "close-ticket",
|
||||
})
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (applyAdjustment) {
|
||||
const result = (await adjustWorkSummary({
|
||||
ticketId: ticketId as unknown as Id<"tickets">,
|
||||
|
|
@ -280,7 +316,13 @@ export function CloseTicketDialog({
|
|||
onWorkSummaryAdjusted?.(result)
|
||||
}
|
||||
|
||||
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: "RESOLVED", actorId })
|
||||
const reopenDaysNumber = Number(reopenWindowDays)
|
||||
await resolveTicketMutation({
|
||||
ticketId: ticketId as unknown as Id<"tickets">,
|
||||
actorId,
|
||||
resolvedWithTicketId: linkedTicket ? (linkedTicket.id as Id<"tickets">) : undefined,
|
||||
reopenWindowDays: Number.isFinite(reopenDaysNumber) ? reopenDaysNumber : undefined,
|
||||
})
|
||||
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
|
||||
const sanitized = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
|
||||
const hasContent = sanitized.replace(/<[^>]*>/g, "").trim().length > 0
|
||||
|
|
@ -351,6 +393,50 @@ export function CloseTicketDialog({
|
|||
/>
|
||||
<p className="text-xs text-neutral-500">Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional.</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="linked-reference" className="text-sm font-medium text-neutral-800">
|
||||
Ticket relacionado (opcional)
|
||||
</Label>
|
||||
<Input
|
||||
id="linked-reference"
|
||||
value={linkedReference}
|
||||
onChange={(event) => setLinkedReference(event.target.value)}
|
||||
placeholder="Número do ticket relacionado (ex.: 12345)"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{linkedReference.trim().length === 0 ? (
|
||||
<p className="text-xs text-neutral-500">Informe o número de outro ticket quando o atendimento estiver relacionado.</p>
|
||||
) : isLinkLoading ? (
|
||||
<p className="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<Spinner className="size-3" /> Procurando ticket #{normalizedReference}...
|
||||
</p>
|
||||
) : linkNotFound ? (
|
||||
<p className="text-xs text-red-500">Ticket não encontrado ou sem acesso permitido. Verifique o número informado.</p>
|
||||
) : linkedTicket ? (
|
||||
<p className="text-xs text-emerald-600">
|
||||
Será registrado vínculo com o ticket #{linkedTicket.reference} — {linkedTicket.subject ?? "Sem assunto"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reopen-window" className="text-sm font-medium text-neutral-800">
|
||||
Reabertura permitida
|
||||
</Label>
|
||||
<Select value={reopenWindowDays} onValueChange={setReopenWindowDays} disabled={isSubmitting}>
|
||||
<SelectTrigger id="reopen-window">
|
||||
<SelectValue placeholder="Escolha o prazo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">7 dias</SelectItem>
|
||||
<SelectItem value="14">14 dias</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-neutral-500">Após esse período o ticket não poderá ser reaberto automaticamente.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{enableAdjustment ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
|
||||
|
|
|
|||
|
|
@ -90,6 +90,23 @@ function RequesterPreview({ customer, company }: RequesterPreviewProps) {
|
|||
|
||||
const NO_COMPANY_VALUE = "__no_company__"
|
||||
|
||||
type TicketFormFieldDefinition = {
|
||||
id: string
|
||||
key: string
|
||||
label: string
|
||||
type: string
|
||||
required: boolean
|
||||
description: string
|
||||
options: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
type TicketFormDefinition = {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
fields: TicketFormFieldDefinition[]
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
subject: z.string().default(""),
|
||||
summary: z.string().optional(),
|
||||
|
|
@ -158,6 +175,41 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
[companiesRemote]
|
||||
)
|
||||
|
||||
const formsRemote = useQuery(
|
||||
api.tickets.listTicketForms,
|
||||
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
) as TicketFormDefinition[] | undefined
|
||||
|
||||
const forms = useMemo<TicketFormDefinition[]>(() => {
|
||||
const base: TicketFormDefinition = {
|
||||
key: "default",
|
||||
label: "Chamado padrão",
|
||||
description: "Formulário básico para abertura de chamados gerais.",
|
||||
fields: [],
|
||||
}
|
||||
if (Array.isArray(formsRemote) && formsRemote.length > 0) {
|
||||
return [base, ...formsRemote]
|
||||
}
|
||||
return [base]
|
||||
}, [formsRemote])
|
||||
|
||||
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
|
||||
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
|
||||
|
||||
const selectedForm = useMemo(() => forms.find((formDef) => formDef.key === selectedFormKey) ?? forms[0], [forms, selectedFormKey])
|
||||
|
||||
const handleFormSelection = (key: string) => {
|
||||
setSelectedFormKey(key)
|
||||
setCustomFieldValues({})
|
||||
}
|
||||
|
||||
const handleCustomFieldChange = (field: TicketFormFieldDefinition, value: unknown) => {
|
||||
setCustomFieldValues((prev) => ({
|
||||
...prev,
|
||||
[field.id]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const customersRemote = useQuery(
|
||||
api.users.listCustomers,
|
||||
directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
|
|
@ -395,6 +447,52 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
return
|
||||
}
|
||||
|
||||
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
|
||||
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
|
||||
for (const field of selectedForm.fields) {
|
||||
const raw = customFieldValues[field.id]
|
||||
const isBooleanField = field.type === "boolean"
|
||||
const isEmpty =
|
||||
raw === undefined ||
|
||||
raw === null ||
|
||||
(typeof raw === "string" && raw.trim().length === 0)
|
||||
|
||||
if (isBooleanField) {
|
||||
const boolValue = Boolean(raw)
|
||||
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value: boolValue })
|
||||
continue
|
||||
}
|
||||
|
||||
if (field.required && isEmpty) {
|
||||
toast.error(`Preencha o campo "${field.label}".`, { id: "new-ticket" })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
continue
|
||||
}
|
||||
|
||||
let value: unknown = raw
|
||||
if (field.type === "number") {
|
||||
const parsed = typeof raw === "number" ? raw : Number(raw)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
toast.error(`Informe um valor numérico válido para "${field.label}".`, { id: "new-ticket" })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
value = parsed
|
||||
} else if (field.type === "boolean") {
|
||||
value = Boolean(raw)
|
||||
} else if (field.type === "date") {
|
||||
value = String(raw)
|
||||
} else {
|
||||
value = String(raw)
|
||||
}
|
||||
|
||||
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value })
|
||||
}
|
||||
}
|
||||
setLoading(true)
|
||||
toast.loading("Criando ticket…", { id: "new-ticket" })
|
||||
try {
|
||||
|
|
@ -413,6 +511,8 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
assigneeId: selectedAssignee ? (selectedAssignee as Id<"users">) : undefined,
|
||||
categoryId: values.categoryId as Id<"ticketCategories">,
|
||||
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
|
||||
formTemplate: selectedFormKey !== "default" ? selectedFormKey : undefined,
|
||||
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
|
||||
})
|
||||
const summaryFallback = values.summary?.trim() ?? ""
|
||||
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
|
||||
|
|
@ -446,6 +546,8 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
subcategoryId: "",
|
||||
})
|
||||
form.clearErrors()
|
||||
setSelectedFormKey("default")
|
||||
setCustomFieldValues({})
|
||||
setAssigneeInitialized(false)
|
||||
setAttachments([])
|
||||
// Navegar para o ticket recém-criado
|
||||
|
|
@ -497,6 +599,28 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{forms.length > 1 ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
|
||||
<p className="text-sm font-semibold text-neutral-800">Modelo de ticket</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{forms.map((formDef) => (
|
||||
<Button
|
||||
key={formDef.key}
|
||||
type="button"
|
||||
variant={selectedFormKey === formDef.key ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleFormSelection(formDef.key)}
|
||||
>
|
||||
{formDef.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{selectedForm?.description ? (
|
||||
<p className="mt-2 text-xs text-neutral-500">{selectedForm.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FieldSet>
|
||||
<FieldGroup className="lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<div className="space-y-4">
|
||||
|
|
@ -811,6 +935,118 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? (
|
||||
<div className="space-y-4 rounded-xl border border-slate-200 bg-white px-4 py-4">
|
||||
<p className="text-sm font-semibold text-neutral-800">Informações adicionais</p>
|
||||
{selectedForm.fields.map((field) => {
|
||||
const value = customFieldValues[field.id]
|
||||
const fieldId = `custom-field-${field.id}`
|
||||
const labelSuffix = field.required ? <span className="text-destructive">*</span> : null
|
||||
const helpText = field.description ? (
|
||||
<p className="text-xs text-neutral-500">{field.description}</p>
|
||||
) : null
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"
|
||||
>
|
||||
<input
|
||||
id={fieldId}
|
||||
type="checkbox"
|
||||
className="size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.checked)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
|
||||
{field.label} {labelSuffix}
|
||||
</label>
|
||||
{helpText}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<Field key={field.id}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(selected) => handleCustomFieldChange(field, selected)}
|
||||
>
|
||||
<SelectTrigger className={selectTriggerClass}>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className={selectItemClass}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<Field key={field.id}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "date") {
|
||||
return (
|
||||
<Field key={field.id}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
type="date"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field key={field.id}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {labelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export function StatusSelect({
|
|||
value,
|
||||
tenantId,
|
||||
requesterName,
|
||||
ticketReference,
|
||||
showCloseButton = true,
|
||||
onStatusChange,
|
||||
}: {
|
||||
|
|
@ -39,6 +40,7 @@ export function StatusSelect({
|
|||
value: TicketStatus
|
||||
tenantId: string
|
||||
requesterName?: string | null
|
||||
ticketReference?: number | null
|
||||
showCloseButton?: boolean
|
||||
onStatusChange?: (next: TicketStatus) => void
|
||||
}) {
|
||||
|
|
@ -94,6 +96,7 @@ export function StatusSelect({
|
|||
ticketId={ticketId}
|
||||
tenantId={tenantId}
|
||||
actorId={actorId}
|
||||
ticketReference={ticketReference ?? null}
|
||||
requesterName={requesterName}
|
||||
agentName={agentName}
|
||||
onSuccess={() => {
|
||||
|
|
|
|||
203
src/components/tickets/ticket-chat-panel.tsx
Normal file
203
src/components/tickets/ticket-chat-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
234
src/components/tickets/ticket-csat-card.tsx
Normal file
234
src/components/tickets/ticket-csat-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ import { TicketComments } from "@/components/tickets/ticket-comments.rich";
|
|||
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
||||
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
|
||||
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
|
||||
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card";
|
||||
import { TicketChatPanel } from "@/components/tickets/ticket-chat-panel";
|
||||
import { useAuth } from "@/lib/auth-client";
|
||||
|
||||
export function TicketDetailView({ id }: { id: string }) {
|
||||
|
|
@ -90,9 +92,11 @@ export function TicketDetailView({ id }: { id: string }) {
|
|||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<TicketSummaryHeader ticket={ticket} />
|
||||
<TicketCsatCard ticket={ticket} />
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="space-y-6">
|
||||
<TicketComments ticket={ticket} />
|
||||
<TicketChatPanel ticketId={ticket.id as string} />
|
||||
<TicketTimeline ticket={ticket} />
|
||||
</div>
|
||||
<TicketDetailsPanel ticket={ticket} />
|
||||
|
|
|
|||
|
|
@ -142,6 +142,22 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
: machineAssignedName && machineAssignedName.length > 0
|
||||
? machineAssignedName
|
||||
: null
|
||||
const viewerId = convexUserId ?? null
|
||||
const viewerRole = (role ?? "").toLowerCase()
|
||||
const [status, setStatus] = useState<TicketStatus>(ticket.status)
|
||||
const reopenDeadline = ticket.reopenDeadline ?? null
|
||||
const isRequester = Boolean(ticket.requester?.id && viewerId && ticket.requester.id === viewerId)
|
||||
const reopenWindowActive = reopenDeadline ? reopenDeadline > Date.now() : false
|
||||
const canReopenTicket =
|
||||
status === "RESOLVED" && reopenWindowActive && (isStaff || viewerRole === "manager" || isRequester)
|
||||
const reopenDeadlineLabel = useMemo(() => {
|
||||
if (!reopenDeadline) return null
|
||||
try {
|
||||
return new Date(reopenDeadline).toLocaleString("pt-BR")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [reopenDeadline])
|
||||
const viewerEmail = session?.user?.email ?? machineContext?.assignedUserEmail ?? null
|
||||
const viewerAvatar = session?.user?.avatarUrl ?? null
|
||||
const viewerAgentMeta = useMemo(
|
||||
|
|
@ -165,6 +181,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const startWork = useMutation(api.tickets.startWork)
|
||||
const pauseWork = useMutation(api.tickets.pauseWork)
|
||||
const updateCategories = useMutation(api.tickets.updateCategories)
|
||||
const reopenTicket = useMutation(api.tickets.reopenTicket)
|
||||
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
|
||||
const queuesEnabled = Boolean(isStaff && convexUserId)
|
||||
const companiesRemote = useQuery(
|
||||
|
|
@ -227,7 +244,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
| null
|
||||
| undefined
|
||||
|
||||
const [status, setStatus] = useState<TicketStatus>(ticket.status)
|
||||
const [assigneeState, setAssigneeState] = useState(ticket.assignee ?? null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [subject, setSubject] = useState(ticket.subject)
|
||||
|
|
@ -242,6 +258,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const [assigneeSelection, setAssigneeSelection] = useState(currentAssigneeId)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [pauseDialogOpen, setPauseDialogOpen] = useState(false)
|
||||
const [isReopening, setIsReopening] = useState(false)
|
||||
const [pauseReason, setPauseReason] = useState<string>(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
|
||||
const [pauseNote, setPauseNote] = useState("")
|
||||
const [pausing, setPausing] = useState(false)
|
||||
|
|
@ -326,8 +343,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
|
||||
const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id])
|
||||
const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty
|
||||
const assigneeReasonRequired = assigneeDirty && !isManager
|
||||
const assigneeReasonValid = !assigneeReasonRequired || assigneeChangeReason.trim().length >= 5
|
||||
const normalizedAssigneeReason = assigneeChangeReason.trim()
|
||||
const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5
|
||||
const saveDisabled = !formDirty || saving || !assigneeReasonValid
|
||||
const companyLabel = useMemo(() => {
|
||||
if (ticket.company?.name) return ticket.company.name
|
||||
|
|
@ -488,9 +505,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
throw new Error("assignee-not-allowed")
|
||||
}
|
||||
const reasonValue = assigneeChangeReason.trim()
|
||||
if (reasonValue.length < 5) {
|
||||
setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres.")
|
||||
toast.error("Informe um motivo para registrar a troca do responsável.", { id: "assignee" })
|
||||
if (reasonValue.length > 0 && reasonValue.length < 5) {
|
||||
setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres ou deixe em branco.")
|
||||
toast.error("Informe ao menos 5 caracteres no motivo ou deixe o campo vazio.", { id: "assignee" })
|
||||
return
|
||||
}
|
||||
if (reasonValue.length > 1000) {
|
||||
|
|
@ -505,7 +522,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
ticketId: ticket.id as Id<"tickets">,
|
||||
assigneeId: assigneeSelection as Id<"users">,
|
||||
actorId: convexUserId as Id<"users">,
|
||||
reason: reasonValue,
|
||||
reason: reasonValue.length > 0 ? reasonValue : undefined,
|
||||
})
|
||||
toast.success("Responsável atualizado!", { id: "assignee" })
|
||||
if (assigneeSelection) {
|
||||
|
|
@ -1008,6 +1025,26 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
}
|
||||
}, [ticket.id, ticket.reference])
|
||||
|
||||
const handleReopenTicket = useCallback(async () => {
|
||||
if (!viewerId) {
|
||||
toast.error("Não foi possível identificar o usuário atual.")
|
||||
return
|
||||
}
|
||||
toast.dismiss("ticket-reopen")
|
||||
setIsReopening(true)
|
||||
toast.loading("Reabrindo ticket...", { id: "ticket-reopen" })
|
||||
try {
|
||||
await reopenTicket({ ticketId: ticket.id as Id<"tickets">, actorId: viewerId as Id<"users"> })
|
||||
toast.success("Ticket reaberto com sucesso!", { id: "ticket-reopen" })
|
||||
setStatus("AWAITING_ATTENDANCE")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível reabrir o ticket.", { id: "ticket-reopen" })
|
||||
} finally {
|
||||
setIsReopening(false)
|
||||
}
|
||||
}, [reopenTicket, ticket.id, viewerId])
|
||||
|
||||
return (
|
||||
<div className={cardClass}>
|
||||
<div className="absolute right-6 top-6 flex items-center gap-3">
|
||||
|
|
@ -1065,6 +1102,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
ticketId={ticket.id as unknown as string}
|
||||
tenantId={ticket.tenantId}
|
||||
actorId={convexUserId as Id<"users"> | null}
|
||||
ticketReference={ticket.reference ?? null}
|
||||
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
|
||||
agentName={agentName}
|
||||
workSummary={
|
||||
|
|
@ -1095,9 +1133,26 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
value={status}
|
||||
tenantId={ticket.tenantId}
|
||||
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
|
||||
ticketReference={ticket.reference ?? null}
|
||||
showCloseButton={false}
|
||||
onStatusChange={setStatus}
|
||||
/>
|
||||
{canReopenTicket ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white text-sm font-semibold text-neutral-700 hover:bg-slate-50"
|
||||
onClick={handleReopenTicket}
|
||||
disabled={isReopening}
|
||||
>
|
||||
{isReopening ? <Spinner className="size-4 text-neutral-600" /> : null}
|
||||
Reabrir
|
||||
</Button>
|
||||
) : null}
|
||||
{canReopenTicket && reopenDeadlineLabel ? (
|
||||
<p className="text-xs text-neutral-500">Prazo para reabrir: {reopenDeadlineLabel}</p>
|
||||
) : null}
|
||||
{isPlaying ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -1427,8 +1482,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
</p>
|
||||
{assigneeReasonError ? (
|
||||
<p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p>
|
||||
) : assigneeReasonRequired && assigneeChangeReason.trim().length < 5 ? (
|
||||
<p className="text-xs font-semibold text-rose-600">Descreva o motivo com pelo menos 5 caracteres.</p>
|
||||
) : normalizedAssigneeReason.length > 0 && normalizedAssigneeReason.length < 5 ? (
|
||||
<p className="text-xs font-semibold text-rose-600">Descreva o motivo com pelo menos 5 caracteres ou deixe em branco.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -351,17 +351,55 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
message = "CSAT recebido"
|
||||
}
|
||||
if (entry.type === "CSAT_RATED") {
|
||||
const score = typeof payload.score === "number" ? payload.score : payload.rating
|
||||
const maxScore =
|
||||
typeof payload.maxScore === "number"
|
||||
? payload.maxScore
|
||||
: typeof payload.max === "number"
|
||||
? payload.max
|
||||
const rawScoreSource = (payload as { score?: unknown; rating?: unknown }) ?? {}
|
||||
const rawScore =
|
||||
typeof rawScoreSource.score === "number"
|
||||
? rawScoreSource.score
|
||||
: typeof rawScoreSource.rating === "number"
|
||||
? rawScoreSource.rating
|
||||
: null
|
||||
const rawMaxSource = (payload as { maxScore?: unknown; max?: unknown }) ?? {}
|
||||
const rawMax =
|
||||
typeof rawMaxSource.maxScore === "number"
|
||||
? rawMaxSource.maxScore
|
||||
: typeof rawMaxSource.max === "number"
|
||||
? rawMaxSource.max
|
||||
: undefined
|
||||
message =
|
||||
typeof score === "number"
|
||||
? `CSAT avaliado: ${score}${typeof maxScore === "number" ? `/${maxScore}` : ""}`
|
||||
: "CSAT avaliado"
|
||||
const safeMax = rawMax && Number.isFinite(rawMax) && rawMax > 0 ? Math.round(rawMax) : 5
|
||||
const safeScore =
|
||||
typeof rawScore === "number" && Number.isFinite(rawScore)
|
||||
? Math.max(1, Math.min(safeMax, Math.round(rawScore)))
|
||||
: null
|
||||
const rawComment = (payload as { comment?: unknown })?.comment
|
||||
const comment =
|
||||
typeof rawComment === "string" && rawComment.trim().length > 0
|
||||
? rawComment.trim()
|
||||
: null
|
||||
message = (
|
||||
<div className="space-y-1">
|
||||
<span>
|
||||
CSAT avaliado:{" "}
|
||||
<span className="font-semibold text-neutral-900">
|
||||
{safeScore ?? "—"}/{safeMax}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-amber-500">
|
||||
{Array.from({ length: safeMax }).map((_, index) => (
|
||||
<IconStar
|
||||
key={index}
|
||||
className="size-3.5"
|
||||
strokeWidth={1.5}
|
||||
fill={safeScore !== null && index < safeScore ? "currentColor" : "none"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{comment ? (
|
||||
<span className="block rounded-lg bg-slate-100 px-3 py-1 text-xs text-neutral-600">
|
||||
“{comment}”
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!message) return null
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ function ChartTooltipContent({
|
|||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
valueFormatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
|
|
@ -125,6 +126,7 @@ function ChartTooltipContent({
|
|||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
valueFormatter?: (value: unknown, name?: string, item?: unknown) => React.ReactNode
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
|
|
@ -234,9 +236,21 @@ function ChartTooltipContent({
|
|||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
{item.value !== undefined && item.value !== null && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
}, [session?.user])
|
||||
|
||||
// Sempre tenta obter o contexto da máquina.
|
||||
// Sempre tenta obter o contexto da dispositivo.
|
||||
// 1) Se a sessão Better Auth indicar role "machine", buscamos normalmente.
|
||||
// 2) Se a sessão vier nula (alguns ambientes WebView), ainda assim tentamos
|
||||
// carregar o contexto — se a API responder 200, assumimos que há sessão válida
|
||||
|
|
@ -303,7 +303,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ensureUser, session?.user?.email, session?.user?.tenantId, session?.user?.role, convexUserId])
|
||||
|
||||
// Se não houver sessão mas tivermos contexto de máquina, tratamos como "machine"
|
||||
// Se não houver sessão mas tivermos contexto de dispositivo, tratamos como "machine"
|
||||
const baseRole = session?.user?.role ? session.user.role.toLowerCase() : (machineContext ? "machine" : null)
|
||||
const personaRole = session?.user?.machinePersona ? session.user.machinePersona.toLowerCase() : null
|
||||
const normalizedRole =
|
||||
|
|
|
|||
67
src/lib/device-inventory-columns.ts
Normal file
67
src/lib/device-inventory-columns.ts
Normal 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"]
|
||||
|
|
@ -65,6 +65,11 @@ const serverTicketSchema = z.object({
|
|||
tags: z.array(z.string()).default([]).optional(),
|
||||
lastTimelineEntry: z.string().nullable().optional(),
|
||||
metrics: z.any().nullable().optional(),
|
||||
csatScore: z.number().nullable().optional(),
|
||||
csatMaxScore: z.number().nullable().optional(),
|
||||
csatComment: z.string().nullable().optional(),
|
||||
csatRatedAt: z.number().nullable().optional(),
|
||||
csatRatedBy: z.string().nullable().optional(),
|
||||
category: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
|
|
@ -154,9 +159,17 @@ const serverTicketWithDetailsSchema = serverTicketSchema.extend({
|
|||
});
|
||||
|
||||
export function mapTicketFromServer(input: unknown) {
|
||||
const s = serverTicketSchema.parse(input);
|
||||
const {
|
||||
csatScore,
|
||||
csatMaxScore,
|
||||
csatComment,
|
||||
csatRatedAt,
|
||||
csatRatedBy,
|
||||
...base
|
||||
} = serverTicketSchema.parse(input);
|
||||
const s = { csatScore, csatMaxScore, csatComment, csatRatedAt, csatRatedBy, ...base };
|
||||
const ui = {
|
||||
...s,
|
||||
...base,
|
||||
status: normalizeTicketStatus(s.status),
|
||||
company: s.company
|
||||
? { id: s.company.id, name: s.company.name, isAvulso: s.company.isAvulso ?? false }
|
||||
|
|
@ -179,6 +192,11 @@ export function mapTicketFromServer(input: unknown) {
|
|||
dueAt: s.dueAt ? new Date(s.dueAt) : null,
|
||||
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
|
||||
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
|
||||
csatScore: typeof csatScore === "number" ? csatScore : null,
|
||||
csatMaxScore: typeof csatMaxScore === "number" ? csatMaxScore : null,
|
||||
csatComment: typeof csatComment === "string" && csatComment.trim().length > 0 ? csatComment.trim() : null,
|
||||
csatRatedAt: csatRatedAt ? new Date(csatRatedAt) : null,
|
||||
csatRatedBy: csatRatedBy ?? null,
|
||||
workSummary: s.workSummary
|
||||
? {
|
||||
totalWorkedMs: s.workSummary.totalWorkedMs,
|
||||
|
|
@ -211,7 +229,15 @@ export function mapTicketsFromServerList(arr: unknown[]) {
|
|||
}
|
||||
|
||||
export function mapTicketWithDetailsFromServer(input: unknown) {
|
||||
const s = serverTicketWithDetailsSchema.parse(input);
|
||||
const {
|
||||
csatScore,
|
||||
csatMaxScore,
|
||||
csatComment,
|
||||
csatRatedAt,
|
||||
csatRatedBy,
|
||||
...base
|
||||
} = serverTicketWithDetailsSchema.parse(input);
|
||||
const s = { csatScore, csatMaxScore, csatComment, csatRatedAt, csatRatedBy, ...base };
|
||||
const customFields = Object.entries(s.customFields ?? {}).reduce<
|
||||
Record<string, { label: string; type: string; value?: unknown; displayValue?: string }>
|
||||
>(
|
||||
|
|
@ -231,47 +257,52 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
|
|||
{}
|
||||
);
|
||||
const ui = {
|
||||
...s,
|
||||
...base,
|
||||
customFields,
|
||||
status: normalizeTicketStatus(s.status),
|
||||
category: s.category ?? undefined,
|
||||
subcategory: s.subcategory ?? undefined,
|
||||
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
|
||||
updatedAt: new Date(s.updatedAt),
|
||||
createdAt: new Date(s.createdAt),
|
||||
dueAt: s.dueAt ? new Date(s.dueAt) : null,
|
||||
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
|
||||
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
|
||||
company: s.company ? { id: s.company.id, name: s.company.name, isAvulso: s.company.isAvulso ?? false } : undefined,
|
||||
machine: s.machine
|
||||
status: normalizeTicketStatus(base.status),
|
||||
category: base.category ?? undefined,
|
||||
subcategory: base.subcategory ?? undefined,
|
||||
lastTimelineEntry: base.lastTimelineEntry ?? undefined,
|
||||
updatedAt: new Date(base.updatedAt),
|
||||
createdAt: new Date(base.createdAt),
|
||||
dueAt: base.dueAt ? new Date(base.dueAt) : null,
|
||||
firstResponseAt: base.firstResponseAt ? new Date(base.firstResponseAt) : null,
|
||||
resolvedAt: base.resolvedAt ? new Date(base.resolvedAt) : null,
|
||||
csatScore: typeof csatScore === "number" ? csatScore : null,
|
||||
csatMaxScore: typeof csatMaxScore === "number" ? csatMaxScore : null,
|
||||
csatComment: typeof csatComment === "string" && csatComment.trim().length > 0 ? csatComment.trim() : null,
|
||||
csatRatedAt: csatRatedAt ? new Date(csatRatedAt) : null,
|
||||
csatRatedBy: csatRatedBy ?? null,
|
||||
company: base.company ? { id: base.company.id, name: base.company.name, isAvulso: base.company.isAvulso ?? false } : undefined,
|
||||
machine: base.machine
|
||||
? {
|
||||
id: s.machine.id ?? null,
|
||||
hostname: s.machine.hostname ?? null,
|
||||
persona: s.machine.persona ?? null,
|
||||
assignedUserName: s.machine.assignedUserName ?? null,
|
||||
assignedUserEmail: s.machine.assignedUserEmail ?? null,
|
||||
status: s.machine.status ?? null,
|
||||
id: base.machine.id ?? null,
|
||||
hostname: base.machine.hostname ?? null,
|
||||
persona: base.machine.persona ?? null,
|
||||
assignedUserName: base.machine.assignedUserName ?? null,
|
||||
assignedUserEmail: base.machine.assignedUserEmail ?? null,
|
||||
status: base.machine.status ?? null,
|
||||
}
|
||||
: null,
|
||||
timeline: s.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })),
|
||||
comments: s.comments.map((c) => ({
|
||||
timeline: base.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })),
|
||||
comments: base.comments.map((c) => ({
|
||||
...c,
|
||||
createdAt: new Date(c.createdAt),
|
||||
updatedAt: new Date(c.updatedAt),
|
||||
})),
|
||||
workSummary: s.workSummary
|
||||
workSummary: base.workSummary
|
||||
? {
|
||||
totalWorkedMs: s.workSummary.totalWorkedMs,
|
||||
internalWorkedMs: s.workSummary.internalWorkedMs ?? 0,
|
||||
externalWorkedMs: s.workSummary.externalWorkedMs ?? 0,
|
||||
serverNow: s.workSummary.serverNow,
|
||||
activeSession: s.workSummary.activeSession
|
||||
totalWorkedMs: base.workSummary.totalWorkedMs,
|
||||
internalWorkedMs: base.workSummary.internalWorkedMs ?? 0,
|
||||
externalWorkedMs: base.workSummary.externalWorkedMs ?? 0,
|
||||
serverNow: base.workSummary.serverNow,
|
||||
activeSession: base.workSummary.activeSession
|
||||
? {
|
||||
...s.workSummary.activeSession,
|
||||
startedAt: new Date(s.workSummary.activeSession.startedAt),
|
||||
...base.workSummary.activeSession,
|
||||
startedAt: new Date(base.workSummary.activeSession.startedAt),
|
||||
}
|
||||
: null,
|
||||
perAgentTotals: (s.workSummary.perAgentTotals ?? []).map((item) => ({
|
||||
perAgentTotals: (base.workSummary.perAgentTotals ?? []).map((item) => ({
|
||||
agentId: item.agentId,
|
||||
agentName: item.agentName ?? null,
|
||||
agentEmail: item.agentEmail ?? null,
|
||||
|
|
|
|||
|
|
@ -150,6 +150,19 @@ export const ticketSchema = z.object({
|
|||
timeOpenedMinutes: z.number().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
relatedTicketIds: z.array(z.string()).optional(),
|
||||
resolvedWithTicketId: z.string().nullable().optional(),
|
||||
reopenDeadline: z.number().nullable().optional(),
|
||||
reopenWindowDays: z.number().nullable().optional(),
|
||||
reopenedAt: z.number().nullable().optional(),
|
||||
reopenedBy: z.string().nullable().optional(),
|
||||
chatEnabled: z.boolean().optional(),
|
||||
formTemplate: z.string().nullable().optional(),
|
||||
csatScore: z.number().nullable().optional(),
|
||||
csatMaxScore: z.number().nullable().optional(),
|
||||
csatComment: z.string().nullable().optional(),
|
||||
csatRatedAt: z.coerce.date().nullable().optional(),
|
||||
csatRatedBy: z.string().nullable().optional(),
|
||||
category: ticketCategorySummarySchema.nullable().optional(),
|
||||
subcategory: ticketSubcategorySummarySchema.nullable().optional(),
|
||||
workSummary: z
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
|
|||
const context = await auth.$context
|
||||
|
||||
const passwordHash = await context.password.hash(machineToken)
|
||||
const machineName = `Máquina ${hostname}`
|
||||
const machineName = `Dispositivo ${hostname}`
|
||||
|
||||
const user = await prisma.authUser.upsert({
|
||||
where: { email: machineEmail },
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export type MachineSessionContext = {
|
|||
}
|
||||
|
||||
export class MachineInactiveError extends Error {
|
||||
constructor(message = "Máquina desativada") {
|
||||
constructor(message = "Dispositivo desativada") {
|
||||
super(message)
|
||||
this.name = "MachineInactiveError"
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ export async function createMachineSession(machineToken: string, rememberMe = tr
|
|||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
const resolved = await client.mutation(api.machines.resolveToken, { machineToken })
|
||||
const resolved = await client.mutation(api.devices.resolveToken, { machineToken })
|
||||
let machineEmail = resolved.machine.authEmail ?? null
|
||||
|
||||
const machineActive = resolved.machine.isActive ?? true
|
||||
|
|
@ -62,7 +62,7 @@ export async function createMachineSession(machineToken: string, rememberMe = tr
|
|||
persona: (resolved.machine.persona ?? null) ?? undefined,
|
||||
})
|
||||
|
||||
await client.mutation(api.machines.linkAuthAccount, {
|
||||
await client.mutation(api.devices.linkAuthAccount, {
|
||||
machineId: resolved.machine._id as Id<"machines">,
|
||||
authUserId: account.authUserId,
|
||||
authEmail: account.authEmail,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
import { buildXlsxWorkbook, type WorksheetConfig } from "@/lib/xlsx"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { DEVICE_INVENTORY_COLUMN_METADATA, type DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns"
|
||||
import { buildXlsxWorkbook, type WorksheetConfig } from "@/lib/xlsx"
|
||||
|
||||
type DeviceCustomField = {
|
||||
fieldId: Id<"deviceFields">
|
||||
fieldKey: string
|
||||
label: string
|
||||
type: string
|
||||
value: unknown
|
||||
displayValue?: string
|
||||
}
|
||||
|
||||
type LinkedUser = {
|
||||
id: string
|
||||
|
|
@ -11,6 +21,11 @@ export type MachineInventoryRecord = {
|
|||
id: Id<"machines">
|
||||
tenantId: string
|
||||
hostname: string
|
||||
displayName: string | null
|
||||
deviceType: string | null
|
||||
devicePlatform: string | null
|
||||
deviceProfile?: Record<string, unknown> | null
|
||||
managementMode: string | null
|
||||
companyId: Id<"companies"> | null
|
||||
companySlug: string | null
|
||||
companyName: string | null
|
||||
|
|
@ -36,6 +51,7 @@ export type MachineInventoryRecord = {
|
|||
lastPostureAt?: number | null
|
||||
remoteAccess?: unknown
|
||||
linkedUsers?: LinkedUser[]
|
||||
customFields?: DeviceCustomField[]
|
||||
}
|
||||
|
||||
type WorkbookOptions = {
|
||||
|
|
@ -43,6 +59,7 @@ type WorkbookOptions = {
|
|||
generatedBy?: string | null
|
||||
companyFilterLabel?: string | null
|
||||
generatedAt?: Date
|
||||
columns?: DeviceInventoryColumnConfig[]
|
||||
}
|
||||
|
||||
type SoftwareEntry = {
|
||||
|
|
@ -54,59 +71,270 @@ type SoftwareEntry = {
|
|||
installedOn: string | null
|
||||
}
|
||||
|
||||
const INVENTORY_HEADERS = [
|
||||
"Hostname",
|
||||
"Empresa",
|
||||
"Status",
|
||||
"Persona",
|
||||
"Ativa",
|
||||
"Último heartbeat",
|
||||
"Responsável",
|
||||
"E-mail responsável",
|
||||
"Usuários vinculados",
|
||||
"E-mail autenticado",
|
||||
"Sistema operacional",
|
||||
"Versão SO",
|
||||
"Arquitetura",
|
||||
"Fabricante",
|
||||
"Modelo",
|
||||
"Serial hardware",
|
||||
"Processador",
|
||||
"Cores físicas",
|
||||
"Cores lógicas",
|
||||
"Memória (GiB)",
|
||||
"GPUs",
|
||||
"Labels",
|
||||
"MACs",
|
||||
"Seriais",
|
||||
"IP principal",
|
||||
"IP público",
|
||||
"Registrada via",
|
||||
"Token expira em",
|
||||
"Token último uso",
|
||||
"Uso do token",
|
||||
"Criada em",
|
||||
"Atualizada em",
|
||||
"Softwares instalados",
|
||||
"Build SO",
|
||||
"Licença ativada",
|
||||
"Experiência SO",
|
||||
"Domínio",
|
||||
"Grupo de trabalho",
|
||||
"Nome do dispositivo",
|
||||
"Serial placa-mãe",
|
||||
"Colaborador (nome)",
|
||||
"Colaborador (e-mail)",
|
||||
"Acessos remotos",
|
||||
"Fleet ID",
|
||||
"Equipe Fleet",
|
||||
"Fleet atualizado em",
|
||||
] as const
|
||||
type InventoryColumnDefinition = {
|
||||
key: string
|
||||
label: string
|
||||
width: number
|
||||
getValue: (machine: MachineInventoryRecord, derived: MachineDerivedData) => unknown
|
||||
}
|
||||
|
||||
const INVENTORY_COLUMN_WIDTHS = [
|
||||
22, 26, 16, 14, 10, 20, 22, 24, 28, 24, 20, 18, 14, 18, 22, 22, 24, 12, 12, 14, 26, 20, 24, 24, 18, 18, 18, 20, 20, 14, 20, 20, 18,
|
||||
18, 18, 20, 20, 20, 26, 24, 24, 26, 16, 18, 18, 20,
|
||||
] as const
|
||||
type MachineDerivedData = {
|
||||
inventory: Record<string, unknown>
|
||||
hardware: ReturnType<typeof extractHardware>
|
||||
gpuNames: string[]
|
||||
labels: string[]
|
||||
primaryIp: string | null
|
||||
publicIp: string | null
|
||||
softwareEntries: SoftwareEntry[]
|
||||
linkedUsersLabel: string | null
|
||||
systemInfo: ReturnType<typeof extractSystemInfo>
|
||||
collaborator: ReturnType<typeof extractCollaborator>
|
||||
remoteAccessCount: number
|
||||
fleetInfo: ReturnType<typeof extractFleetInfo>
|
||||
customFieldByKey: Record<string, DeviceCustomField>
|
||||
customFieldById: Record<string, DeviceCustomField>
|
||||
}
|
||||
|
||||
const COLUMN_VALUE_RESOLVERS: Record<string, (machine: MachineInventoryRecord, derived: MachineDerivedData) => unknown> = {
|
||||
displayName: (machine) => machine.displayName ?? machine.hostname,
|
||||
hostname: (machine) => machine.hostname,
|
||||
deviceType: (machine) => describeDeviceType(machine.deviceType),
|
||||
devicePlatform: (machine) => machine.devicePlatform ?? null,
|
||||
company: (machine) => machine.companyName ?? null,
|
||||
status: (machine) => describeStatus(machine.status),
|
||||
persona: (machine) => describePersona(machine.persona),
|
||||
active: (machine) => yesNo(machine.isActive),
|
||||
lastHeartbeat: (machine) => formatDateTime(machine.lastHeartbeatAt),
|
||||
assignedUser: (machine) => machine.assignedUserName ?? machine.assignedUserEmail ?? null,
|
||||
assignedEmail: (machine) => machine.assignedUserEmail ?? null,
|
||||
linkedUsers: (_machine, derived) => derived.linkedUsersLabel,
|
||||
authEmail: (machine) => machine.authEmail ?? null,
|
||||
osName: (machine) => machine.osName,
|
||||
osVersion: (machine) => machine.osVersion ?? null,
|
||||
architecture: (machine) => machine.architecture ?? null,
|
||||
hardwareVendor: (_machine, derived) => derived.hardware.vendor ?? derived.systemInfo.systemManufacturer ?? null,
|
||||
hardwareModel: (_machine, derived) => derived.hardware.model ?? derived.systemInfo.systemModel ?? null,
|
||||
hardwareSerial: (_machine, derived) => derived.hardware.serial ?? derived.systemInfo.boardSerial ?? null,
|
||||
cpu: (_machine, derived) => derived.hardware.cpuType ?? null,
|
||||
physicalCores: (_machine, derived) => derived.hardware.physicalCores ?? null,
|
||||
logicalCores: (_machine, derived) => derived.hardware.logicalCores ?? null,
|
||||
memoryGiB: (_machine, derived) => derived.hardware.memoryGiB ?? null,
|
||||
gpus: (_machine, derived) => (derived.gpuNames.length > 0 ? derived.gpuNames.join(", ") : null),
|
||||
labels: (_machine, derived) => (derived.labels.length > 0 ? derived.labels.join(", ") : null),
|
||||
macs: (machine) => (machine.macAddresses.length > 0 ? machine.macAddresses.join(", ") : null),
|
||||
serials: (machine) => (machine.serialNumbers.length > 0 ? machine.serialNumbers.join(", ") : null),
|
||||
primaryIp: (_machine, derived) => derived.primaryIp ?? null,
|
||||
publicIp: (_machine, derived) => derived.publicIp ?? null,
|
||||
registeredBy: (machine) => machine.registeredBy ?? null,
|
||||
tokenExpiresAt: (machine) => (machine.token?.expiresAt ? formatDateTime(machine.token.expiresAt) : null),
|
||||
tokenLastUsedAt: (machine) => (machine.token?.lastUsedAt ? formatDateTime(machine.token.lastUsedAt) : null),
|
||||
tokenUsageCount: (machine) => machine.token?.usageCount ?? 0,
|
||||
createdAt: (machine) => formatDateTime(machine.createdAt),
|
||||
updatedAt: (machine) => formatDateTime(machine.updatedAt),
|
||||
softwareCount: (_machine, derived) => derived.softwareEntries.length,
|
||||
osBuild: (_machine, derived) => derived.systemInfo.osBuild ?? null,
|
||||
osLicense: (_machine, derived) => derived.systemInfo.license ?? null,
|
||||
osExperience: (_machine, derived) => derived.systemInfo.experience ?? null,
|
||||
domain: (_machine, derived) => derived.systemInfo.domain ?? null,
|
||||
workgroup: (_machine, derived) => derived.systemInfo.workgroup ?? null,
|
||||
deviceName: (_machine, derived) => derived.systemInfo.deviceName ?? null,
|
||||
boardSerial: (_machine, derived) => derived.systemInfo.boardSerial ?? derived.hardware.serial ?? null,
|
||||
collaboratorName: (_machine, derived) => derived.collaborator?.name ?? null,
|
||||
collaboratorEmail: (_machine, derived) => derived.collaborator?.email ?? null,
|
||||
remoteAccessCount: (_machine, derived) => derived.remoteAccessCount,
|
||||
fleetId: (_machine, derived) => derived.fleetInfo?.id ?? null,
|
||||
fleetTeam: (_machine, derived) => derived.fleetInfo?.team ?? null,
|
||||
fleetUpdatedAt: (_machine, derived) =>
|
||||
derived.fleetInfo?.updatedAt ? formatDateTime(derived.fleetInfo.updatedAt) : null,
|
||||
managementMode: (machine) => describeManagementMode(machine.managementMode),
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMN_CONFIG: DeviceInventoryColumnConfig[] = DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map(
|
||||
(meta) => ({ key: meta.key })
|
||||
)
|
||||
|
||||
const INVENTORY_COLUMN_DEFINITIONS: InventoryColumnDefinition[] = DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({
|
||||
key: meta.key,
|
||||
label: meta.label,
|
||||
width: meta.width,
|
||||
getValue: COLUMN_VALUE_RESOLVERS[meta.key] ?? (() => null),
|
||||
}))
|
||||
|
||||
const INVENTORY_COLUMN_MAP: Record<string, InventoryColumnDefinition> = Object.fromEntries(
|
||||
INVENTORY_COLUMN_DEFINITIONS.map((column) => [column.key, column]),
|
||||
)
|
||||
|
||||
const CUSTOM_FIELD_KEY_PREFIX = "custom:"
|
||||
const CUSTOM_FIELD_ID_PREFIX = "custom#"
|
||||
|
||||
function deriveMachineData(machine: MachineInventoryRecord): MachineDerivedData {
|
||||
const inventory = toRecord(machine.inventory) ?? {}
|
||||
const hardware = extractHardware(inventory)
|
||||
const gpuNames = extractGpuNames(inventory)
|
||||
const labels = extractLabels(inventory)
|
||||
const primaryIp = extractPrimaryIp(inventory)
|
||||
const publicIp = extractPublicIp(inventory)
|
||||
const softwareEntries = extractSoftwareEntries(machine.hostname, inventory)
|
||||
const linkedUsersLabel = summarizeLinkedUsers(machine.linkedUsers)
|
||||
const systemInfo = extractSystemInfo(inventory)
|
||||
const collaborator = extractCollaborator(machine, inventory)
|
||||
const remoteAccessCount = collectRemoteAccessEntries(machine).length
|
||||
const fleetInfo = extractFleetInfo(inventory)
|
||||
const customFieldByKey: Record<string, DeviceCustomField> = {}
|
||||
const customFieldById: Record<string, DeviceCustomField> = {}
|
||||
for (const field of machine.customFields ?? []) {
|
||||
customFieldByKey[field.fieldKey] = field
|
||||
customFieldById[String(field.fieldId)] = field
|
||||
}
|
||||
return {
|
||||
inventory,
|
||||
hardware,
|
||||
gpuNames,
|
||||
labels,
|
||||
primaryIp,
|
||||
publicIp,
|
||||
softwareEntries,
|
||||
linkedUsersLabel,
|
||||
systemInfo,
|
||||
collaborator,
|
||||
remoteAccessCount,
|
||||
fleetInfo,
|
||||
customFieldByKey,
|
||||
customFieldById,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeColumnConfig(columns?: DeviceInventoryColumnConfig[]): DeviceInventoryColumnConfig[] {
|
||||
if (!columns || columns.length === 0) {
|
||||
return [...DEFAULT_COLUMN_CONFIG]
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const normalized: DeviceInventoryColumnConfig[] = []
|
||||
for (const column of columns) {
|
||||
const key = column.key.trim()
|
||||
if (!key) continue
|
||||
if (seen.has(key)) continue
|
||||
if (!isCustomFieldKey(key) && !INVENTORY_COLUMN_MAP[key]) continue
|
||||
seen.add(key)
|
||||
normalized.push({
|
||||
key,
|
||||
label: column.label?.trim() || undefined,
|
||||
})
|
||||
}
|
||||
return normalized.length > 0 ? normalized : [...DEFAULT_COLUMN_CONFIG]
|
||||
}
|
||||
|
||||
function resolveColumnLabel(column: DeviceInventoryColumnConfig, derivedList: MachineDerivedData[]): string {
|
||||
if (column.label) return column.label
|
||||
const definition = INVENTORY_COLUMN_MAP[column.key]
|
||||
if (definition) return definition.label
|
||||
if (isCustomFieldKey(column.key)) {
|
||||
for (const derived of derivedList) {
|
||||
const field = lookupCustomField(column.key, derived)
|
||||
if (field) return field.label
|
||||
}
|
||||
return "Campo personalizado"
|
||||
}
|
||||
return column.key
|
||||
}
|
||||
|
||||
function resolveColumnWidth(key: string): number {
|
||||
const definition = INVENTORY_COLUMN_MAP[key]
|
||||
if (definition) return definition.width
|
||||
if (isCustomFieldKey(key)) return 28
|
||||
return 20
|
||||
}
|
||||
|
||||
function isCustomFieldKey(key: string): boolean {
|
||||
return key.startsWith(CUSTOM_FIELD_KEY_PREFIX) || key.startsWith(CUSTOM_FIELD_ID_PREFIX)
|
||||
}
|
||||
|
||||
function lookupCustomField(key: string, derived: MachineDerivedData): DeviceCustomField | null {
|
||||
if (key.startsWith(CUSTOM_FIELD_KEY_PREFIX)) {
|
||||
const fieldKey = key.slice(CUSTOM_FIELD_KEY_PREFIX.length)
|
||||
return derived.customFieldByKey[fieldKey] ?? null
|
||||
}
|
||||
if (key.startsWith(CUSTOM_FIELD_ID_PREFIX)) {
|
||||
const fieldId = key.slice(CUSTOM_FIELD_ID_PREFIX.length)
|
||||
return derived.customFieldById[fieldId] ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatCustomFieldValue(field: DeviceCustomField): string {
|
||||
if (field.value === null || field.value === undefined) {
|
||||
return "—"
|
||||
}
|
||||
if (field.displayValue !== undefined && field.displayValue !== null) {
|
||||
const text = String(field.displayValue).trim()
|
||||
if (text.length > 0) return text
|
||||
}
|
||||
switch (field.type) {
|
||||
case "boolean":
|
||||
return yesNo(Boolean(field.value))
|
||||
case "number": {
|
||||
const num = typeof field.value === "number" ? field.value : Number(field.value)
|
||||
return Number.isFinite(num) ? String(num) : String(field.value)
|
||||
}
|
||||
case "date": {
|
||||
const date = new Date(field.value as string | number)
|
||||
if (Number.isNaN(date.getTime())) return String(field.value)
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
default:
|
||||
return String(field.value)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveColumnValue(
|
||||
key: string,
|
||||
machine: MachineInventoryRecord,
|
||||
derived: MachineDerivedData
|
||||
): unknown {
|
||||
if (isCustomFieldKey(key)) {
|
||||
const field = lookupCustomField(key, derived)
|
||||
if (!field) return "—"
|
||||
return formatCustomFieldValue(field)
|
||||
}
|
||||
const definition = INVENTORY_COLUMN_MAP[key]
|
||||
if (!definition) return "—"
|
||||
return definition.getValue(machine, derived)
|
||||
}
|
||||
|
||||
function formatInventoryCell(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return "—"
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : "—"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function describeDeviceType(type: string | null | undefined): string {
|
||||
const normalized = (type ?? "").toLowerCase()
|
||||
switch (normalized) {
|
||||
case "desktop":
|
||||
return "Desktop"
|
||||
case "mobile":
|
||||
return "Celular"
|
||||
case "tablet":
|
||||
return "Tablet"
|
||||
default:
|
||||
return "Desconhecido"
|
||||
}
|
||||
}
|
||||
|
||||
function describeManagementMode(mode: string | null | undefined): string {
|
||||
const normalized = (mode ?? "").toLowerCase()
|
||||
switch (normalized) {
|
||||
case "agent":
|
||||
return "Agente"
|
||||
case "manual":
|
||||
return "Manual"
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
|
||||
const SOFTWARE_HEADERS = ["Hostname", "Aplicativo", "Versão", "Origem", "Publicador", "Instalado em"] as const
|
||||
const SOFTWARE_COLUMN_WIDTHS = [22, 36, 18, 18, 22, 20] as const
|
||||
|
|
@ -173,7 +401,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||
const PERSONA_LABELS: Record<string, string> = {
|
||||
collaborator: "Colaborador",
|
||||
manager: "Gestor",
|
||||
machine: "Máquina",
|
||||
machine: "Dispositivo",
|
||||
}
|
||||
|
||||
const SUMMARY_STATUS_ORDER = ["Online", "Sem sinal", "Offline", "Manutenção", "Bloqueada", "Desativada", "Desconhecido"]
|
||||
|
|
@ -186,7 +414,14 @@ export function buildMachinesInventoryWorkbook(
|
|||
): Buffer {
|
||||
const generatedAt = options.generatedAt ?? new Date()
|
||||
const summaryRows = buildSummaryRows(machines, options, generatedAt)
|
||||
const inventoryRows = machines.map((machine) => flattenMachine(machine))
|
||||
const columnConfig = normalizeColumnConfig(options.columns)
|
||||
const derivedList = machines.map((machine) => deriveMachineData(machine))
|
||||
const headers = columnConfig.map((column) => resolveColumnLabel(column, derivedList))
|
||||
const columnWidths = columnConfig.map((column) => resolveColumnWidth(column.key))
|
||||
const inventoryRows = machines.map((machine, index) => {
|
||||
const derived = derivedList[index]
|
||||
return columnConfig.map((column) => formatInventoryCell(resolveColumnValue(column.key, machine, derived)))
|
||||
})
|
||||
const linksRows = buildLinkedUsersRows(machines)
|
||||
const softwareRows = buildSoftwareRows(machines)
|
||||
const partitionRows = buildPartitionRows(machines)
|
||||
|
|
@ -210,9 +445,9 @@ export function buildMachinesInventoryWorkbook(
|
|||
|
||||
sheets.push({
|
||||
name: "Inventário",
|
||||
headers: [...INVENTORY_HEADERS],
|
||||
headers,
|
||||
rows: inventoryRows,
|
||||
columnWidths: [...INVENTORY_COLUMN_WIDTHS],
|
||||
columnWidths,
|
||||
freezePane: { rowSplit: 1 },
|
||||
autoFilter: true,
|
||||
})
|
||||
|
|
@ -353,11 +588,28 @@ function buildSummaryRows(
|
|||
rows.push(["Filtro de empresa", options.companyFilterLabel])
|
||||
}
|
||||
|
||||
rows.push(["Total de máquinas", machines.length])
|
||||
rows.push(["Total de dispositivos", machines.length])
|
||||
|
||||
const activeCount = machines.filter((machine) => machine.isActive).length
|
||||
rows.push(["Máquinas ativas", activeCount])
|
||||
rows.push(["Máquinas inativas", machines.length - activeCount])
|
||||
rows.push(["Dispositivos ativos", activeCount])
|
||||
rows.push(["Dispositivos inativos", machines.length - activeCount])
|
||||
|
||||
const typeCounts = new Map<string, number>()
|
||||
machines.forEach((machine) => {
|
||||
const label = describeDeviceType(machine.deviceType)
|
||||
typeCounts.set(label, (typeCounts.get(label) ?? 0) + 1)
|
||||
})
|
||||
const DEVICE_TYPE_ORDER = ["Desktop", "Celular", "Tablet", "Desconhecido"]
|
||||
Array.from(typeCounts.entries())
|
||||
.sort((a, b) => {
|
||||
const idxA = DEVICE_TYPE_ORDER.indexOf(a[0])
|
||||
const idxB = DEVICE_TYPE_ORDER.indexOf(b[0])
|
||||
if (idxA === -1 && idxB === -1) return a[0].localeCompare(b[0], "pt-BR")
|
||||
if (idxA === -1) return 1
|
||||
if (idxB === -1) return -1
|
||||
return idxA - idxB
|
||||
})
|
||||
.forEach(([label, total]) => rows.push([`Tipo: ${label}`, total]))
|
||||
|
||||
const statusCounts = new Map<string, number>()
|
||||
machines.forEach((machine) => {
|
||||
|
|
@ -396,70 +648,6 @@ function buildSummaryRows(
|
|||
return rows
|
||||
}
|
||||
|
||||
function flattenMachine(machine: MachineInventoryRecord): WorksheetRow {
|
||||
const inventory = toRecord(machine.inventory)
|
||||
const hardware = extractHardware(inventory)
|
||||
const gpuNames = extractGpuNames(inventory)
|
||||
const labels = extractLabels(inventory)
|
||||
const primaryIp = extractPrimaryIp(inventory)
|
||||
const publicIp = extractPublicIp(inventory)
|
||||
const softwareEntries = extractSoftwareEntries(machine.hostname, inventory)
|
||||
const linkedUsers = summarizeLinkedUsers(machine.linkedUsers)
|
||||
const systemInfo = extractSystemInfo(inventory)
|
||||
const collaborator = extractCollaborator(machine, inventory)
|
||||
const remoteAccessCount = collectRemoteAccessEntries(machine).length
|
||||
const fleetInfo = extractFleetInfo(inventory)
|
||||
|
||||
return [
|
||||
machine.hostname,
|
||||
machine.companyName ?? "—",
|
||||
describeStatus(machine.status),
|
||||
describePersona(machine.persona),
|
||||
yesNo(machine.isActive),
|
||||
formatDateTime(machine.lastHeartbeatAt),
|
||||
machine.assignedUserName ?? machine.assignedUserEmail ?? "—",
|
||||
machine.assignedUserEmail ?? "—",
|
||||
linkedUsers ?? "—",
|
||||
machine.authEmail ?? "—",
|
||||
machine.osName,
|
||||
machine.osVersion ?? "—",
|
||||
machine.architecture ?? "—",
|
||||
hardware.vendor ?? systemInfo.systemManufacturer ?? "—",
|
||||
hardware.model ?? systemInfo.systemModel ?? "—",
|
||||
hardware.serial ?? systemInfo.boardSerial ?? "—",
|
||||
hardware.cpuType ?? "—",
|
||||
hardware.physicalCores ?? "—",
|
||||
hardware.logicalCores ?? "—",
|
||||
hardware.memoryGiB ?? "—",
|
||||
gpuNames.length > 0 ? gpuNames.join(", ") : "—",
|
||||
labels.length > 0 ? labels.join(", ") : "—",
|
||||
machine.macAddresses.length > 0 ? machine.macAddresses.join(", ") : "—",
|
||||
machine.serialNumbers.length > 0 ? machine.serialNumbers.join(", ") : "—",
|
||||
primaryIp ?? "—",
|
||||
publicIp ?? "—",
|
||||
machine.registeredBy ?? "—",
|
||||
machine.token?.expiresAt ? formatDateTime(machine.token.expiresAt) ?? "—" : "—",
|
||||
machine.token?.lastUsedAt ? formatDateTime(machine.token.lastUsedAt) ?? "—" : "—",
|
||||
machine.token?.usageCount ?? 0,
|
||||
formatDateTime(machine.createdAt) ?? "—",
|
||||
formatDateTime(machine.updatedAt) ?? "—",
|
||||
softwareEntries.length,
|
||||
systemInfo.osBuild ?? "—",
|
||||
systemInfo.license ?? "—",
|
||||
systemInfo.experience ?? "—",
|
||||
systemInfo.domain ?? "—",
|
||||
systemInfo.workgroup ?? "—",
|
||||
systemInfo.deviceName ?? "—",
|
||||
systemInfo.boardSerial ?? hardware.serial ?? "—",
|
||||
collaborator?.name ?? "—",
|
||||
collaborator?.email ?? "—",
|
||||
remoteAccessCount,
|
||||
fleetInfo?.id ?? "—",
|
||||
fleetInfo?.team ?? "—",
|
||||
fleetInfo?.updatedAt ? formatDateTime(fleetInfo.updatedAt) ?? "—" : "—",
|
||||
]
|
||||
}
|
||||
|
||||
function buildLinkedUsersRows(machines: MachineInventoryRecord[]): Array<[string, string | null, string | null, string | null]> {
|
||||
const rows: Array<[string, string | null, string | null, string | null]> = []
|
||||
machines.forEach((machine) => {
|
||||
|
|
|
|||
|
|
@ -408,7 +408,7 @@ function TicketPdfDocument({ ticket, logoDataUrl }: { ticket: TicketWithDetails;
|
|||
]
|
||||
if (ticket.machine) {
|
||||
const machineLabel = ticket.machine.hostname ?? (ticket.machine.id ? `ID ${ticket.machine.id}` : "—")
|
||||
rightMeta.push({ label: "Máquina", value: machineLabel })
|
||||
rightMeta.push({ label: "Dispositivo", value: machineLabel })
|
||||
}
|
||||
if (ticket.resolvedAt) {
|
||||
rightMeta.push({ label: "Resolvido em", value: formatDateTime(ticket.resolvedAt) })
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { normalizeMachineRemoteAccess } from "@/components/admin/machines/admin-machines-overview"
|
||||
import { normalizeDeviceRemoteAccess } from "@/components/admin/devices/admin-devices-overview"
|
||||
|
||||
describe("normalizeMachineRemoteAccess", () => {
|
||||
describe("normalizeDeviceRemoteAccess", () => {
|
||||
it("returns null when value is empty", () => {
|
||||
expect(normalizeMachineRemoteAccess(undefined)).toBeNull()
|
||||
expect(normalizeMachineRemoteAccess(" ")).toBeNull()
|
||||
expect(normalizeDeviceRemoteAccess(undefined)).toBeNull()
|
||||
expect(normalizeDeviceRemoteAccess(" ")).toBeNull()
|
||||
})
|
||||
|
||||
it("parses plain identifier strings", () => {
|
||||
const result = normalizeMachineRemoteAccess("PC-001")
|
||||
const result = normalizeDeviceRemoteAccess("PC-001")
|
||||
expect(result).toEqual({
|
||||
provider: null,
|
||||
identifier: "PC-001",
|
||||
|
|
@ -21,7 +21,7 @@ describe("normalizeMachineRemoteAccess", () => {
|
|||
})
|
||||
|
||||
it("detects URLs in string input", () => {
|
||||
const result = normalizeMachineRemoteAccess("https://remote.example.com/session/123")
|
||||
const result = normalizeDeviceRemoteAccess("https://remote.example.com/session/123")
|
||||
expect(result).toEqual({
|
||||
provider: null,
|
||||
identifier: null,
|
||||
|
|
@ -34,7 +34,7 @@ describe("normalizeMachineRemoteAccess", () => {
|
|||
|
||||
it("normalizes object payload with aliases", () => {
|
||||
const timestamp = 1_701_234_567_890
|
||||
const result = normalizeMachineRemoteAccess({
|
||||
const result = normalizeDeviceRemoteAccess({
|
||||
provider: "AnyDesk",
|
||||
code: "123-456-789",
|
||||
remoteUrl: "https://anydesk.com/session/123",
|
||||
263
tests/tickets.lifecycle.test.ts
Normal file
263
tests/tickets.lifecycle.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
219
tests/tickets.submitCsat.test.ts
Normal file
219
tests/tickets.submitCsat.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue