feat: expand admin companies and users modules

This commit is contained in:
Esdras Renan 2025-10-22 01:27:43 -03:00
parent a043b1203c
commit 2e3b46a7b5
31 changed files with 5626 additions and 2003 deletions

View file

@ -23,6 +23,7 @@ import type * as migrations from "../migrations.js";
import type * as queues from "../queues.js";
import type * as rbac from "../rbac.js";
import type * as reports from "../reports.js";
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";
@ -59,6 +60,7 @@ declare const fullApi: ApiFromModules<{
queues: typeof queues;
rbac: typeof rbac;
reports: typeof reports;
revision: typeof revision;
seed: typeof seed;
slas: typeof slas;
teams: typeof teams;

View file

@ -74,6 +74,31 @@ export const ensureProvisioned = mutation({
phone: undefined,
description: undefined,
address: undefined,
legalName: undefined,
tradeName: undefined,
stateRegistration: undefined,
stateRegistrationType: undefined,
primaryCnae: undefined,
timezone: undefined,
businessHours: undefined,
supportEmail: undefined,
billingEmail: undefined,
contactPreferences: undefined,
clientDomains: undefined,
communicationChannels: undefined,
fiscalAddress: undefined,
hasBranches: false,
regulatedEnvironments: undefined,
privacyPolicyAccepted: false,
privacyPolicyReference: undefined,
privacyPolicyMetadata: undefined,
contracts: undefined,
contacts: undefined,
locations: undefined,
sla: undefined,
tags: undefined,
customFields: undefined,
notes: undefined,
createdAt: now,
updatedAt: now,
})

View file

@ -28,6 +28,31 @@ export default defineSchema({
phone: v.optional(v.string()),
description: v.optional(v.string()),
address: v.optional(v.string()),
legalName: v.optional(v.string()),
tradeName: v.optional(v.string()),
stateRegistration: v.optional(v.string()),
stateRegistrationType: v.optional(v.string()),
primaryCnae: v.optional(v.string()),
timezone: v.optional(v.string()),
businessHours: v.optional(v.any()),
supportEmail: v.optional(v.string()),
billingEmail: v.optional(v.string()),
contactPreferences: v.optional(v.any()),
clientDomains: v.optional(v.array(v.string())),
communicationChannels: v.optional(v.any()),
fiscalAddress: v.optional(v.any()),
hasBranches: v.optional(v.boolean()),
regulatedEnvironments: v.optional(v.array(v.string())),
privacyPolicyAccepted: v.optional(v.boolean()),
privacyPolicyReference: v.optional(v.string()),
privacyPolicyMetadata: v.optional(v.any()),
contracts: v.optional(v.any()),
contacts: v.optional(v.any()),
locations: v.optional(v.any()),
sla: v.optional(v.any()),
tags: v.optional(v.array(v.string())),
customFields: v.optional(v.any()),
notes: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
@ -304,6 +329,7 @@ export default defineSchema({
createdAt: v.number(),
updatedAt: v.number(),
registeredBy: v.optional(v.string()),
remoteAccess: v.optional(v.any()),
})
.index("by_tenant", ["tenantId"])
.index("by_tenant_company", ["tenantId", "companyId"])

View file

@ -0,0 +1,30 @@
# Admin ▸ Empresas — Perfil Ampliado
Este documento resume a ampliação do cadastro de empresas (dados fiscais, contratos, SLA, canais de comunicação) e registra o que ainda falta concluir na camada de apresentação.
## Base de dados e backend
- `prisma/schema.prisma`: modelo `Company` agora inclui razão social, inscrição estadual/tipo, CNAE, canais de comunicação consolidados, endereço fiscal, contatos, localizações, contratos, SLA, campos personalizados e observações. Foi criado o enum `CompanyStateRegistrationType`.
- `prisma/migrations/20251022120000_extend_company_profile/`: migração que adiciona todas as novas colunas ao banco (SQLite/Postgres).
- `scripts/apply-company-migration.mjs`: script idempotente para aplicar os `ALTER TABLE` diretamente em ambientes SQLite já existentes.
- `convex/schema.ts` e `convex/companies.ts`: schema do Convex espelha os campos adicionais e as mutations `ensureProvisioned/removeBySlug` passaram a sincronizar os metadados estendidos.
- `src/lib/schemas/company.ts`: novo módulo de validação/normalização (React Hook Form + zod) com tipos ricos (`CompanyFormValues`, contatos, contratos, SLA, horários, etc.).
- `src/server/company-service.ts`: serviço único que sanitiza input (`sanitizeCompanyInput`), gera payload para Prisma (`buildCompanyData`) e normaliza registros (`normalizeCompany` / `NormalizedCompany`).
- `src/server/companies-sync.ts`: reutilizado para garantir que Convex receba os campos novos quando houver provisionamento/remoção.
## APIs e página server-side
- `src/app/api/admin/companies/route.ts`: `GET` devolve `NormalizedCompany`; `POST` aplica validação zod, normaliza, cria a empresa no Prisma e sincroniza com Convex.
- `src/app/api/admin/companies/[id]/route.ts`: `PATCH` faz merge seguro do payload parcial, reaproveita o serviço de normalização, trata erros de unicidade e replica as alterações para o Convex; `DELETE` desvincula usuários/tickets antes de remover e garante remoção no Convex.
- `src/app/api/admin/companies/last-alerts/route.ts`: continua servindo a UI atual, sem mudanças funcionais.
- `src/app/admin/companies/page.tsx`: carrega empresas via Prisma server-side e entrega `NormalizedCompany` para o front (a tela ainda usa o componente legado `AdminCompaniesManager`).
## Componentes utilitários
- `src/components/ui/accordion.tsx` e `src/components/ui/multi-value-input.tsx`: helpers (Radix + badges/input) que darão suporte ao novo formulário seccional.
## O que falta implementar
- **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.
- **Qualidade**: ajustar lint/testes após a nova UI e cobrir o fluxo de criação/edição com testes de integração.
> Até que a nova interface seja publicada, a API já aceita todos os campos e qualquer cliente (front, automação, seed) deve usar `company-service.ts` para converter dados de/para Prisma, evitando divergências.

View file

@ -24,6 +24,7 @@
"@noble/hashes": "^1.5.0",
"@paper-design/shaders-react": "^0.0.55",
"@prisma/client": "^6.16.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@ -52,7 +53,7 @@
"date-fns": "^4.1.0",
"dotenv": "^16.4.5",
"lucide-react": "^0.544.0",
"next": "15.5.6",
"next": "^16.0.0",
"next-themes": "^0.4.6",
"pdfkit": "^0.17.2",
"postcss": "^8.5.6",
@ -79,8 +80,9 @@
"@types/react-dom": "^18",
"@types/sanitize-html": "^2.16.0",
"@types/three": "^0.180.0",
"better-sqlite3": "^12.4.1",
"eslint": "^9",
"eslint-config-next": "15.5.6",
"eslint-config-next": "^16.0.0",
"eslint-plugin-react-hooks": "^5.0.0",
"prisma": "^6.16.2",
"tailwindcss": "^4",

284
pnpm-lock.yaml generated
View file

@ -32,6 +32,9 @@ importers:
'@prisma/client':
specifier: ^6.16.2
version: 6.16.3(prisma@6.16.3(typescript@5.9.3))(typescript@5.9.3)
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-avatar':
specifier: ^1.1.10
version: 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@ -192,6 +195,9 @@ importers:
'@types/three':
specifier: ^0.180.0
version: 0.180.0
better-sqlite3:
specifier: ^12.4.1
version: 12.4.1
eslint:
specifier: ^9
version: 9.37.0(jiti@2.6.1)
@ -1088,6 +1094,19 @@ packages:
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-accordion@1.2.12':
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
@ -1127,6 +1146,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-collapsible@1.1.12':
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
@ -2519,9 +2551,19 @@ packages:
better-call@1.0.19:
resolution: {integrity: sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw==}
better-sqlite3@12.4.1:
resolution: {integrity: sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==}
engines: {node: 20.x || 22.x || 23.x || 24.x}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@ -2543,6 +2585,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
@ -2593,6 +2638,9 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
@ -2748,10 +2796,18 @@ packages:
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@ -2831,6 +2887,9 @@ packages:
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
engines: {node: '>=14'}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
@ -3018,6 +3077,10 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.2.2:
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
engines: {node: '>=12.0.0'}
@ -3069,6 +3132,9 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@ -3091,6 +3157,9 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -3137,6 +3206,9 @@ packages:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
hasBin: true
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@ -3224,6 +3296,9 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@ -3551,6 +3626,10 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@ -3569,6 +3648,9 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -3581,6 +3663,9 @@ packages:
resolution: {integrity: sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw==}
engines: {node: ^20.0.0 || >=22.0.0}
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
napi-postinstall@0.3.3:
resolution: {integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@ -3616,6 +3701,10 @@ packages:
sass:
optional: true
node-abi@3.78.0:
resolution: {integrity: sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==}
engines: {node: '>=10'}
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
@ -3665,6 +3754,9 @@ packages:
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@ -3768,6 +3860,11 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
hasBin: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@ -3848,6 +3945,9 @@ packages:
prosemirror-view@1.41.2:
resolution: {integrity: sha512-PGS/jETmh+Qjmre/6vcG7SNHAKiGc4vKOJmHMPRmvcUl7ISuVtrtHmH06UDUwaim4NDJfZfVMl7U7JkMMETa6g==}
pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
@ -3875,6 +3975,10 @@ packages:
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-dom@19.2.0:
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
peerDependencies:
@ -3957,6 +4061,10 @@ packages:
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
engines: {node: '>=0.10.0'}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@ -4105,6 +4213,12 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
@ -4161,6 +4275,10 @@ packages:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@ -4204,6 +4322,13 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar@7.5.1:
resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==}
engines: {node: '>=18'}
@ -4265,6 +4390,9 @@ packages:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
@ -4515,6 +4643,9 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@ -5266,6 +5397,23 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-accordion@1.2.12(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.26)(react@19.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.26)(react@19.2.0)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.26)(react@19.2.0)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.26)(react@19.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.26)(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 18.3.26
'@types/react-dom': 18.3.7(@types/react@18.3.26)
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@ -5304,6 +5452,22 @@ snapshots:
'@types/react': 18.3.26
'@types/react-dom': 18.3.7(@types/react@18.3.26)
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.26)(react@19.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.26)(react@19.2.0)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.26)(react@19.2.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.26)(react@19.2.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.26)(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 18.3.26
'@types/react-dom': 18.3.7(@types/react@18.3.26)
'@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.26)(react@19.2.0)
@ -6865,10 +7029,25 @@ snapshots:
set-cookie-parser: 2.7.1
uncrypto: 0.1.3
better-sqlite3@12.4.1:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@ -6898,6 +7077,11 @@ snapshots:
node-releases: 2.0.23
update-browserslist-db: 1.1.3(browserslist@4.26.3)
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
@ -6960,6 +7144,8 @@ snapshots:
dependencies:
readdirp: 4.1.2
chownr@1.1.4: {}
chownr@3.0.0: {}
citty@0.1.6:
@ -7084,8 +7270,14 @@ snapshots:
decimal.js-light@2.5.1: {}
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
deep-eql@5.0.2: {}
deep-extend@0.6.0: {}
deep-is@0.1.4: {}
deepmerge-ts@7.1.5: {}
@ -7162,6 +7354,10 @@ snapshots:
empathic@2.0.0: {}
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
@ -7535,6 +7731,8 @@ snapshots:
events@3.3.0: {}
expand-template@2.0.3: {}
expect-type@1.2.2: {}
exsolve@1.0.7: {}
@ -7581,6 +7779,8 @@ snapshots:
dependencies:
flat-cache: 4.0.1
file-uri-to-path@1.0.0: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@ -7613,6 +7813,8 @@ snapshots:
dependencies:
is-callable: 1.2.7
fs-constants@1.0.0: {}
fsevents@2.3.3:
optional: true
@ -7672,6 +7874,8 @@ snapshots:
nypm: 0.6.2
pathe: 2.0.3
github-from-package@0.0.0: {}
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@ -7745,6 +7949,8 @@ snapshots:
inherits@2.0.4: {}
ini@1.3.8: {}
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@ -8051,6 +8257,8 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
mimic-response@3.1.0: {}
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
@ -8067,12 +8275,16 @@ snapshots:
dependencies:
minipass: 7.1.2
mkdirp-classic@0.5.3: {}
ms@2.1.3: {}
nanoid@3.3.11: {}
nanostores@1.0.1: {}
napi-build-utils@2.0.0: {}
napi-postinstall@0.3.3: {}
natural-compare@1.4.0: {}
@ -8105,6 +8317,10 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
node-abi@3.78.0:
dependencies:
semver: 7.7.2
node-fetch-native@1.6.7: {}
node-releases@2.0.23: {}
@ -8165,6 +8381,10 @@ snapshots:
ohash@2.0.11: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@ -8262,6 +8482,21 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.1
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.78.0
pump: 3.0.3
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
prelude-ls@1.2.1: {}
prettier@3.6.2: {}
@ -8384,6 +8619,11 @@ snapshots:
prosemirror-state: 1.4.3
prosemirror-transform: 1.10.4
pump@3.0.3:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
punycode.js@2.3.1: {}
punycode@2.3.1: {}
@ -8407,6 +8647,13 @@ snapshots:
defu: 6.1.4
destr: 2.0.5
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
react-dom@19.2.0(react@19.2.0):
dependencies:
react: 19.2.0
@ -8479,6 +8726,12 @@ snapshots:
react@19.2.0: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readdirp@4.1.2: {}
recharts-scale@0.4.5:
@ -8708,6 +8961,14 @@ snapshots:
siginfo@2.0.0: {}
simple-concat@1.0.1: {}
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
@ -8786,6 +9047,8 @@ snapshots:
strip-bom@3.0.0: {}
strip-json-comments@2.0.1: {}
strip-json-comments@3.1.1: {}
styled-jsx@5.1.6(react@19.2.0):
@ -8811,6 +9074,21 @@ snapshots:
tapable@2.3.0: {}
tar-fs@2.1.4:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.3
tar-stream: 2.2.0
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
tar@7.5.1:
dependencies:
'@isaacs/fs-minipass': 4.0.1
@ -8865,6 +9143,10 @@ snapshots:
dependencies:
tslib: 1.14.1
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
tw-animate-css@1.4.0: {}
type-check@0.4.0:
@ -9156,6 +9438,8 @@ snapshots:
word-wrap@1.2.5: {}
wrappy@1.0.2: {}
yallist@3.1.1: {}
yallist@5.0.0: {}

View file

@ -0,0 +1,26 @@
-- AlterTable
ALTER TABLE "Company" ADD COLUMN "legalName" TEXT;
ALTER TABLE "Company" ADD COLUMN "tradeName" TEXT;
ALTER TABLE "Company" ADD COLUMN "stateRegistration" TEXT;
ALTER TABLE "Company" ADD COLUMN "stateRegistrationType" TEXT;
ALTER TABLE "Company" ADD COLUMN "primaryCnae" TEXT;
ALTER TABLE "Company" ADD COLUMN "timezone" TEXT;
ALTER TABLE "Company" ADD COLUMN "businessHours" JSONB;
ALTER TABLE "Company" ADD COLUMN "supportEmail" TEXT;
ALTER TABLE "Company" ADD COLUMN "billingEmail" TEXT;
ALTER TABLE "Company" ADD COLUMN "contactPreferences" JSONB;
ALTER TABLE "Company" ADD COLUMN "clientDomains" JSONB;
ALTER TABLE "Company" ADD COLUMN "communicationChannels" JSONB;
ALTER TABLE "Company" ADD COLUMN "fiscalAddress" JSONB;
ALTER TABLE "Company" ADD COLUMN "hasBranches" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Company" ADD COLUMN "regulatedEnvironments" JSONB;
ALTER TABLE "Company" ADD COLUMN "privacyPolicyAccepted" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Company" ADD COLUMN "privacyPolicyReference" TEXT;
ALTER TABLE "Company" ADD COLUMN "privacyPolicyMetadata" JSONB;
ALTER TABLE "Company" ADD COLUMN "contracts" JSONB;
ALTER TABLE "Company" ADD COLUMN "contacts" JSONB;
ALTER TABLE "Company" ADD COLUMN "locations" JSONB;
ALTER TABLE "Company" ADD COLUMN "sla" JSONB;
ALTER TABLE "Company" ADD COLUMN "tags" JSONB;
ALTER TABLE "Company" ADD COLUMN "customFields" JSONB;
ALTER TABLE "Company" ADD COLUMN "notes" TEXT;

View file

@ -45,6 +45,12 @@ enum CommentVisibility {
INTERNAL
}
enum CompanyStateRegistrationType {
STANDARD
EXEMPT
SIMPLES
}
model Team {
id String @id @default(cuid())
tenantId String
@ -83,6 +89,31 @@ model Company {
phone String?
description String?
address String?
legalName String?
tradeName String?
stateRegistration String?
stateRegistrationType CompanyStateRegistrationType?
primaryCnae String?
timezone String?
businessHours Json?
supportEmail String?
billingEmail String?
contactPreferences Json?
clientDomains Json?
communicationChannels Json?
fiscalAddress Json?
hasBranches Boolean @default(false)
regulatedEnvironments Json?
privacyPolicyAccepted Boolean @default(false)
privacyPolicyReference String?
privacyPolicyMetadata Json?
contacts Json?
locations Json?
contracts Json?
sla Json?
tags Json?
customFields Json?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View file

@ -0,0 +1,57 @@
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
const statements = [
`ALTER TABLE "Company" ADD COLUMN "legalName" TEXT`,
`ALTER TABLE "Company" ADD COLUMN "tradeName" TEXT`,
`ALTER TABLE "Company" ADD COLUMN "stateRegistration" TEXT`,
`ALTER TABLE "Company" ADD COLUMN "stateRegistrationType" TEXT`,
`ALTER TABLE "Company" ADD COLUMN "primaryCnae" TEXT`,
`ALTER TABLE "Company" ADD COLUMN "timezone" TEXT`,
`ALTER TABLE "Company" ADD COLUMN "businessHours" JSON`,
`ALTER TABLE "Company" ADD COLUMN "supportEmail" TEXT`,
`ALTER TABLE "Company" ADD COLUMN "billingEmail" TEXT`,
`ALTER TABLE "Company" ADD COLUMN "contactPreferences" JSON`,
`ALTER TABLE "Company" ADD COLUMN "clientDomains" JSON`,
`ALTER TABLE "Company" ADD COLUMN "communicationChannels" JSON`,
`ALTER TABLE "Company" ADD COLUMN "fiscalAddress" JSON`,
`ALTER TABLE "Company" ADD COLUMN "hasBranches" BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE "Company" ADD COLUMN "regulatedEnvironments" JSON`,
`ALTER TABLE "Company" ADD COLUMN "privacyPolicyAccepted" BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE "Company" ADD COLUMN "privacyPolicyReference" TEXT`,
`ALTER TABLE "Company" ADD COLUMN "privacyPolicyMetadata" JSON`,
`ALTER TABLE "Company" ADD COLUMN "contracts" JSON`,
`ALTER TABLE "Company" ADD COLUMN "contacts" JSON`,
`ALTER TABLE "Company" ADD COLUMN "locations" JSON`,
`ALTER TABLE "Company" ADD COLUMN "sla" JSON`,
`ALTER TABLE "Company" ADD COLUMN "tags" JSON`,
`ALTER TABLE "Company" ADD COLUMN "customFields" JSON`,
`ALTER TABLE "Company" ADD COLUMN "notes" TEXT`,
]
async function main() {
for (const statement of statements) {
try {
await prisma.$executeRawUnsafe(statement)
} catch (error) {
// Ignore errors caused by columns that already exist (idempotent execution)
if (
!(error instanceof Error) ||
!/duplicate column name|already exists/i.test(error.message ?? "")
) {
console.error(`Failed to apply migration statement: ${statement}`)
throw error
}
}
}
}
main()
.catch((error) => {
console.error(error)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View file

@ -1,87 +1,7 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { requireStaffSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { prisma } from "@/lib/prisma"
import { AdminClientsManager, type AdminClient } from "@/components/admin/clients/admin-clients-manager"
import { redirect } from "next/navigation"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export default async function AdminClientsPage() {
const session = await requireStaffSession()
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
const users = await prisma.user.findMany({
where: {
tenantId,
role: { in: ["MANAGER", "COLLABORATOR"] },
},
include: {
company: {
select: {
id: true,
name: true,
},
},
},
orderBy: { createdAt: "desc" },
})
const emails = users.map((user) => user.email)
const authUsers = await prisma.authUser.findMany({
where: { email: { in: emails } },
select: { id: true, email: true, updatedAt: true, createdAt: true },
})
const sessions = await prisma.authSession.findMany({
where: { userId: { in: authUsers.map((auth) => auth.id) } },
orderBy: { updatedAt: "desc" },
select: { userId: true, updatedAt: true },
})
const sessionByUserId = new Map<string, Date>()
for (const sessionRow of sessions) {
if (!sessionByUserId.has(sessionRow.userId)) {
sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt)
}
}
const authByEmail = new Map<string, { id: string; updatedAt: Date; createdAt: Date }>()
for (const authUser of authUsers) {
authByEmail.set(authUser.email.toLowerCase(), {
id: authUser.id,
updatedAt: authUser.updatedAt,
createdAt: authUser.createdAt,
})
}
const initialClients: AdminClient[] = users.map((user) => {
const auth = authByEmail.get(user.email.toLowerCase())
const lastSeenAt = auth ? sessionByUserId.get(auth.id) ?? auth.updatedAt : null
const normalizedRole = user.role === "MANAGER" ? "MANAGER" : "COLLABORATOR"
return {
id: user.id,
email: user.email,
name: user.name ?? user.email,
role: normalizedRole,
companyId: user.companyId ?? null,
companyName: user.company?.name ?? null,
tenantId: user.tenantId,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
authUserId: auth?.id ?? null,
lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null,
}
})
return (
<AppShell
header={<SiteHeader title="Clientes" lead="Gerencie colaboradores e gestores vinculados às empresas." />}
>
<div className="mx-auto w-full max-w-7xl px-4 pb-12 lg:px-8">
<AdminClientsManager initialClients={initialClients} />
</div>
</AppShell>
)
export default function AdminClientsRedirect() {
redirect("/admin/users")
}

View file

@ -1,45 +1,17 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { prisma } from "@/lib/prisma"
import { requireStaffSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { AdminCompaniesManager } from "@/components/admin/companies/admin-companies-manager"
import { fetchCompaniesByTenant, normalizeCompany } from "@/server/company-service"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export default async function AdminCompaniesPage() {
const companiesRaw = await prisma.company.findMany({
orderBy: { name: "asc" },
select: {
id: true,
tenantId: true,
name: true,
slug: true,
provisioningCode: true,
isAvulso: true,
contractedHoursPerMonth: true,
cnpj: true,
domain: true,
phone: true,
description: true,
address: true,
createdAt: true,
updatedAt: true,
},
})
const companies = companiesRaw.map((c) => ({
id: c.id,
tenantId: c.tenantId,
name: c.name,
slug: c.slug,
provisioningCode: c.provisioningCode ?? null,
isAvulso: Boolean(c.isAvulso ?? false),
contractedHoursPerMonth: c.contractedHoursPerMonth ?? null,
cnpj: c.cnpj ?? null,
domain: c.domain ?? null,
phone: c.phone ?? null,
description: c.description ?? null,
address: c.address ?? null,
}))
const session = await requireStaffSession()
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
const companies = (await fetchCompaniesByTenant(tenantId)).map(normalizeCompany)
return (
<AppShell
header={
@ -47,7 +19,7 @@ export default async function AdminCompaniesPage() {
}
>
<div className="mx-auto w-full max-w-7xl px-4 md:px-8 lg:px-10">
<AdminCompaniesManager initialCompanies={companies} />
<AdminCompaniesManager initialCompanies={companies} tenantId={tenantId} />
</div>
</AppShell>
)

View file

@ -6,7 +6,8 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export default function AdminMachinesPage() {
export default function AdminMachinesPage({ searchParams }: { searchParams?: { [key: string]: string | string[] | undefined } }) {
const company = typeof searchParams?.company === 'string' ? searchParams?.company : undefined
return (
<AppShell
header={
@ -17,7 +18,7 @@ export default function AdminMachinesPage() {
}
>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<AdminMachinesOverview tenantId={DEFAULT_TENANT_ID} />
<AdminMachinesOverview tenantId={DEFAULT_TENANT_ID} initialCompanyFilterSlug={company ?? "all"} />
</div>
</AppShell>
)

View file

@ -0,0 +1,98 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { prisma } from "@/lib/prisma"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { requireStaffSession } from "@/lib/auth-server"
import { AdminUsersWorkspace, type AdminAccount } from "@/components/admin/users/admin-users-workspace"
import { normalizeCompany } from "@/server/company-service"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export default async function AdminUsersPage() {
const session = await requireStaffSession()
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
const users = await prisma.user.findMany({
where: {
tenantId,
role: { in: ["MANAGER", "COLLABORATOR"] },
},
include: {
company: {
select: {
id: true,
name: true,
},
},
},
orderBy: { createdAt: "desc" },
})
const emails = users.map((user) => user.email)
const authUsers = await prisma.authUser.findMany({
where: { email: { in: emails } },
select: { id: true, email: true, updatedAt: true, createdAt: true },
})
const sessions = await prisma.authSession.findMany({
where: { userId: { in: authUsers.map((auth) => auth.id) } },
orderBy: { updatedAt: "desc" },
select: { userId: true, updatedAt: true },
})
const sessionByUserId = new Map<string, Date>()
for (const sessionRow of sessions) {
if (!sessionByUserId.has(sessionRow.userId)) {
sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt)
}
}
const authByEmail = new Map<string, { id: string; updatedAt: Date; createdAt: Date }>()
for (const authUser of authUsers) {
authByEmail.set(authUser.email.toLowerCase(), {
id: authUser.id,
updatedAt: authUser.updatedAt,
createdAt: authUser.createdAt,
})
}
const accounts: AdminAccount[] = users.map((user) => {
const auth = authByEmail.get(user.email.toLowerCase())
const lastSeenAt = auth ? sessionByUserId.get(auth.id) ?? auth.updatedAt : null
return {
id: user.id,
email: user.email,
name: user.name ?? user.email,
role: user.role === "MANAGER" ? "MANAGER" : "COLLABORATOR",
companyId: user.companyId ?? null,
companyName: user.company?.name ?? null,
tenantId: user.tenantId,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
authUserId: auth?.id ?? null,
lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null,
}
})
const companiesRaw = await prisma.company.findMany({
where: { tenantId },
orderBy: { name: "asc" },
})
const companies = companiesRaw.map(normalizeCompany)
return (
<AppShell
header={
<SiteHeader
title="Usuários"
lead="Gerencie acessos de gestores/colaboradores e mantenha contatos, unidades e contratos atualizados."
/>
}
>
<div className="mx-auto w-full max-w-7xl px-4 pb-12 lg:px-8">
<AdminUsersWorkspace initialAccounts={accounts} companies={companies} />
</div>
</AppShell>
)
}

View file

@ -1,156 +1,2 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { assertStaffSession } from "@/lib/auth-server"
import { isAdmin } from "@/lib/authz"
export const runtime = "nodejs"
const ALLOWED_ROLES = ["MANAGER", "COLLABORATOR"] as const
type AllowedRole = (typeof ALLOWED_ROLES)[number]
function normalizeRole(role?: string | null): AllowedRole {
const normalized = (role ?? "COLLABORATOR").toUpperCase()
return ALLOWED_ROLES.includes(normalized as AllowedRole) ? (normalized as AllowedRole) : "COLLABORATOR"
}
export async function GET() {
const session = await assertStaffSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
const users = await prisma.user.findMany({
where: {
tenantId,
role: { in: [...ALLOWED_ROLES] },
},
include: {
company: {
select: {
id: true,
name: true,
},
},
},
orderBy: { createdAt: "desc" },
})
const emails = users.map((user) => user.email)
const authUsers = await prisma.authUser.findMany({
where: { email: { in: emails } },
select: {
id: true,
email: true,
updatedAt: true,
createdAt: true,
},
})
const sessions = await prisma.authSession.findMany({
where: { userId: { in: authUsers.map((authUser) => authUser.id) } },
orderBy: { updatedAt: "desc" },
select: {
userId: true,
updatedAt: true,
},
})
const sessionByUserId = new Map<string, Date>()
for (const sessionRow of sessions) {
if (!sessionByUserId.has(sessionRow.userId)) {
sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt)
}
}
const authByEmail = new Map<string, { id: string; updatedAt: Date; createdAt: Date }>()
for (const authUser of authUsers) {
authByEmail.set(authUser.email.toLowerCase(), {
id: authUser.id,
updatedAt: authUser.updatedAt,
createdAt: authUser.createdAt,
})
}
const items = users.map((user) => {
const auth = authByEmail.get(user.email.toLowerCase())
const lastSeenAt = auth ? sessionByUserId.get(auth.id) ?? auth.updatedAt : null
return {
id: user.id,
email: user.email,
name: user.name,
role: normalizeRole(user.role),
companyId: user.companyId,
companyName: user.company?.name ?? null,
tenantId: user.tenantId,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
authUserId: auth?.id ?? null,
lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null,
}
})
return NextResponse.json({ items })
}
export async function DELETE(request: Request) {
const session = await assertStaffSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Apenas administradores podem excluir clientes." }, { status: 403 })
}
const json = await request.json().catch(() => null)
const ids = Array.isArray(json?.ids) ? (json.ids as string[]) : []
if (ids.length === 0) {
return NextResponse.json({ error: "Nenhum cliente selecionado." }, { status: 400 })
}
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
const users = await prisma.user.findMany({
where: {
id: { in: ids },
tenantId,
role: { in: [...ALLOWED_ROLES] },
},
select: {
id: true,
email: true,
},
})
if (users.length === 0) {
return NextResponse.json({ deletedIds: [] })
}
const emails = users.map((user) => user.email.toLowerCase())
const authUsers = await prisma.authUser.findMany({
where: {
email: { in: emails },
},
select: {
id: true,
},
})
const authUserIds = authUsers.map((authUser) => authUser.id)
await prisma.$transaction(async (tx) => {
if (authUserIds.length > 0) {
await tx.authSession.deleteMany({ where: { userId: { in: authUserIds } } })
await tx.authAccount.deleteMany({ where: { userId: { in: authUserIds } } })
await tx.authUser.deleteMany({ where: { id: { in: authUserIds } } })
}
await tx.user.deleteMany({ where: { id: { in: users.map((user) => user.id) } } })
})
return NextResponse.json({ deletedIds: users.map((user) => user.id) })
}
export { runtime } from "../users/route"
export { GET, DELETE } from "../users/route"

View file

@ -1,48 +1,99 @@
import { NextResponse } from "next/server"
import { ZodError } from "zod"
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"
import { prisma } from "@/lib/prisma"
import { assertStaffSession } from "@/lib/auth-server"
import { isAdmin } from "@/lib/authz"
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"
import { removeConvexCompany, syncConvexCompany } from "@/server/companies-sync"
import {
buildCompanyData,
fetchCompanyById,
formatZodError,
normalizeCompany,
sanitizeCompanyInput,
} from "@/server/company-service"
import type { CompanyFormValues } from "@/lib/schemas/company"
export const runtime = "nodejs"
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
function mergePayload(base: CompanyFormValues, updates: Record<string, unknown>): Record<string, unknown> {
const merged: Record<string, unknown> = { ...base, ...updates }
if (!("businessHours" in updates)) merged.businessHours = base.businessHours
if (!("communicationChannels" in updates)) merged.communicationChannels = base.communicationChannels
if (!("clientDomains" in updates)) merged.clientDomains = base.clientDomains
if (!("fiscalAddress" in updates)) merged.fiscalAddress = base.fiscalAddress
if (!("regulatedEnvironments" in updates)) merged.regulatedEnvironments = base.regulatedEnvironments
if (!("contacts" in updates)) merged.contacts = base.contacts
if (!("locations" in updates)) merged.locations = base.locations
if (!("contracts" in updates)) merged.contracts = base.contracts
if (!("sla" in updates)) merged.sla = base.sla
if (!("tags" in updates)) merged.tags = base.tags
if (!("customFields" in updates)) merged.customFields = base.customFields
if ("privacyPolicy" in updates) {
const incoming = updates.privacyPolicy
if (incoming && typeof incoming === "object") {
merged.privacyPolicy = {
...base.privacyPolicy,
...incoming,
}
}
} else {
merged.privacyPolicy = base.privacyPolicy
}
merged.tenantId = base.tenantId
return merged
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await assertStaffSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Apenas administradores podem editar empresas" }, { status: 403 })
}
const { id } = await params
const raw = (await request.json()) as Partial<{
name: string
slug: string
cnpj: string | null
domain: string | null
phone: string | null
description: string | null
address: string | null
isAvulso: boolean
contractedHoursPerMonth: number | string | null
}>
const updates: Record<string, unknown> = {}
if (typeof raw.name === "string" && raw.name.trim()) updates.name = raw.name.trim()
if (typeof raw.slug === "string" && raw.slug.trim()) updates.slug = raw.slug.trim()
if ("cnpj" in raw) updates.cnpj = raw.cnpj ?? null
if ("domain" in raw) updates.domain = raw.domain ?? null
if ("phone" in raw) updates.phone = raw.phone ?? null
if ("description" in raw) updates.description = raw.description ?? null
if ("address" in raw) updates.address = raw.address ?? null
if ("isAvulso" in raw) updates.isAvulso = Boolean(raw.isAvulso)
if ("contractedHoursPerMonth" in raw) {
const v = raw.contractedHoursPerMonth
updates.contractedHoursPerMonth = typeof v === "number" ? v : v ? Number(v) : null
}
const { id } = await params
try {
const company = await prisma.company.update({ where: { id }, data: updates })
const existing = await fetchCompanyById(id)
if (!existing) {
return NextResponse.json({ error: "Empresa não encontrada" }, { status: 404 })
}
if (existing.tenantId !== (session.user.tenantId ?? existing.tenantId)) {
return NextResponse.json({ error: "Acesso negado" }, { status: 403 })
}
const rawBody = (await request.json()) as Record<string, unknown>
const normalized = normalizeCompany(existing)
const {
id: _ignoreId,
provisioningCode: _ignoreCode,
createdAt: _createdAt,
updatedAt: _updatedAt,
...baseForm
} = normalized
void _ignoreId
void _ignoreCode
void _createdAt
void _updatedAt
const mergedInput = mergePayload(baseForm, rawBody)
const form = sanitizeCompanyInput(mergedInput, existing.tenantId)
const createData = buildCompanyData(form, existing.tenantId)
const { tenantId: _omitTenant, ...updateData } = createData
void _omitTenant
const company = await prisma.company.update({
where: { id },
data: updateData,
})
if (company.provisioningCode) {
const synced = await syncConvexCompany({
@ -56,17 +107,23 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
}
}
return NextResponse.json({ company })
return NextResponse.json({ company: normalizeCompany(company) })
} catch (error) {
console.error("Failed to update company", error)
if (error instanceof ZodError) {
return NextResponse.json({ error: "Dados inválidos", issues: formatZodError(error) }, { status: 422 })
}
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json({ error: "Já existe uma empresa com este slug." }, { status: 409 })
}
console.error("Failed to update company", error)
return NextResponse.json({ error: "Falha ao atualizar empresa" }, { status: 500 })
}
}
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
export async function DELETE(
_: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await assertStaffSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
if (!isAdmin(session.user.role)) {
@ -74,10 +131,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
}
const { id } = await params
const company = await prisma.company.findUnique({
where: { id },
select: { id: true, tenantId: true, name: true, slug: true },
})
const company = await fetchCompanyById(id)
if (!company) {
return NextResponse.json({ error: "Empresa não encontrada" }, { status: 404 })

View file

@ -1,22 +1,31 @@
import { NextResponse } from "next/server"
import { randomBytes } from "crypto"
import { ZodError } from "zod"
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"
import { prisma } from "@/lib/prisma"
import { assertStaffSession } from "@/lib/auth-server"
import { isAdmin } from "@/lib/authz"
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"
import { syncConvexCompany } from "@/server/companies-sync"
import {
buildCompanyData,
fetchCompaniesByTenant,
formatZodError,
normalizeCompany,
sanitizeCompanyInput,
} from "@/server/company-service"
export const runtime = "nodejs"
const DEFAULT_TENANT_ID = "tenant-atlas"
export async function GET() {
const session = await assertStaffSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const companies = await prisma.company.findMany({
orderBy: { name: "asc" },
})
return NextResponse.json({ companies })
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
const companies = await fetchCompaniesByTenant(tenantId)
return NextResponse.json({ companies: companies.map(normalizeCompany) })
}
export async function POST(request: Request) {
@ -26,43 +35,16 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Apenas administradores podem criar empresas" }, { status: 403 })
}
const body = (await request.json()) as Partial<{
name: string
slug: string
isAvulso: boolean
contractedHoursPerMonth: number | string | null
cnpj: string | null
domain: string | null
phone: string | null
description: string | null
address: string | null
}>
const { name, slug, isAvulso, contractedHoursPerMonth, cnpj, domain, phone, description, address } = body ?? {}
if (!name || !slug) {
return NextResponse.json({ error: "Nome e slug são obrigatórios" }, { status: 400 })
}
try {
const rawBody = await request.json()
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
const form = sanitizeCompanyInput(rawBody, tenantId)
const provisioningCode = randomBytes(32).toString("hex")
const company = await prisma.company.create({
data: {
tenantId: session.user.tenantId ?? "tenant-atlas",
name: String(name),
slug: String(slug),
...buildCompanyData(form, tenantId),
provisioningCode,
// Campos opcionais
isAvulso: Boolean(isAvulso ?? false),
contractedHoursPerMonth:
typeof contractedHoursPerMonth === "number"
? contractedHoursPerMonth
: contractedHoursPerMonth
? Number(contractedHoursPerMonth)
: null,
cnpj: cnpj ? String(cnpj) : null,
domain: domain ? String(domain) : null,
phone: phone ? String(phone) : null,
description: description ? String(description) : null,
address: address ? String(address) : null,
},
})
@ -78,16 +60,18 @@ export async function POST(request: Request) {
}
}
return NextResponse.json({ company })
return NextResponse.json({ company: normalizeCompany(company) })
} catch (error) {
console.error("Failed to create company", error)
if (error instanceof ZodError) {
return NextResponse.json({ error: "Dados inválidos", issues: formatZodError(error) }, { status: 422 })
}
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
// Duplicidade de slug por tenant ou provisioningCode único
return NextResponse.json(
{ error: "Já existe uma empresa com este slug ou código de provisionamento." },
{ status: 409 }
)
}
console.error("Failed to create company", error)
return NextResponse.json({ error: "Falha ao criar empresa" }, { status: 500 })
}
}

View file

@ -1,35 +1,19 @@
import { NextResponse } from "next/server"
import { randomBytes } from "crypto"
import { hashPassword } from "better-auth/crypto"
import { ConvexHttpClient } from "convex/browser"
import type { UserRole } from "@prisma/client"
import { api } from "@/convex/_generated/api"
import { prisma } from "@/lib/prisma"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { assertStaffSession } from "@/lib/auth-server"
import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz"
import { isAdmin } from "@/lib/authz"
export const runtime = "nodejs"
function normalizeRole(input: string | null | undefined): RoleOption {
const role = (input ?? "agent").toLowerCase() as RoleOption
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
}
const ALLOWED_ROLES = ["MANAGER", "COLLABORATOR"] as const
const USER_ROLE_OPTIONS: ReadonlyArray<UserRole> = ["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]
type AllowedRole = (typeof ALLOWED_ROLES)[number]
function mapToUserRole(role: RoleOption): UserRole {
const candidate = role.toUpperCase() as UserRole
return USER_ROLE_OPTIONS.includes(candidate) ? candidate : "AGENT"
}
function generatePassword(length = 12) {
const bytes = randomBytes(length)
return Array.from(bytes)
.map((byte) => (byte % 36).toString(36))
.join("")
function normalizeRole(role?: string | null): AllowedRole {
const normalized = (role ?? "COLLABORATOR").toUpperCase()
return ALLOWED_ROLES.includes(normalized as AllowedRole) ? (normalized as AllowedRole) : "COLLABORATOR"
}
export async function GET() {
@ -38,111 +22,135 @@ export async function GET() {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const users = await prisma.authUser.findMany({
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
const users = await prisma.user.findMany({
where: {
tenantId,
role: { in: [...ALLOWED_ROLES] },
},
include: {
company: {
select: {
id: true,
name: true,
},
},
},
orderBy: { createdAt: "desc" },
})
const emails = users.map((user) => user.email)
const authUsers = await prisma.authUser.findMany({
where: { email: { in: emails } },
select: {
id: true,
email: true,
name: true,
role: true,
tenantId: true,
updatedAt: true,
createdAt: true,
},
})
const sessions = await prisma.authSession.findMany({
where: { userId: { in: authUsers.map((authUser) => authUser.id) } },
orderBy: { updatedAt: "desc" },
select: {
userId: true,
updatedAt: true,
},
})
return NextResponse.json({ users })
const sessionByUserId = new Map<string, Date>()
for (const sessionRow of sessions) {
if (!sessionByUserId.has(sessionRow.userId)) {
sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt)
}
}
const authByEmail = new Map<string, { id: string; updatedAt: Date; createdAt: Date }>()
for (const authUser of authUsers) {
authByEmail.set(authUser.email.toLowerCase(), {
id: authUser.id,
updatedAt: authUser.updatedAt,
createdAt: authUser.createdAt,
})
}
const items = users.map((user) => {
const auth = authByEmail.get(user.email.toLowerCase())
const lastSeenAt = auth ? sessionByUserId.get(auth.id) ?? auth.updatedAt : null
return {
id: user.id,
email: user.email,
name: user.name,
role: normalizeRole(user.role),
companyId: user.companyId,
companyName: user.company?.name ?? null,
tenantId: user.tenantId,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
authUserId: auth?.id ?? null,
lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null,
}
})
return NextResponse.json({ items })
}
export async function POST(request: Request) {
export async function DELETE(request: Request) {
const session = await assertStaffSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Apenas administradores podem criar usuários" }, { status: 403 })
return NextResponse.json({ error: "Apenas administradores podem excluir usuários." }, { status: 403 })
}
const payload = await request.json().catch(() => null)
if (!payload || typeof payload !== "object") {
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
const json = await request.json().catch(() => null)
const ids = Array.isArray(json?.ids) ? (json.ids as string[]) : []
if (ids.length === 0) {
return NextResponse.json({ error: "Nenhum usuário selecionado." }, { status: 400 })
}
const emailInput = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : ""
const nameInput = typeof payload.name === "string" ? payload.name.trim() : ""
const roleInput = typeof payload.role === "string" ? payload.role : undefined
const tenantInput = typeof payload.tenantId === "string" ? payload.tenantId.trim() : undefined
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
if (!emailInput || !emailInput.includes("@")) {
return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 })
}
const role = normalizeRole(roleInput)
const tenantId = tenantInput || session.user.tenantId || DEFAULT_TENANT_ID
const userRole = mapToUserRole(role)
const existing = await prisma.authUser.findUnique({ where: { email: emailInput } })
if (existing) {
return NextResponse.json({ error: "Já existe um usuário com este e-mail" }, { status: 409 })
}
const password = generatePassword()
const hashedPassword = await hashPassword(password)
const user = await prisma.authUser.create({
data: {
email: emailInput,
name: nameInput || emailInput,
role,
const users = await prisma.user.findMany({
where: {
id: { in: ids },
tenantId,
accounts: {
create: {
providerId: "credential",
accountId: emailInput,
password: hashedPassword,
},
},
role: { in: [...ALLOWED_ROLES] },
},
select: {
id: true,
email: true,
name: true,
role: true,
tenantId: true,
createdAt: true,
},
})
await prisma.user.upsert({
where: { email: user.email },
update: {
name: user.name ?? user.email,
role: userRole,
tenantId,
},
create: {
email: user.email,
name: user.name ?? user.email,
role: userRole,
tenantId,
},
})
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (convexUrl) {
try {
const convex = new ConvexHttpClient(convexUrl)
await convex.mutation(api.users.ensureUser, {
tenantId,
email: emailInput,
name: nameInput || emailInput,
avatarUrl: undefined,
role: userRole,
})
} catch (error) {
console.warn("Falha ao sincronizar usuário no Convex", error)
}
if (users.length === 0) {
return NextResponse.json({ deletedIds: [] })
}
return NextResponse.json({ user, temporaryPassword: password })
const emails = users.map((user) => user.email.toLowerCase())
const authUsers = await prisma.authUser.findMany({
where: {
email: { in: emails },
},
select: {
id: true,
},
})
const authUserIds = authUsers.map((authUser) => authUser.id)
await prisma.$transaction(async (tx) => {
if (authUserIds.length > 0) {
await tx.authSession.deleteMany({ where: { userId: { in: authUserIds } } })
await tx.authAccount.deleteMany({ where: { userId: { in: authUserIds } } })
await tx.authUser.deleteMany({ where: { id: { in: authUserIds } } })
}
await tx.user.deleteMany({ where: { id: { in: users.map((user) => user.id) } } })
})
return NextResponse.json({ deletedIds: users.map((user) => user.id) })
}

View file

@ -1,440 +0,0 @@
"use client"
import { useCallback, useMemo, useState, useTransition } from "react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { toast } from "sonner"
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
SortingState,
} from "@tanstack/react-table"
import { IconFilter, IconTrash, IconUser } from "@tabler/icons-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { TablePagination } from "@/components/ui/table-pagination"
export type AdminClient = {
id: string
email: string
name: string
role: "MANAGER" | "COLLABORATOR"
companyId: string | null
companyName: string | null
tenantId: string
createdAt: string
updatedAt: string
authUserId: string | null
lastSeenAt: string | null
}
const ROLE_LABEL: Record<AdminClient["role"], string> = {
MANAGER: "Gestor",
COLLABORATOR: "Colaborador",
}
function formatDate(dateString: string) {
const date = new Date(dateString)
return format(date, "dd/MM/yy HH:mm", { locale: ptBR })
}
function formatLastSeen(lastSeen: string | null) {
if (!lastSeen) return "Nunca conectado"
return formatDate(lastSeen)
}
export function AdminClientsManager({ initialClients }: { initialClients: AdminClient[] }) {
const [clients, setClients] = useState(initialClients)
const [search, setSearch] = useState("")
const [roleFilter, setRoleFilter] = useState<"all" | AdminClient["role"]>("all")
const [companyFilter, setCompanyFilter] = useState<string>("all")
const [rowSelection, setRowSelection] = useState({})
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }])
const [isPending, startTransition] = useTransition()
const [deleteDialogIds, setDeleteDialogIds] = useState<string[]>([])
const companies = useMemo(() => {
const entries = new Map<string, string>()
clients.forEach((client) => {
if (client.companyId && client.companyName) {
entries.set(client.companyId, client.companyName)
}
})
return Array.from(entries.entries()).map(([id, name]) => ({ id, name }))
}, [clients])
const filteredData = useMemo(() => {
return clients.filter((client) => {
if (roleFilter !== "all" && client.role !== roleFilter) return false
if (companyFilter !== "all" && client.companyId !== companyFilter) return false
if (!search.trim()) return true
const term = search.trim().toLowerCase()
return (
client.name.toLowerCase().includes(term) ||
client.email.toLowerCase().includes(term) ||
(client.companyName ?? "").toLowerCase().includes(term)
)
})
}, [clients, roleFilter, companyFilter, search])
const deleteTargets = useMemo(
() => clients.filter((client) => deleteDialogIds.includes(client.id)),
[clients, deleteDialogIds],
)
const handleDelete = useCallback(
(ids: string[]) => {
if (ids.length === 0) return
startTransition(async () => {
try {
const response = await fetch("/api/admin/clients", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new Error(payload?.error ?? "Não foi possível excluir os clientes selecionados.")
}
const { deletedIds } = (await response.json().catch(() => ({ deletedIds: [] }))) as {
deletedIds: string[]
}
if (deletedIds.length > 0) {
setClients((prev) => prev.filter((client) => !deletedIds.includes(client.id)))
setRowSelection({})
setDeleteDialogIds([])
}
toast.success(
deletedIds.length === 1
? "Cliente removido com sucesso."
: `${deletedIds.length} clientes removidos com sucesso.`,
)
} catch (error) {
const message =
error instanceof Error
? error.message
: "Não foi possível excluir os clientes selecionados."
toast.error(message)
}
})
},
[startTransition],
)
const columns = useMemo<ColumnDef<AdminClient>[]>(
() => [
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Selecionar todos"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Selecionar linha"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "Cliente",
cell: ({ row }) => {
const client = row.original
const initials = client.name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((value) => value.charAt(0).toUpperCase())
.join("")
return (
<div className="flex items-center gap-3">
<Avatar className="size-9 border border-slate-200">
<AvatarFallback>{initials || client.email.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-neutral-900">{client.name}</p>
<p className="truncate text-xs text-neutral-500">{client.email}</p>
</div>
</div>
)
},
},
{
accessorKey: "role",
header: "Perfil",
cell: ({ row }) => {
const role = row.original.role
const variant = role === "MANAGER" ? "default" : "secondary"
return <Badge variant={variant}>{ROLE_LABEL[role]}</Badge>
},
},
{
accessorKey: "companyName",
header: "Empresa",
cell: ({ row }) =>
row.original.companyName ? (
<Badge variant="outline" className="bg-slate-50 text-xs font-medium">
{row.original.companyName}
</Badge>
) : (
<span className="text-xs text-neutral-500">Sem empresa</span>
),
},
{
accessorKey: "createdAt",
header: "Cadastrado em",
cell: ({ row }) => (
<span className="text-xs text-neutral-600">{formatDate(row.original.createdAt)}</span>
),
},
{
id: "lastSeenAt",
header: "Último acesso",
cell: ({ row }) => (
<span className="text-xs text-neutral-600">{formatLastSeen(row.original.lastSeenAt)}</span>
),
},
{
id: "actions",
header: "",
enableSorting: false,
cell: ({ row }) => (
<Button
variant="outline"
size="sm"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
disabled={isPending}
onClick={() => setDeleteDialogIds([row.original.id])}
>
<IconTrash className="mr-2 size-4" /> Remover
</Button>
),
},
],
[isPending, setDeleteDialogIds]
)
const table = useReactTable({
data: filteredData,
columns,
state: { rowSelection, sorting },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
initialState: {
pagination: {
pageSize: 10,
},
},
})
const selectedRows = table.getSelectedRowModel().flatRows.map((row) => row.original)
const isBulkDelete = deleteTargets.length > 1
const dialogTitle = isBulkDelete ? "Remover clientes selecionados" : "Remover cliente"
const dialogDescription = isBulkDelete
? "Essa ação remove os clientes selecionados e revoga o acesso ao portal."
: "Essa ação remove o cliente escolhido e revoga o acesso ao portal."
const previewTargets = deleteTargets.slice(0, 3)
const remainingCount = deleteTargets.length - previewTargets.length
return (
<>
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2 text-sm text-neutral-600">
<IconUser className="size-4" />
{clients.length} cliente{clients.length === 1 ? "" : "s"}
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex items-center gap-2">
<Input
placeholder="Buscar por nome, e-mail ou empresa"
value={search}
onChange={(event) => setSearch(event.target.value)}
className="h-9 w-full md:w-72"
/>
<Button variant="outline" size="icon" className="md:hidden">
<IconFilter className="size-4" />
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={roleFilter} onValueChange={(value) => setRoleFilter(value as typeof roleFilter)}>
<SelectTrigger className="h-9 w-40">
<SelectValue placeholder="Perfil" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os perfis</SelectItem>
<SelectItem value="MANAGER">Gestores</SelectItem>
<SelectItem value="COLLABORATOR">Colaboradores</SelectItem>
</SelectContent>
</Select>
<Select value={companyFilter} onValueChange={(value) => setCompanyFilter(value)}>
<SelectTrigger className="h-9 w-48">
<SelectValue placeholder="Empresa" />
</SelectTrigger>
<SelectContent className="max-h-64">
<SelectItem value="all">Todas as empresas</SelectItem>
{companies.map((company) => (
<SelectItem key={company.id} value={company.id}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="gap-2 border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700 disabled:text-rose-300 disabled:border-rose-100"
disabled={selectedRows.length === 0 || isPending}
onClick={() => setDeleteDialogIds(selectedRows.map((row) => row.id))}
>
<IconTrash className="size-4" />
Excluir selecionados
</Button>
</div>
</div>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<Table>
<TableHeader className="bg-slate-50">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-slate-200">
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="text-xs uppercase tracking-wide text-neutral-500">
{header.isPlaceholder ? null : header.column.columnDef.header instanceof Function
? header.column.columnDef.header(header.getContext())
: header.column.columnDef.header}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center text-sm text-neutral-500">
Nenhum cliente encontrado para os filtros selecionados.
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="align-middle text-sm text-neutral-700">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<TablePagination
table={table}
pageSizeOptions={[10, 20, 30, 40, 50]}
rowsPerPageLabel="Itens por página"
showSelectedRows
selectionLabel={(selected, total) => `${selected} de ${total} selecionados`}
/>
</div>
<Dialog
open={deleteDialogIds.length > 0}
onOpenChange={(open) => {
if (!open && !isPending) {
setDeleteDialogIds([])
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
{deleteTargets.length > 0 ? (
<div className="space-y-3 py-2 text-sm text-neutral-600">
<p>
Confirme a exclusão de {isBulkDelete ? `${deleteTargets.length} clientes selecionados` : "um cliente"}. O acesso ao portal será revogado imediatamente.
</p>
<ul className="space-y-1">
{previewTargets.map((target) => (
<li key={target.id} className="rounded-md bg-slate-100 px-3 py-2 text-sm text-neutral-800">
<span className="font-medium">{target.name}</span>
<span className="text-neutral-500"> {target.email}</span>
</li>
))}
{remainingCount > 0 ? (
<li className="px-3 py-1 text-xs text-neutral-500">+ {remainingCount} outro{remainingCount === 1 ? "" : "s"}</li>
) : null}
</ul>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogIds([])} disabled={isPending}>
Cancelar
</Button>
<Button
variant="destructive"
onClick={() => handleDelete(deleteDialogIds)}
disabled={isPending}
>
{isPending ? "Removendo..." : isBulkDelete ? "Excluir clientes" : "Excluir cliente"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

File diff suppressed because it is too large Load diff

View file

@ -3,17 +3,25 @@
import { useMemo } from "react"
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import { MachineDetails, type MachinesQueryItem } from "@/components/admin/machines/admin-machines-overview"
import {
MachineDetails,
normalizeMachineItem,
type MachinesQueryItem,
} from "@/components/admin/machines/admin-machines-overview"
import { Card, CardContent } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
export function AdminMachineDetailsClient({ tenantId, machineId }: { tenantId: string; machineId: string }) {
const queryResult = useQuery(api.machines.listByTenant, { tenantId, includeMetadata: true }) as MachinesQueryItem[] | undefined
const isLoading = queryResult === undefined
const rawResult = useQuery(api.machines.listByTenant, { tenantId, includeMetadata: true }) as Array<Record<string, unknown>> | undefined
const machines: MachinesQueryItem[] | undefined = useMemo(() => {
if (!rawResult) return undefined
return rawResult.map((item) => normalizeMachineItem(item))
}, [rawResult])
const isLoading = rawResult === undefined
const machine = useMemo(() => {
if (!queryResult) return null
return queryResult.find((m) => m.id === machineId) ?? null
}, [queryResult, machineId])
if (!machines) return null
return machines.find((m) => m.id === machineId) ?? null
}, [machines, machineId])
if (isLoading) {
return (

View file

@ -224,6 +224,15 @@ type MachineInventory = {
collaborator?: { email?: string; name?: string; role?: string }
}
type MachineRemoteAccess = {
provider: string | null
identifier: string | null
url: string | null
notes: string | null
lastVerifiedAt: number | null
metadata: Record<string, unknown> | null
}
function collectInitials(name: string): string {
const words = name.split(/\s+/).filter(Boolean)
if (words.length === 0) return "?"
@ -267,6 +276,59 @@ function readNumber(record: Record<string, unknown>, ...keys: string[]): number
return undefined
}
export function normalizeMachineRemoteAccess(raw: unknown): MachineRemoteAccess | null {
if (!raw) return null
if (typeof raw === "string") {
const trimmed = raw.trim()
if (!trimmed) return null
const isUrl = /^https?:\/\//i.test(trimmed)
return {
provider: null,
identifier: isUrl ? null : trimmed,
url: isUrl ? trimmed : null,
notes: null,
lastVerifiedAt: null,
metadata: null,
}
}
const record = toRecord(raw)
if (!record) return null
const provider = readString(record, "provider", "tool", "vendor", "name") ?? null
const identifier = readString(record, "identifier", "code", "id", "accessId") ?? null
const url = readString(record, "url", "link", "remoteUrl", "console", "viewer") ?? null
const notes = readString(record, "notes", "note", "description", "obs") ?? null
const timestampCandidate =
readNumber(record, "lastVerifiedAt", "verifiedAt", "checkedAt", "updatedAt") ??
parseDateish(record["lastVerifiedAt"] ?? record["verifiedAt"] ?? record["checkedAt"] ?? record["updatedAt"])
const lastVerifiedAt = timestampCandidate instanceof Date ? timestampCandidate.getTime() : timestampCandidate ?? null
return {
provider,
identifier,
url,
notes,
lastVerifiedAt,
metadata: record,
}
}
function formatRemoteAccessMetadataKey(key: string) {
return key
.replace(/[_.-]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
}
function formatRemoteAccessMetadataValue(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (value instanceof Date) return formatAbsoluteDateTime(value)
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function readText(record: Record<string, unknown>, ...keys: string[]): string | undefined {
const stringValue = readString(record, ...keys)
if (stringValue) return stringValue
@ -663,15 +725,23 @@ export type MachinesQueryItem = {
postureAlerts?: Array<Record<string, unknown>> | null
lastPostureAt?: number | null
linkedUsers?: Array<{ id: string; email: string; name: string }>
remoteAccess: MachineRemoteAccess | null
}
export function normalizeMachineItem(raw: Record<string, unknown>): MachinesQueryItem {
const base = raw as MachinesQueryItem
return {
...base,
remoteAccess: normalizeMachineRemoteAccess(raw["remoteAccess"]) ?? null,
}
}
function useMachinesQuery(tenantId: string): MachinesQueryItem[] {
return (
(useQuery(api.machines.listByTenant, {
tenantId,
includeMetadata: true,
}) ?? []) as MachinesQueryItem[]
)
const result = useQuery(api.machines.listByTenant, {
tenantId,
includeMetadata: true,
}) as Array<Record<string, unknown>> | undefined
return useMemo(() => (result ?? []).map((item) => normalizeMachineItem(item)), [result])
}
const DEFAULT_OFFLINE_THRESHOLD_MS = 10 * 60 * 1000
@ -975,11 +1045,11 @@ function OsIcon({ osName }: { osName?: string | null }) {
return <Monitor className="size-4 text-black" />
}
export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "all" }: { tenantId: string; initialCompanyFilterSlug?: string }) {
const machines = useMachinesQuery(tenantId)
const [q, setQ] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("all")
const [companyFilterSlug, setCompanyFilterSlug] = useState<string>("all")
const [companyFilterSlug, setCompanyFilterSlug] = useState<string>(initialCompanyFilterSlug)
const [companySearch, setCompanySearch] = useState<string>("")
const [onlyAlerts, setOnlyAlerts] = useState<boolean>(false)
const { convexUserId } = useAuth()
@ -1599,6 +1669,53 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
const remoteAccess = machine?.remoteAccess ?? null
const remoteAccessMetadataEntries = useMemo(() => {
if (!remoteAccess?.metadata) return [] as Array<[string, unknown]>
const knownKeys = new Set([
"provider",
"tool",
"vendor",
"name",
"identifier",
"code",
"id",
"accessId",
"url",
"link",
"remoteUrl",
"console",
"viewer",
"notes",
"note",
"description",
"obs",
"lastVerifiedAt",
"verifiedAt",
"checkedAt",
"updatedAt",
])
return Object.entries(remoteAccess.metadata)
.filter(([key, value]) => {
if (knownKeys.has(key)) return false
if (value === null || value === undefined) return false
if (typeof value === "string" && value.trim().length === 0) return false
return true
})
}, [remoteAccess])
const remoteAccessLastVerifiedDate = useMemo(() => {
if (!remoteAccess?.lastVerifiedAt) return null
const date = new Date(remoteAccess.lastVerifiedAt)
return Number.isNaN(date.getTime()) ? null : date
}, [remoteAccess?.lastVerifiedAt])
const hasRemoteAccess = Boolean(
remoteAccess?.identifier ||
remoteAccess?.url ||
remoteAccess?.notes ||
remoteAccess?.provider ||
remoteAccessMetadataEntries.length > 0
)
const summaryChips = useMemo(() => {
const chips: Array<{ key: string; label: string; value: string; icon: ReactNode; tone?: "warning" | "muted" }> = []
const osName = osNameDisplay || "Sistema desconhecido"
@ -1652,8 +1769,18 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
icon: <ShieldCheck className="size-4 text-neutral-500" />,
})
}
if (remoteAccess && (remoteAccess.identifier || remoteAccess.url)) {
const value = remoteAccess.identifier ?? remoteAccess.url ?? "—"
const label = remoteAccess.provider ? `Acesso (${remoteAccess.provider})` : "Acesso remoto"
chips.push({
key: "remote-access",
label,
value,
icon: <Key className="size-4 text-neutral-500" />,
})
}
return chips
}, [osNameDisplay, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel, machine?.osName])
}, [osNameDisplay, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel, machine?.osName, remoteAccess])
const companyName = (() => {
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
@ -1782,6 +1909,17 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
}
}
const handleCopyRemoteIdentifier = useCallback(async () => {
if (!remoteAccess?.identifier) return
try {
await navigator.clipboard.writeText(remoteAccess.identifier)
toast.success("Identificador de acesso remoto copiado.")
} catch (error) {
console.error(error)
toast.error("Não foi possível copiar o identificador.")
}
}, [remoteAccess?.identifier])
return (
<Card className="border-slate-200">
<CardHeader className="gap-1">
@ -1861,6 +1999,61 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</Badge>
) : null}
</div>
{hasRemoteAccess ? (
<div className="rounded-lg border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-xs sm:text-sm text-slate-700">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-indigo-600">
<Key className="size-4" />
Acesso remoto
{remoteAccess?.provider ? (
<Badge variant="outline" className="border-indigo-200 bg-white text-[11px] font-semibold text-indigo-700">
{remoteAccess.provider}
</Badge>
) : null}
</div>
{remoteAccess?.identifier ? (
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-slate-900">{remoteAccess.identifier}</span>
<Button variant="ghost" size="sm" className="h-7 gap-2 px-2" onClick={handleCopyRemoteIdentifier}>
<ClipboardCopy className="size-3.5" /> Copiar ID
</Button>
</div>
) : null}
{remoteAccess?.url ? (
<a
href={remoteAccess.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-indigo-700 underline-offset-4 hover:underline"
>
Abrir console remoto
</a>
) : null}
{remoteAccess?.notes ? (
<p className="text-[11px] text-slate-600">{remoteAccess.notes}</p>
) : null}
{remoteAccessLastVerifiedDate ? (
<p className="text-[11px] text-slate-500">
Atualizado {formatRelativeTime(remoteAccessLastVerifiedDate)}
{" "}
<span className="text-slate-400">({formatAbsoluteDateTime(remoteAccessLastVerifiedDate)})</span>
</p>
) : null}
</div>
</div>
{remoteAccessMetadataEntries.length ? (
<div className="mt-3 grid gap-2 text-[11px] text-slate-600 sm:grid-cols-2">
{remoteAccessMetadataEntries.map(([key, value]) => (
<div key={key} className="flex items-center justify-between gap-3 rounded-md border border-indigo-100 bg-white px-2 py-1">
<span className="font-semibold text-slate-700">{formatRemoteAccessMetadataKey(key)}</span>
<span className="truncate text-right text-slate-600">{formatRemoteAccessMetadataValue(value)}</span>
</div>
))}
</div>
) : null}
</div>
) : null}
</section>
<section className="space-y-2">

File diff suppressed because it is too large Load diff

View file

@ -23,16 +23,16 @@ export function AppShell({ header, children }: AppShellProps) {
<AuthGuard />
</Suspense>
{isLoading ? (
<div className="px-4 pt-4 lg:px-6">
<div className="flex items-center justify-between gap-4">
<Skeleton className="h-7 w-48" />
<div className="hidden items-center gap-2 sm:flex">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
<header className="flex h-auto shrink-0 flex-wrap items-start gap-3 border-b bg-background/80 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60 transition-[width,height] ease-linear sm:h-(--header-height) sm:flex-nowrap sm:items-center sm:px-6 lg:px-8 sm:group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex flex-1 flex-col gap-1">
<Skeleton className="h-4 w-52" />
<Skeleton className="h-7 w-40" />
</div>
<Skeleton className="mt-2 h-4 w-72" />
</div>
<div className="hidden items-center gap-2 sm:flex">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
</header>
) : (
header
)}

View file

@ -106,7 +106,7 @@ const navigation: NavigationGroup[] = [
url: "/admin/companies",
icon: Building2,
requiredRole: "admin",
children: [{ title: "Clientes", url: "/admin/clients", icon: Users, requiredRole: "admin" }],
children: [{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" }],
},
{ title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" },
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },

View file

@ -0,0 +1,61 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b border-border/60 last:border-b-0", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-left text-sm font-medium transition-all hover:text-foreground/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<svg
viewBox="0 0 24 24"
stroke="currentColor"
fill="none"
className="h-4 w-4 shrink-0 transition-transform duration-200"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="m6 9 6 6 6-6" />
</svg>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View file

@ -0,0 +1,144 @@
import * as React from "react"
import { IconX } from "@tabler/icons-react"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
type MultiValueInputProps = {
values: string[]
onChange: (values: string[]) => void
placeholder?: string
disabled?: boolean
maxItems?: number
addOnBlur?: boolean
className?: string
inputClassName?: string
validate?: (value: string) => string | null
format?: (value: string) => string
emptyState?: React.ReactNode
}
export function MultiValueInput({
values,
onChange,
placeholder,
disabled,
maxItems,
addOnBlur,
className,
inputClassName,
validate,
format,
emptyState,
}: MultiValueInputProps) {
const [pending, setPending] = React.useState("")
const [error, setError] = React.useState<string | null>(null)
const inputRef = React.useRef<HTMLInputElement | null>(null)
const remainingSlots = typeof maxItems === "number" ? Math.max(maxItems - values.length, 0) : undefined
const canAdd = remainingSlots === undefined || remainingSlots > 0
const addValue = React.useCallback(
(raw: string) => {
const trimmed = raw.trim()
if (!trimmed) return
const formatted = format ? format(trimmed) : trimmed
if (values.includes(formatted)) {
setPending("")
setError(null)
return
}
if (validate) {
const validation = validate(formatted)
if (validation) {
setError(validation)
return
}
}
setError(null)
onChange([...values, formatted])
setPending("")
},
[format, onChange, validate, values]
)
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" || event.key === "," || event.key === "Tab") {
if (!canAdd) return
event.preventDefault()
addValue(pending)
}
if (event.key === "Backspace" && pending.length === 0 && values.length > 0) {
onChange(values.slice(0, -1))
setError(null)
}
}
const handleBlur = () => {
if (addOnBlur && pending.trim()) {
addValue(pending)
}
}
const removeValue = (value: string) => {
onChange(values.filter((item) => item !== value))
setError(null)
inputRef.current?.focus()
}
return (
<div className={cn("space-y-2", className)}>
<div
className={cn(
"flex flex-wrap gap-2 rounded-md border border-input bg-background px-3 py-2 focus-within:ring-2 focus-within:ring-ring",
disabled && "opacity-60"
)}
>
{values.map((value) => (
<Badge
key={value}
variant="secondary"
className="flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium"
>
<span>{value}</span>
<button
type="button"
className="flex h-4 w-4 items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80"
onClick={() => removeValue(value)}
aria-label={`Remover ${value}`}
disabled={disabled}
>
<IconX className="h-3 w-3" strokeWidth={2} />
</button>
</Badge>
))}
{canAdd ? (
<Input
ref={inputRef}
value={pending}
onChange={(event) => {
setPending(event.target.value)
setError(null)
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
placeholder={values.length === 0 ? placeholder : undefined}
disabled={disabled}
className={cn(
"m-0 h-auto min-w-[8rem] flex-1 border-0 bg-transparent px-0 py-1 text-sm shadow-none focus-visible:ring-0",
inputClassName
)}
/>
) : null}
</div>
{error ? <p className="text-xs font-medium text-destructive">{error}</p> : null}
{!values.length && emptyState ? <div className="text-xs text-muted-foreground">{emptyState}</div> : null}
{typeof remainingSlots === "number" ? (
<div className="text-right text-[11px] text-muted-foreground">
{remainingSlots} item{remainingSlots === 1 ? "" : "s"} restantes
</div>
) : null}
</div>
)
}

View file

@ -0,0 +1,33 @@
"use client"
import * as React from "react"
type ScrollAreaProps = React.ComponentPropsWithoutRef<"div"> & {
orientation?: "vertical" | "horizontal" | "both"
}
const orientationClasses: Record<NonNullable<ScrollAreaProps["orientation"]>, string> = {
vertical: "overflow-y-auto",
horizontal: "overflow-x-auto",
both: "overflow-auto",
}
const baseClasses =
"relative [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted [&::-webkit-scrollbar-track]:bg-transparent"
const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
({ className, orientation = "vertical", children, ...props }, ref) => {
return (
<div
ref={ref}
className={[baseClasses, orientationClasses[orientation], className].filter(Boolean).join(" ")}
{...props}
>
{children}
</div>
)
}
)
ScrollArea.displayName = "ScrollArea"
export { ScrollArea }

View file

@ -0,0 +1,83 @@
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/lib/utils"
type TimePickerProps = {
value?: string | null
onChange?: (value: string) => void
className?: string
placeholder?: string
stepMinutes?: number
}
function pad2(n: number) {
return String(n).padStart(2, "0")
}
export function TimePicker({ value, onChange, className, placeholder = "Selecionar horário", stepMinutes = 15 }: TimePickerProps) {
const [open, setOpen] = React.useState(false)
const [hours, minutes] = React.useMemo(() => {
if (!value || !/^\d{2}:\d{2}$/.test(value)) return ["", ""]
const [h, m] = value.split(":")
return [h, m]
}, [value])
const minuteOptions = React.useMemo(() => {
const list: string[] = []
for (let i = 0; i < 60; i += stepMinutes) list.push(pad2(i))
if (!list.includes("00")) list.unshift("00")
return list
}, [stepMinutes])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className={cn("w-full justify-between font-normal", className)}>
{value ? value : placeholder}
<ChevronDownIcon className="ml-2 size-4 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="flex gap-2 p-2">
<div className="max-h-56 w-16 overflow-auto rounded-md border">
{Array.from({ length: 24 }, (_, h) => pad2(h)).map((h) => (
<button
key={h}
type="button"
className={cn(
"block w-full px-3 py-1.5 text-left text-sm hover:bg-muted",
h === hours && "bg-muted/70 font-semibold"
)}
onClick={() => onChange?.(`${h}:${minutes || "00"}`)}
>
{h}
</button>
))}
</div>
<div className="max-h-56 w-16 overflow-auto rounded-md border">
{minuteOptions.map((m) => (
<button
key={m}
type="button"
className={cn(
"block w-full px-3 py-1.5 text-left text-sm hover:bg-muted",
m === minutes && "bg-muted/70 font-semibold"
)}
onClick={() => onChange?.(`${hours || "00"}:${m}`)}
>
{m}
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)
}

439
src/lib/schemas/company.ts Normal file
View file

@ -0,0 +1,439 @@
import { z } from "zod"
export const BRAZILIAN_UF = [
{ value: "AC", label: "Acre" },
{ value: "AL", label: "Alagoas" },
{ value: "AP", label: "Amapá" },
{ value: "AM", label: "Amazonas" },
{ value: "BA", label: "Bahia" },
{ value: "CE", label: "Ceará" },
{ value: "DF", label: "Distrito Federal" },
{ value: "ES", label: "Espírito Santo" },
{ value: "GO", label: "Goiás" },
{ value: "MA", label: "Maranhão" },
{ value: "MT", label: "Mato Grosso" },
{ value: "MS", label: "Mato Grosso do Sul" },
{ value: "MG", label: "Minas Gerais" },
{ value: "PA", label: "Pará" },
{ value: "PB", label: "Paraíba" },
{ value: "PR", label: "Paraná" },
{ value: "PE", label: "Pernambuco" },
{ value: "PI", label: "Piauí" },
{ value: "RJ", label: "Rio de Janeiro" },
{ value: "RN", label: "Rio Grande do Norte" },
{ value: "RS", label: "Rio Grande do Sul" },
{ value: "RO", label: "Rondônia" },
{ value: "RR", label: "Roraima" },
{ value: "SC", label: "Santa Catarina" },
{ value: "SP", label: "São Paulo" },
{ value: "SE", label: "Sergipe" },
{ value: "TO", label: "Tocantins" },
] as const
export const COMPANY_STATE_REGISTRATION_TYPES = [
{ value: "standard", label: "Inscrição estadual" },
{ value: "exempt", label: "Isento" },
{ value: "simples", label: "Simples Nacional" },
] as const
export const COMPANY_CONTACT_ROLES = [
{ value: "financeiro", label: "Financeiro" },
{ value: "decisor", label: "Decisor" },
{ value: "ti", label: "TI" },
{ value: "juridico", label: "Jurídico" },
{ value: "compras", label: "Compras" },
{ value: "usuario_chave", label: "Usuário-chave" },
{ value: "outro", label: "Outro" },
] as const
export const COMPANY_CONTACT_PREFERENCES = [
{ value: "email", label: "E-mail" },
{ value: "phone", label: "Telefone" },
{ value: "whatsapp", label: "WhatsApp" },
{ value: "business_hours", label: "Horário comercial" },
] as const
export const COMPANY_LOCATION_TYPES = [
{ value: "matrix", label: "Matriz" },
{ value: "branch", label: "Filial" },
{ value: "data_center", label: "Data Center" },
{ value: "home_office", label: "Home Office" },
{ value: "other", label: "Outro" },
] as const
export const COMPANY_CONTRACT_TYPES = [
{ value: "monthly", label: "Mensalidade" },
{ value: "time_bank", label: "Banco de horas" },
{ value: "per_ticket", label: "Por chamado" },
{ value: "project", label: "Projetos" },
] as const
export const COMPANY_CONTRACT_SCOPES = [
"suporte_m365",
"endpoints",
"rede",
"servidores",
"backup",
"email",
"impressoras",
"seguranca",
] as const
export const COMPANY_CRITICALITY_LEVELS = [
{ value: "low", label: "Baixa" },
{ value: "medium", label: "Média" },
{ value: "high", label: "Alta" },
] as const
export const COMPANY_REGULATION_OPTIONS = [
{ value: "lgpd_critical", label: "LGPD crítico" },
{ value: "finance", label: "Financeiro" },
{ value: "health", label: "Saúde" },
{ value: "public", label: "Setor público" },
{ value: "education", label: "Educação" },
{ value: "custom", label: "Outro" },
] as const
export const SLA_SEVERITY_LEVELS = ["P1", "P2", "P3", "P4"] as const
const daySchema = z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])
const ufSchema = z.enum(BRAZILIAN_UF.map((item) => item.value) as [string, ...string[]])
const stateRegistrationTypeSchema = z.enum(
COMPANY_STATE_REGISTRATION_TYPES.map((item) => item.value) as [string, ...string[]]
)
const contactRoleSchema = z.enum(
COMPANY_CONTACT_ROLES.map((item) => item.value) as [string, ...string[]]
)
const contactPreferenceSchema = z.enum(
COMPANY_CONTACT_PREFERENCES.map((item) => item.value) as [string, ...string[]]
)
const locationTypeSchema = z.enum(
COMPANY_LOCATION_TYPES.map((item) => item.value) as [string, ...string[]]
)
const contractTypeSchema = z.enum(
COMPANY_CONTRACT_TYPES.map((item) => item.value) as [string, ...string[]]
)
const contractScopeSchema = z.enum(COMPANY_CONTRACT_SCOPES)
const criticalitySchema = z.enum(
COMPANY_CRITICALITY_LEVELS.map((item) => item.value) as [string, ...string[]]
)
const regulationSchema = z.enum(
COMPANY_REGULATION_OPTIONS.map((item) => item.value) as [string, ...string[]]
)
const severitySchema = z.enum(SLA_SEVERITY_LEVELS)
const phoneRegex = /^[0-9()+\s-]{8,20}$/
const timeRegex = /^\d{2}:\d{2}$/
const dateRegex = /^\d{4}-\d{2}-\d{2}$/
const cnpjDigitsRegex = /^\d{14}$/
const cepRegex = /^\d{5}-?\d{3}$/
const domainRegex =
/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/i
const optionalString = z
.string()
.trim()
.transform((value) => (value.length === 0 ? null : value))
.nullable()
.optional()
const monetarySchema = z
.union([z.number(), z.string()])
.transform((value) => {
if (typeof value === "number") return value
const normalized = value.replace(/\./g, "").replace(",", ".")
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
})
.nullable()
const servicePeriodSchema = z.object({
days: z.array(daySchema).min(1, "Informe ao menos um dia"),
start: z
.string()
.regex(timeRegex, "Use o formato HH:MM"),
end: z
.string()
.regex(timeRegex, "Use o formato HH:MM"),
})
export const businessHoursSchema = z
.object({
mode: z.enum(["business", "twentyfour", "custom"]).default("business"),
timezone: z.string().min(3).default("America/Sao_Paulo"),
periods: z.array(servicePeriodSchema).default([]),
})
.refine(
(value) => {
if (value.mode === "twentyfour") return true
return value.periods.length > 0
},
{ message: "Defina pelo menos um período", path: ["periods"] }
)
const addressSchema = z
.object({
street: z.string().min(3, "Informe a rua"),
number: z.string().min(1, "Informe o número"),
complement: optionalString,
district: z.string().min(2, "Informe o bairro"),
city: z.string().min(2, "Informe a cidade"),
state: ufSchema,
zip: z
.string()
.regex(cepRegex, "CEP inválido"),
})
.partial({
complement: true,
})
const contactSchema = z.object({
id: z.string(),
fullName: z.string().min(3, "Nome obrigatório"),
email: z.string().email("E-mail inválido"),
phone: z
.string()
.regex(phoneRegex, "Telefone inválido")
.nullable()
.optional(),
whatsapp: z
.string()
.regex(phoneRegex, "WhatsApp inválido")
.nullable()
.optional(),
role: contactRoleSchema,
title: z.string().trim().nullable().optional(),
preference: z.array(contactPreferenceSchema).default([]),
canAuthorizeTickets: z.boolean().default(false),
canApproveCosts: z.boolean().default(false),
lgpdConsent: z.boolean().default(true),
notes: z.string().trim().nullable().optional(),
})
const locationSchema = z.object({
id: z.string(),
name: z.string().min(2, "Informe o nome da unidade"),
type: locationTypeSchema,
address: addressSchema.nullish(),
responsibleContactId: z.string().nullable().optional(),
serviceWindow: z
.object({
mode: z.enum(["inherit", "custom"]).default("inherit"),
periods: z.array(servicePeriodSchema).default([]),
})
.refine(
(value) => (value.mode === "inherit" ? true : value.periods.length > 0),
{ message: "Defina períodos personalizados", path: ["periods"] }
),
notes: z.string().trim().nullable().optional(),
})
const contractSchema = z.object({
id: z.string(),
contractType: contractTypeSchema,
planSku: z.string().trim().nullable().optional(),
startDate: z
.string()
.regex(dateRegex, "Formato AAAA-MM-DD")
.nullable()
.optional(),
endDate: z
.string()
.regex(dateRegex, "Formato AAAA-MM-DD")
.nullable()
.optional(),
renewalDate: z
.string()
.regex(dateRegex, "Formato AAAA-MM-DD")
.nullable()
.optional(),
scope: z.array(contractScopeSchema).default([]),
price: monetarySchema,
costCenter: z.string().trim().nullable().optional(),
criticality: criticalitySchema.default("medium"),
notes: z.string().trim().nullable().optional(),
})
const severityEntrySchema = z.object({
level: severitySchema,
responseMinutes: z
.number()
.int()
.min(0)
.default(60),
resolutionMinutes: z
.number()
.int()
.min(0)
.default(240),
})
const slaSchema = z.object({
calendar: z.enum(["24x7", "business", "custom"]).default("business"),
validChannels: z.array(z.string().min(3)).default([]),
holidays: z
.array(
z
.string()
.regex(dateRegex, "Formato AAAA-MM-DD")
)
.default([]),
severities: z
.array(severityEntrySchema)
.default([
{ level: "P1", responseMinutes: 30, resolutionMinutes: 240 },
{ level: "P2", responseMinutes: 60, resolutionMinutes: 480 },
{ level: "P3", responseMinutes: 120, resolutionMinutes: 1440 },
{ level: "P4", responseMinutes: 240, resolutionMinutes: 2880 },
]),
serviceWindow: z
.object({
timezone: z.string().default("America/Sao_Paulo"),
periods: z.array(servicePeriodSchema).default([]),
})
.optional(),
})
const communicationChannelsSchema = z.object({
supportEmails: z.array(z.string().email()).default([]),
billingEmails: z.array(z.string().email()).default([]),
whatsappNumbers: z.array(z.string().regex(phoneRegex, "Telefone inválido")).default([]),
phones: z.array(z.string().regex(phoneRegex, "Telefone inválido")).default([]),
portals: z.array(z.string().url("URL inválida")).default([]),
})
const privacyPolicySchema = z.object({
accepted: z.boolean().default(false),
reference: z.string().url("URL inválida").nullable().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
const customFieldSchema = z.object({
id: z.string(),
key: z.string().min(1),
label: z.string().min(1),
type: z.enum(["text", "number", "boolean", "date", "url"]).default("text"),
value: z.union([z.string(), z.number(), z.boolean(), z.null()]).nullable(),
})
const domainSchema = z
.string()
.trim()
.toLowerCase()
.regex(domainRegex, "Domínio inválido")
export const companyFormSchema = z.object({
tenantId: z.string(),
name: z.string().min(2, "Nome obrigatório"),
slug: z
.string()
.min(2, "Informe um apelido")
.regex(/^[a-z0-9-]+$/, "Use apenas letras minúsculas, números ou hífen"),
legalName: z.string().trim().nullable().optional(),
tradeName: z.string().trim().nullable().optional(),
cnpj: z
.string()
.regex(cnpjDigitsRegex, "CNPJ deve ter 14 dígitos")
.nullable()
.optional(),
stateRegistration: z.string().trim().nullable().optional(),
stateRegistrationType: stateRegistrationTypeSchema.nullish(),
primaryCnae: z.string().trim().nullable().optional(),
description: z.string().trim().nullable().optional(),
domain: domainSchema.nullable().optional(),
phone: z
.string()
.regex(phoneRegex, "Telefone inválido")
.nullable()
.optional(),
address: z.string().trim().nullable().optional(),
contractedHoursPerMonth: z
.number()
.min(0)
.nullable()
.optional(),
businessHours: businessHoursSchema.nullish(),
communicationChannels: communicationChannelsSchema.default({
supportEmails: [],
billingEmails: [],
whatsappNumbers: [],
phones: [],
portals: [],
}),
supportEmail: z.string().email().nullable().optional(),
billingEmail: z.string().email().nullable().optional(),
contactPreferences: z
.object({
defaultChannel: contactPreferenceSchema.nullable().optional(),
escalationNotes: z.string().trim().nullable().optional(),
})
.optional(),
clientDomains: z.array(domainSchema).default([]),
fiscalAddress: addressSchema.nullish(),
hasBranches: z.boolean().default(false),
regulatedEnvironments: z.array(regulationSchema).default([]),
privacyPolicy: privacyPolicySchema.default({
accepted: false,
reference: null,
}),
contacts: z.array(contactSchema).default([]),
locations: z.array(locationSchema).default([]),
contracts: z.array(contractSchema).default([]),
sla: slaSchema.nullish(),
tags: z.array(z.string().trim().min(1)).default([]),
customFields: z.array(customFieldSchema).default([]),
notes: z.string().trim().nullable().optional(),
isAvulso: z.boolean().default(false),
})
export type CompanyFormValues = z.infer<typeof companyFormSchema>
export type CompanyContact = z.infer<typeof contactSchema>
export type CompanyLocation = z.infer<typeof locationSchema>
export type CompanyContract = z.infer<typeof contractSchema>
export type CompanySla = z.infer<typeof slaSchema>
export type CompanyBusinessHours = z.infer<typeof businessHoursSchema>
export type CompanyCommunicationChannels = z.infer<typeof communicationChannelsSchema>
export type CompanyStateRegistrationTypeOption =
(typeof COMPANY_STATE_REGISTRATION_TYPES)[number]["value"]
export const defaultBusinessHours: CompanyBusinessHours = {
mode: "business",
timezone: "America/Sao_Paulo",
periods: [
{
days: ["mon", "tue", "wed", "thu", "fri"],
start: "09:00",
end: "18:00",
},
],
}
export const defaultSla: CompanySla = {
calendar: "business",
validChannels: [],
holidays: [],
severities: [
{ level: "P1", responseMinutes: 30, resolutionMinutes: 240 },
{ level: "P2", responseMinutes: 60, resolutionMinutes: 480 },
{ level: "P3", responseMinutes: 120, resolutionMinutes: 1440 },
{ level: "P4", responseMinutes: 240, resolutionMinutes: 2880 },
],
serviceWindow: {
timezone: "America/Sao_Paulo",
periods: [
{
days: ["mon", "tue", "wed", "thu", "fri"],
start: "09:00",
end: "18:00",
},
],
},
}
export type CompanyFormPayload = CompanyFormValues
export const companyInputSchema = companyFormSchema.extend({
tenantId: companyFormSchema.shape.tenantId.optional(),
})
export type CompanyInputPayload = z.infer<typeof companyInputSchema>

View file

@ -0,0 +1,489 @@
import { Prisma, type Company, type CompanyStateRegistrationType } from "@prisma/client"
import { prisma } from "@/lib/prisma"
import { ZodError } from "zod"
import {
companyFormSchema,
companyInputSchema,
type CompanyCommunicationChannels,
type CompanyFormValues,
type CompanyStateRegistrationTypeOption,
} from "@/lib/schemas/company"
export type NormalizedCompany = CompanyFormValues & {
id: string
provisioningCode: string | null
createdAt: string
updatedAt: string
}
function slugify(value?: string | null): string {
if (!value) return ""
const ascii = value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w\s-]/g, "")
const collapsed = ascii.trim().replace(/[_\s]+/g, "-")
const sanitized = collapsed.replace(/-+/g, "-").toLowerCase()
return sanitized.replace(/^-+|-+$/g, "")
}
function ensureSlugValue(
inputSlug: string | null | undefined,
fallbackName: string | null | undefined,
fallbackId?: string
): string {
const slugFromInput = slugify(inputSlug)
if (slugFromInput) return slugFromInput
const slugFromName = slugify(fallbackName)
if (slugFromName) return slugFromName
if (fallbackId) {
const slugFromId = slugify(fallbackId)
if (slugFromId) return slugFromId
return fallbackId.toLowerCase()
}
return ""
}
const STATE_REGISTRATION_TYPE_TO_PRISMA: Record<
CompanyStateRegistrationTypeOption,
CompanyStateRegistrationType
> = {
standard: "STANDARD",
exempt: "EXEMPT",
simples: "SIMPLES",
}
const STATE_REGISTRATION_TYPE_FROM_PRISMA: Record<
CompanyStateRegistrationType,
CompanyStateRegistrationTypeOption
> = {
STANDARD: "standard",
EXEMPT: "exempt",
SIMPLES: "simples",
}
export function formatZodError(error: ZodError) {
return error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
}))
}
function sanitizeDomain(value?: string | null) {
if (!value) return null
const trimmed = value.trim().toLowerCase()
return trimmed.length === 0 ? null : trimmed
}
function sanitizePhone(value?: string | null) {
if (!value) return null
const trimmed = value.trim()
return trimmed.length === 0 ? null : trimmed
}
function normalizeChannels(
channels?: Partial<CompanyCommunicationChannels> | null
): CompanyCommunicationChannels {
const ensure = (values?: string[]) =>
Array.from(new Set((values ?? []).map((value) => value.trim()).filter(Boolean)))
return {
supportEmails: ensure(channels?.supportEmails).map((email) => email.toLowerCase()),
billingEmails: ensure(channels?.billingEmails).map((email) => email.toLowerCase()),
whatsappNumbers: ensure(channels?.whatsappNumbers),
phones: ensure(channels?.phones),
portals: ensure(channels?.portals),
}
}
function mergeChannelsWithPrimary(
payload: CompanyFormValues,
base?: CompanyCommunicationChannels
): CompanyCommunicationChannels {
const channels = normalizeChannels(base ?? payload.communicationChannels)
const supportEmails = new Set(channels.supportEmails)
const billingEmails = new Set(channels.billingEmails)
const phones = new Set(channels.phones)
if (payload.supportEmail) supportEmails.add(payload.supportEmail.toLowerCase())
if (payload.billingEmail) billingEmails.add(payload.billingEmail.toLowerCase())
if (payload.phone) phones.add(payload.phone)
return {
supportEmails: Array.from(supportEmails),
billingEmails: Array.from(billingEmails),
whatsappNumbers: channels.whatsappNumbers,
phones: Array.from(phones),
portals: channels.portals,
}
}
export function sanitizeCompanyInput(input: unknown, tenantId: string): CompanyFormValues {
const parsed = companyInputSchema.safeParse(input)
if (!parsed.success) {
throw parsed.error
}
const raw = parsed.data
const normalizedName = raw.name?.trim() ?? ""
const normalizedSlug = ensureSlugValue(raw.slug, normalizedName, raw.slug ?? raw.name ?? undefined)
const cnpjDigits =
typeof raw.cnpj === "string" ? raw.cnpj.replace(/\D/g, "").slice(0, 14) : null
const normalizedContacts = (raw.contacts ?? []).map((contact) => ({
...contact,
email: contact.email?.trim().toLowerCase(),
phone: sanitizePhone(contact.phone),
whatsapp: sanitizePhone(contact.whatsapp),
preference: Array.from(new Set(contact.preference ?? [])),
}))
const normalizedLocations = (raw.locations ?? []).map((location) => ({
...location,
responsibleContactId: location.responsibleContactId ?? null,
serviceWindow: location.serviceWindow ?? { mode: "inherit", periods: [] },
}))
const normalizedContracts = (raw.contracts ?? []).map((contract) => ({
...contract,
scope: Array.from(new Set(contract.scope ?? [])),
}))
const normalizedTags = Array.from(
new Set((raw.tags ?? []).map((tag) => tag.trim()).filter(Boolean))
)
const normalizedCustomFields = (raw.customFields ?? []).map((field) => ({
...field,
label: field.label.trim(),
key: field.key.trim(),
}))
const normalized: CompanyFormValues = companyFormSchema.parse({
...raw,
tenantId,
name: normalizedName,
slug: normalizedSlug,
legalName: raw.legalName?.trim() ?? null,
tradeName: raw.tradeName?.trim() ?? null,
cnpj: cnpjDigits && cnpjDigits.length === 14 ? cnpjDigits : null,
stateRegistration: raw.stateRegistration?.trim() ?? null,
primaryCnae: raw.primaryCnae?.trim() ?? null,
description: raw.description?.trim() ?? null,
domain: sanitizeDomain(raw.domain),
phone: sanitizePhone(raw.phone),
address: raw.address?.trim() ?? null,
communicationChannels: normalizeChannels(raw.communicationChannels),
supportEmail: raw.supportEmail?.trim().toLowerCase() ?? null,
billingEmail: raw.billingEmail?.trim().toLowerCase() ?? null,
clientDomains: Array.from(
new Set((raw.clientDomains ?? []).map((domain) => domain.trim().toLowerCase()).filter(Boolean))
),
fiscalAddress: raw.fiscalAddress ?? null,
regulatedEnvironments: Array.from(new Set(raw.regulatedEnvironments ?? [])),
contacts: normalizedContacts,
locations: normalizedLocations,
contracts: normalizedContracts,
businessHours: raw.businessHours ?? null,
sla: raw.sla ?? null,
tags: normalizedTags,
customFields: normalizedCustomFields,
notes: raw.notes?.trim() ?? null,
privacyPolicy: raw.privacyPolicy
? {
accepted: raw.privacyPolicy.accepted ?? false,
reference: raw.privacyPolicy.reference ?? null,
metadata: raw.privacyPolicy.metadata,
}
: {
accepted: false,
reference: null,
},
})
return normalized
}
export function buildCompanyData(
payload: CompanyFormValues,
tenantId: string
): Omit<Prisma.CompanyCreateInput, "provisioningCode"> {
const stateRegistrationType = payload.stateRegistrationType
? STATE_REGISTRATION_TYPE_TO_PRISMA[payload.stateRegistrationType as CompanyStateRegistrationTypeOption]
: null
const communicationChannels = mergeChannelsWithPrimary(payload)
const privacyPolicyMetadata = payload.privacyPolicy?.metadata ?? null
return {
tenantId,
name: payload.name.trim(),
slug: payload.slug.trim(),
isAvulso: payload.isAvulso ?? false,
contractedHoursPerMonth: payload.contractedHoursPerMonth ?? null,
cnpj: payload.cnpj ?? null,
domain: payload.domain ?? null,
phone: payload.phone ?? null,
description: payload.description ?? null,
address: payload.address ?? null,
legalName: payload.legalName ?? null,
tradeName: payload.tradeName ?? null,
stateRegistration: payload.stateRegistration ?? null,
stateRegistrationType,
primaryCnae: payload.primaryCnae ?? null,
timezone: payload.businessHours?.timezone ?? null,
businessHours: payload.businessHours ?? Prisma.JsonNull,
supportEmail: payload.supportEmail ?? null,
billingEmail: payload.billingEmail ?? null,
contactPreferences:
payload.contactPreferences || payload.supportEmail || payload.billingEmail
? ({
...payload.contactPreferences,
supportEmail: payload.supportEmail ?? null,
billingEmail: payload.billingEmail ?? null,
} satisfies Prisma.InputJsonValue)
: Prisma.JsonNull,
clientDomains: payload.clientDomains,
communicationChannels,
fiscalAddress: payload.fiscalAddress ?? Prisma.JsonNull,
hasBranches: payload.hasBranches ?? false,
regulatedEnvironments: payload.regulatedEnvironments,
privacyPolicyAccepted: payload.privacyPolicy?.accepted ?? false,
privacyPolicyReference: payload.privacyPolicy?.reference ?? null,
privacyPolicyMetadata: privacyPolicyMetadata
? (privacyPolicyMetadata as Prisma.InputJsonValue)
: Prisma.JsonNull,
contacts: payload.contacts,
locations: payload.locations,
contracts: payload.contracts,
sla: payload.sla ?? Prisma.JsonNull,
tags: payload.tags,
customFields: payload.customFields,
notes: payload.notes ?? null,
}
}
export function normalizeCompany(company: Company): NormalizedCompany {
const communicationChannels = normalizeChannels(
company.communicationChannels as CompanyCommunicationChannels | null | undefined
)
const normalizedName = (company.name ?? "").trim()
const normalizedSlug = ensureSlugValue(company.slug, normalizedName || company.name, company.id)
const base: CompanyFormValues = {
tenantId: company.tenantId,
name: normalizedName,
slug: normalizedSlug,
legalName: company.legalName,
tradeName: company.tradeName,
cnpj: company.cnpj,
stateRegistration: company.stateRegistration,
stateRegistrationType: company.stateRegistrationType
? STATE_REGISTRATION_TYPE_FROM_PRISMA[company.stateRegistrationType]
: undefined,
primaryCnae: company.primaryCnae,
description: company.description,
domain: company.domain,
phone: company.phone,
address: company.address,
contractedHoursPerMonth: company.contractedHoursPerMonth,
businessHours: (company.businessHours as CompanyFormValues["businessHours"]) ?? null,
communicationChannels,
supportEmail: company.supportEmail,
billingEmail: company.billingEmail,
contactPreferences: (company.contactPreferences as CompanyFormValues["contactPreferences"]) ?? undefined,
clientDomains: (company.clientDomains as string[] | null) ?? [],
fiscalAddress: (company.fiscalAddress as CompanyFormValues["fiscalAddress"]) ?? null,
hasBranches: Boolean(company.hasBranches),
regulatedEnvironments: (company.regulatedEnvironments as string[] | null) ?? [],
privacyPolicy: {
accepted: Boolean(company.privacyPolicyAccepted),
reference: company.privacyPolicyReference ?? null,
metadata: company.privacyPolicyMetadata
? (company.privacyPolicyMetadata as Record<string, unknown>)
: undefined,
},
contacts: (company.contacts as CompanyFormValues["contacts"]) ?? [],
locations: (company.locations as CompanyFormValues["locations"]) ?? [],
contracts: (company.contracts as CompanyFormValues["contracts"]) ?? [],
sla: (company.sla as CompanyFormValues["sla"]) ?? null,
tags: (company.tags as string[] | null) ?? [],
customFields: (company.customFields as CompanyFormValues["customFields"]) ?? [],
notes: company.notes ?? null,
isAvulso: Boolean(company.isAvulso),
}
const payload = companyFormSchema.parse({
...base,
communicationChannels: mergeChannelsWithPrimary(base, communicationChannels),
})
return {
...payload,
id: company.id,
provisioningCode: company.provisioningCode ?? null,
createdAt: company.createdAt.toISOString(),
updatedAt: company.updatedAt.toISOString(),
}
}
type RawCompanyRow = {
id: string
tenantId: string
name: string
slug: string
provisioningCode: string | null
isAvulso: number | boolean | null
contractedHoursPerMonth: number | null
cnpj: string | null
domain: string | null
phone: string | null
description: string | null
address: string | null
legalName: string | null
tradeName: string | null
stateRegistration: string | null
stateRegistrationType: string | null
primaryCnae: string | null
timezone: string | null
businessHours: string | null
supportEmail: string | null
billingEmail: string | null
contactPreferences: string | null
clientDomains: string | null
communicationChannels: string | null
fiscalAddress: string | null
hasBranches: number | null
regulatedEnvironments: string | null
privacyPolicyAccepted: number | null
privacyPolicyReference: string | null
privacyPolicyMetadata: string | null
contacts: string | null
locations: string | null
contracts: string | null
sla: string | null
tags: string | null
customFields: string | null
notes: string | null
createdAt: string
updatedAt: string
}
function parseJsonValue(value: string | null): Prisma.JsonValue | null {
if (value === null || value === undefined) return null
const trimmed = value.trim()
if (!trimmed || trimmed.toLowerCase() === "null") return null
try {
return JSON.parse(trimmed) as Prisma.JsonValue
} catch (error) {
console.warn("[company-service] Invalid JSON detected; coercing to null.", { value, error })
return null
}
}
function mapRawRowToCompany(row: RawCompanyRow): Company {
return {
id: row.id,
tenantId: row.tenantId,
name: row.name,
slug: row.slug,
provisioningCode: row.provisioningCode ?? "",
isAvulso: Boolean(row.isAvulso),
contractedHoursPerMonth: row.contractedHoursPerMonth,
cnpj: row.cnpj,
domain: row.domain,
phone: row.phone,
description: row.description,
address: row.address,
legalName: row.legalName,
tradeName: row.tradeName,
stateRegistration: row.stateRegistration,
stateRegistrationType: row.stateRegistrationType
? (row.stateRegistrationType as CompanyStateRegistrationType)
: null,
primaryCnae: row.primaryCnae,
timezone: row.timezone,
businessHours: parseJsonValue(row.businessHours),
supportEmail: row.supportEmail,
billingEmail: row.billingEmail,
contactPreferences: parseJsonValue(row.contactPreferences),
clientDomains: parseJsonValue(row.clientDomains),
communicationChannels: parseJsonValue(row.communicationChannels),
fiscalAddress: parseJsonValue(row.fiscalAddress),
hasBranches: Boolean(row.hasBranches),
regulatedEnvironments: parseJsonValue(row.regulatedEnvironments),
privacyPolicyAccepted: Boolean(row.privacyPolicyAccepted),
privacyPolicyReference: row.privacyPolicyReference,
privacyPolicyMetadata: parseJsonValue(row.privacyPolicyMetadata),
contacts: parseJsonValue(row.contacts),
locations: parseJsonValue(row.locations),
contracts: parseJsonValue(row.contracts),
sla: parseJsonValue(row.sla),
tags: parseJsonValue(row.tags),
customFields: parseJsonValue(row.customFields),
notes: row.notes,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}
}
const COMPANY_BASE_SELECT = Prisma.sql`
SELECT
id,
tenantId,
name,
slug,
provisioningCode,
isAvulso,
contractedHoursPerMonth,
cnpj,
domain,
phone,
description,
address,
legalName,
tradeName,
stateRegistration,
stateRegistrationType,
primaryCnae,
timezone,
CAST(businessHours AS TEXT) AS businessHours,
supportEmail,
billingEmail,
CAST(contactPreferences AS TEXT) AS contactPreferences,
CAST(clientDomains AS TEXT) AS clientDomains,
CAST(communicationChannels AS TEXT) AS communicationChannels,
CAST(fiscalAddress AS TEXT) AS fiscalAddress,
hasBranches,
CAST(regulatedEnvironments AS TEXT) AS regulatedEnvironments,
privacyPolicyAccepted,
privacyPolicyReference,
CAST(privacyPolicyMetadata AS TEXT) AS privacyPolicyMetadata,
CAST(contacts AS TEXT) AS contacts,
CAST(locations AS TEXT) AS locations,
CAST(contracts AS TEXT) AS contracts,
CAST(sla AS TEXT) AS sla,
CAST(tags AS TEXT) AS tags,
CAST(customFields AS TEXT) AS customFields,
notes,
createdAt,
updatedAt
FROM "Company"
`
export async function fetchCompaniesByTenant(tenantId: string): Promise<Company[]> {
const rows = await prisma.$queryRaw<RawCompanyRow[]>(Prisma.sql`
${COMPANY_BASE_SELECT}
WHERE tenantId = ${tenantId}
ORDER BY name ASC
`)
return rows.map(mapRawRowToCompany)
}
export async function fetchCompanyById(id: string): Promise<Company | null> {
const rows = await prisma.$queryRaw<RawCompanyRow[]>(Prisma.sql`
${COMPANY_BASE_SELECT}
WHERE id = ${id}
LIMIT 1
`)
const row = rows[0]
return row ? mapRawRowToCompany(row) : null
}

View file

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest"
import { normalizeMachineRemoteAccess } from "@/components/admin/machines/admin-machines-overview"
describe("normalizeMachineRemoteAccess", () => {
it("returns null when value is empty", () => {
expect(normalizeMachineRemoteAccess(undefined)).toBeNull()
expect(normalizeMachineRemoteAccess(" ")).toBeNull()
})
it("parses plain identifier strings", () => {
const result = normalizeMachineRemoteAccess("PC-001")
expect(result).toEqual({
provider: null,
identifier: "PC-001",
url: null,
notes: null,
lastVerifiedAt: null,
metadata: null,
})
})
it("detects URLs in string input", () => {
const result = normalizeMachineRemoteAccess("https://remote.example.com/session/123")
expect(result).toEqual({
provider: null,
identifier: null,
url: "https://remote.example.com/session/123",
notes: null,
lastVerifiedAt: null,
metadata: null,
})
})
it("normalizes object payload with aliases", () => {
const timestamp = 1_701_234_567_890
const result = normalizeMachineRemoteAccess({
provider: "AnyDesk",
code: "123-456-789",
remoteUrl: "https://anydesk.com/session/123",
note: "Suporte avançado",
verifiedAt: timestamp,
extraTag: "vip",
})
expect(result).toEqual({
provider: "AnyDesk",
identifier: "123-456-789",
url: "https://anydesk.com/session/123",
notes: "Suporte avançado",
lastVerifiedAt: timestamp,
metadata: {
provider: "AnyDesk",
code: "123-456-789",
remoteUrl: "https://anydesk.com/session/123",
note: "Suporte avançado",
verifiedAt: timestamp,
extraTag: "vip",
},
})
})
})