diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 6fecd02..5d6b9d2 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; diff --git a/convex/companies.ts b/convex/companies.ts index 7cf9c03..16266e4 100644 --- a/convex/companies.ts +++ b/convex/companies.ts @@ -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, }) diff --git a/convex/schema.ts b/convex/schema.ts index c300326..2c7fedb 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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"]) diff --git a/docs/admin/companies-expanded-profile.md b/docs/admin/companies-expanded-profile.md new file mode 100644 index 0000000..0175267 --- /dev/null +++ b/docs/admin/companies-expanded-profile.md @@ -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. diff --git a/package.json b/package.json index e9adccc..b1ff552 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ca368b..f7053f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/prisma/migrations/20251022120000_extend_company_profile/migration.sql b/prisma/migrations/20251022120000_extend_company_profile/migration.sql new file mode 100644 index 0000000..bf91fe5 --- /dev/null +++ b/prisma/migrations/20251022120000_extend_company_profile/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bb632ba..9ae66af 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,16 +40,22 @@ enum TicketChannel { MANUAL } -enum CommentVisibility { - PUBLIC - INTERNAL -} - -model Team { - id String @id @default(cuid()) - tenantId String - name String - description String? +enum CommentVisibility { + PUBLIC + INTERNAL +} + +enum CompanyStateRegistrationType { + STANDARD + EXEMPT + SIMPLES +} + +model Team { + id String @id @default(cuid()) + tenantId String + name String + description String? members TeamMember[] queues Queue[] createdAt DateTime @default(now()) @@ -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 diff --git a/scripts/apply-company-migration.mjs b/scripts/apply-company-migration.mjs new file mode 100644 index 0000000..fc68514 --- /dev/null +++ b/scripts/apply-company-migration.mjs @@ -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() + }) diff --git a/src/app/admin/clients/page.tsx b/src/app/admin/clients/page.tsx index 4a44752..486bdbb 100644 --- a/src/app/admin/clients/page.tsx +++ b/src/app/admin/clients/page.tsx @@ -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() - for (const sessionRow of sessions) { - if (!sessionByUserId.has(sessionRow.userId)) { - sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt) - } - } - - const authByEmail = new Map() - 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 ( - } - > -
- -
-
- ) +export default function AdminClientsRedirect() { + redirect("/admin/users") } diff --git a/src/app/admin/companies/page.tsx b/src/app/admin/companies/page.tsx index ceaaf8b..d8cca43 100644 --- a/src/app/admin/companies/page.tsx +++ b/src/app/admin/companies/page.tsx @@ -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 (
- +
) diff --git a/src/app/admin/machines/page.tsx b/src/app/admin/machines/page.tsx index 4563373..365c04a 100644 --- a/src/app/admin/machines/page.tsx +++ b/src/app/admin/machines/page.tsx @@ -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 (
- +
) diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 0000000..f9facbc --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -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() + for (const sessionRow of sessions) { + if (!sessionByUserId.has(sessionRow.userId)) { + sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt) + } + } + + const authByEmail = new Map() + 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 ( + + } + > +
+ +
+
+ ) +} diff --git a/src/app/api/admin/clients/route.ts b/src/app/api/admin/clients/route.ts index 5156827..ff76cca 100644 --- a/src/app/api/admin/clients/route.ts +++ b/src/app/api/admin/clients/route.ts @@ -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() - for (const sessionRow of sessions) { - if (!sessionByUserId.has(sessionRow.userId)) { - sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt) - } - } - - const authByEmail = new Map() - 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" diff --git a/src/app/api/admin/companies/[id]/route.ts b/src/app/api/admin/companies/[id]/route.ts index b649cf7..6c230f5 100644 --- a/src/app/api/admin/companies/[id]/route.ts +++ b/src/app/api/admin/companies/[id]/route.ts @@ -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): Record { + const merged: Record = { ...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 = {} - 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 + 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 }) diff --git a/src/app/api/admin/companies/route.ts b/src/app/api/admin/companies/route.ts index e4286a8..7839c5a 100644 --- a/src/app/api/admin/companies/route.ts +++ b/src/app/api/admin/companies/route.ts @@ -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 }) } } diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 40f27fa..8f9701c 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -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 = ["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() + for (const sessionRow of sessions) { + if (!sessionByUserId.has(sessionRow.userId)) { + sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt) + } + } + + const authByEmail = new Map() + 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) }) } diff --git a/src/components/admin/clients/admin-clients-manager.tsx b/src/components/admin/clients/admin-clients-manager.tsx deleted file mode 100644 index a5941a8..0000000 --- a/src/components/admin/clients/admin-clients-manager.tsx +++ /dev/null @@ -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 = { - 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("all") - const [rowSelection, setRowSelection] = useState({}) - const [sorting, setSorting] = useState([{ id: "name", desc: false }]) - const [isPending, startTransition] = useTransition() - const [deleteDialogIds, setDeleteDialogIds] = useState([]) - - const companies = useMemo(() => { - const entries = new Map() - 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[]>( - () => [ - { - id: "select", - header: ({ table }) => ( -
- table.toggleAllPageRowsSelected(!!value)} - aria-label="Selecionar todos" - /> -
- ), - cell: ({ row }) => ( -
- row.toggleSelected(!!value)} - aria-label="Selecionar linha" - /> -
- ), - 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 ( -
- - {initials || client.email.charAt(0).toUpperCase()} - -
-

{client.name}

-

{client.email}

-
-
- ) - }, - }, - { - accessorKey: "role", - header: "Perfil", - cell: ({ row }) => { - const role = row.original.role - const variant = role === "MANAGER" ? "default" : "secondary" - return {ROLE_LABEL[role]} - }, - }, - { - accessorKey: "companyName", - header: "Empresa", - cell: ({ row }) => - row.original.companyName ? ( - - {row.original.companyName} - - ) : ( - Sem empresa - ), - }, - { - accessorKey: "createdAt", - header: "Cadastrado em", - cell: ({ row }) => ( - {formatDate(row.original.createdAt)} - ), - }, - { - id: "lastSeenAt", - header: "Último acesso", - cell: ({ row }) => ( - {formatLastSeen(row.original.lastSeenAt)} - ), - }, - { - id: "actions", - header: "", - enableSorting: false, - cell: ({ row }) => ( - - ), - }, - ], - [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 ( - <> -
-
-
- - {clients.length} cliente{clients.length === 1 ? "" : "s"} -
-
-
- setSearch(event.target.value)} - className="h-9 w-full md:w-72" - /> - -
-
- - - -
-
-
- -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder ? null : header.column.columnDef.header instanceof Function - ? header.column.columnDef.header(header.getContext()) - : header.column.columnDef.header} - - ))} - - ))} - - - {table.getRowModel().rows.length === 0 ? ( - - - Nenhum cliente encontrado para os filtros selecionados. - - - ) : ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - )} - -
-
- - `${selected} de ${total} selecionados`} - /> -
- - 0} - onOpenChange={(open) => { - if (!open && !isPending) { - setDeleteDialogIds([]) - } - }} - > - - - {dialogTitle} - {dialogDescription} - - {deleteTargets.length > 0 ? ( -
-

- Confirme a exclusão de {isBulkDelete ? `${deleteTargets.length} clientes selecionados` : "um cliente"}. O acesso ao portal será revogado imediatamente. -

-
    - {previewTargets.map((target) => ( -
  • - {target.name} - — {target.email} -
  • - ))} - {remainingCount > 0 ? ( -
  • + {remainingCount} outro{remainingCount === 1 ? "" : "s"}
  • - ) : null} -
-
- ) : null} - - - - -
-
- - ) -} diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx index 2aa64b6..36fd1f2 100644 --- a/src/components/admin/companies/admin-companies-manager.tsx +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -1,45 +1,66 @@ "use client" -import { useCallback, useEffect, useMemo, useRef, useState, useTransition, useId } from "react" -import Link from "next/link" -import { formatDistanceToNow } from "date-fns" -import { ptBR } from "date-fns/locale" -import { useQuery } from "convex/react" +import { useCallback, useEffect, useMemo, useState, useTransition } from "react" +import { Controller, FormProvider, useFieldArray, useForm, type UseFormReturn } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" import { IconAlertTriangle, IconBuildingSkyscraper, + IconCheck, + IconClipboard, IconClock, IconCopy, - IconDotsVertical, - IconCheck, + IconFilter, + IconList, + IconMapPin, IconDeviceDesktop, IconPencil, + IconPlus, IconRefresh, IconSearch, - IconShieldCheck, - IconSwitchHorizontal, IconTrash, + IconUsers, } from "@tabler/icons-react" import { toast } from "sonner" +import { + COMPANY_CONTACT_PREFERENCES, + COMPANY_CONTACT_ROLES, + COMPANY_CONTRACT_SCOPES, + COMPANY_CONTRACT_TYPES, + COMPANY_LOCATION_TYPES, + COMPANY_STATE_REGISTRATION_TYPES, + SLA_SEVERITY_LEVELS, + companyFormSchema, + type CompanyBusinessHours, + type CompanyContract, + type CompanyFormValues, +} from "@/lib/schemas/company" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import type { NormalizedCompany } from "@/server/company-service" import { cn } from "@/lib/utils" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { PhoneInput, formatPhoneDisplay } from "@/components/ui/phone-input" -import { Label } from "@/components/ui/label" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Checkbox } from "@/components/ui/checkbox" import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Table, TableBody, @@ -48,1112 +69,2114 @@ import { TableHeader, TableRow, } from "@/components/ui/table" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import { Progress } from "@/components/ui/progress" -import { useIsMobile } from "@/hooks/use-mobile" +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination" +import { Textarea } from "@/components/ui/textarea" +import { TimePicker } from "@/components/ui/time-picker" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { useQuery } from "convex/react" import { api } from "@/convex/_generated/api" +import { MultiValueInput } from "@/components/ui/multi-value-input" +import { AdminMachinesOverview } from "@/components/admin/machines/admin-machines-overview" -type Company = { - id: string - tenantId: string - name: string - slug: string - provisioningCode: string | null - isAvulso: boolean - contractedHoursPerMonth?: number | null - cnpj: string | null - domain: string | null - phone: string | null - description: string | null - address: string | null +type LastAlertInfo = { createdAt: number; usagePct: number; threshold: number } | null + +type Props = { + initialCompanies: NormalizedCompany[] + tenantId?: string | null } -type MachineSummary = { - id: string - tenantId: string - companyId: string | null - companySlug?: string | null - hostname: string - status: string | null - lastHeartbeatAt: number | null - isActive?: boolean | null - authEmail?: string | null - osName?: string | null - osVersion?: string | null - architecture?: string | null +type ViewMode = "table" | "board" + +type EditorState = + | { mode: "create" } + | { mode: "edit"; company: NormalizedCompany } + +const BOARD_COLUMNS = [ + { id: "monthly", title: "Mensalistas", description: "Contratos recorrentes ou planos mensais." }, + { id: "time_bank", title: "Banco de horas", description: "Clientes com consumo controlado por horas." }, + { id: "project", title: "Projetos", description: "Clientes com projetos fechados." }, + { id: "per_ticket", title: "Por chamado", description: "Pagamento por ticket/chamado." }, + { id: "avulso", title: "Avulsos", description: "Sem contrato ou marcação como avulso." }, + { id: "other", title: "Outros", description: "Contratos customizados ou sem categoria definida." }, +] as const + +const DAY_OPTIONS = [ + { value: "mon", label: "Seg" }, + { value: "tue", label: "Ter" }, + { value: "wed", label: "Qua" }, + { value: "thu", label: "Qui" }, + { value: "fri", label: "Sex" }, + { value: "sat", label: "Sáb" }, + { value: "sun", label: "Dom" }, +] as const + +const EMPTY_SELECT_VALUE = "__empty__" + +const SLA_LEVEL_LABEL: Record<(typeof SLA_SEVERITY_LEVELS)[number], string> = { + P1: "P1", + P2: "P2", + P3: "P3", + P4: "P4", } -function formatCnpjInput(value: string): string { - const digits = value.replace(/\D/g, "").slice(0, 14) - if (digits.length <= 2) return digits - if (digits.length <= 5) return `${digits.slice(0, 2)}.${digits.slice(2)}` - if (digits.length <= 8) return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5)}` - if (digits.length <= 12) { - return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5, 8)}/${digits.slice(8)}` +function createId(prefix: string) { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${prefix}-${crypto.randomUUID()}` } - return `${digits.slice(0, 2)}.${digits.slice(2, 5)}.${digits.slice(5, 8)}/${digits.slice(8, 12)}-${digits.slice(12)}` + return `${prefix}-${Math.random().toString(36).slice(2, 10)}` } +function toFormValues(company: NormalizedCompany): CompanyFormValues { + const { + id: _id, + provisioningCode: _code, + createdAt: _createdAt, + updatedAt: _updatedAt, + ...rest + } = company + void _id + void _code + void _createdAt + void _updatedAt + return rest +} -export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) { - const [companies, setCompanies] = useState(() => initialCompanies ?? []) - const [isPending, startTransition] = useTransition() - const [form, setForm] = useState>({}) - const [editingId, setEditingId] = useState(null) - const [lastAlerts, setLastAlerts] = useState>({}) - const [deleteId, setDeleteId] = useState(null) - const [isDeleting, setIsDeleting] = useState(false) - const [searchTerm, setSearchTerm] = useState("") - const [machinesDialog, setMachinesDialog] = useState<{ companyId: string; name: string } | null>(null) - const isMobile = useIsMobile() +function emptyCompany(tenantId: string): CompanyFormValues { + return { + tenantId, + name: "", + slug: "", + legalName: null, + tradeName: null, + cnpj: null, + stateRegistration: null, + stateRegistrationType: undefined, + primaryCnae: null, + description: null, + domain: null, + phone: null, + address: null, + contractedHoursPerMonth: null, + businessHours: { + mode: "business", + timezone: "America/Sao_Paulo", + periods: [ + { + days: ["mon", "tue", "wed", "thu", "fri"], + start: "09:00", + end: "18:00", + }, + ], + }, + communicationChannels: { + supportEmails: [], + billingEmails: [], + whatsappNumbers: [], + phones: [], + portals: [], + }, + supportEmail: null, + billingEmail: null, + contactPreferences: { + defaultChannel: null, + escalationNotes: null, + }, + clientDomains: [], + fiscalAddress: null, + hasBranches: false, + regulatedEnvironments: [], + privacyPolicy: { + accepted: false, + reference: null, + }, + contacts: [], + locations: [], + contracts: [], + sla: { + 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", + }, + ], + }, + }, + tags: [], + customFields: [], + notes: null, + isAvulso: false, + } +} - const nameId = useId() - const slugId = useId() - const descriptionId = useId() - const cnpjId = useId() - const domainId = useId() - const phoneId = useId() - const addressId = useId() - const hoursId = useId() - - const machinesQuery = useQuery(api.machines.listByTenant, { includeMetadata: false }) as MachineSummary[] | undefined - const machinesByCompanyId = useMemo(() => { - const map = new Map() - ;(machinesQuery ?? []).forEach((machine) => { - const keys = [ - machine.companyId ?? undefined, - machine.companySlug ?? undefined, - ].filter((key): key is string => Boolean(key)) - if (keys.length === 0) { - return +function sanitisePayload(values: CompanyFormValues) { + // Remove helper-only fields that should not be persisted when blank + const normalizedPrivacy = values.privacyPolicy + ? { + ...values.privacyPolicy, + reference: + typeof values.privacyPolicy.reference === "string" && values.privacyPolicy.reference.trim().length === 0 + ? null + : values.privacyPolicy.reference ?? null, } - keys.forEach((key) => { - const list = map.get(key) - if (list) { - list.push(machine) - } else { - map.set(key, [machine]) - } - }) + : undefined + return { + ...values, + privacyPolicy: normalizedPrivacy, + contactPreferences: + values.contactPreferences && values.contactPreferences.defaultChannel + ? values.contactPreferences + : values.contactPreferences?.escalationNotes + ? values.contactPreferences + : undefined, + } +} + +function inferBoardBucket(company: NormalizedCompany) { + if (company.isAvulso) return "avulso" + const contractTypes = new Set(company.contracts.map((contract) => contract.contractType)) + for (const column of BOARD_COLUMNS) { + if (column.id === "avulso" || column.id === "other") continue + if (contractTypes.has(column.id as CompanyContract["contractType"])) return column.id + } + if (contractTypes.size === 0) return "other" + return contractTypes.values().next().value ?? "other" +} + +function formatCurrency(value: number | null | undefined) { + if (value === null || value === undefined) return "—" + return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(value) +} + +function formatDate(value: string | null | undefined) { + if (!value) return "—" + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleDateString("pt-BR") +} + +function FieldError({ error }: { error?: string }) { + if (!error) return null + return

{error}

+} + +export function AdminCompaniesManager({ initialCompanies, tenantId }: Props) { + const [companies, setCompanies] = useState(() => initialCompanies) + const [view, setView] = useState("table") + const [search, setSearch] = useState("") + const [contractFilter, setContractFilter] = useState("all") + const [regulatedFilter, setRegulatedFilter] = useState("all") + const [isRefreshing, startRefresh] = useTransition() + const [editor, setEditor] = useState(null) + const [isDeleting, setIsDeleting] = useState(null) + const [alertsBySlug, setAlertsBySlug] = useState>({}) + + const effectiveTenantId = tenantId ?? companies[0]?.tenantId ?? DEFAULT_TENANT_ID + + // Máquinas por empresa para contagem rápida + const machines = useQuery(api.machines.listByTenant, { + tenantId: effectiveTenantId, + includeMetadata: false, + }) as Array> | undefined + + const machineCountsBySlug = useMemo(() => { + const map: Record = {} + ;(machines ?? []).forEach((m) => { + const slug = (m as any)?.companySlug as string | undefined + if (!slug) return + map[slug] = (map[slug] ?? 0) + 1 }) return map - }, [machinesQuery]) + }, [machines]) - const getMachinesForCompany = useCallback( - (company: Company | null | undefined) => { - if (!company) return [] - const keys = [company.id, company.slug].filter(Boolean) - for (const key of keys) { - const list = machinesByCompanyId.get(key) - if (list && list.length > 0) { - return list - } + const filtered = useMemo(() => { + const term = search.trim().toLowerCase() + return companies.filter((company) => { + if (term) { + const matchesTerm = + company.name.toLowerCase().includes(term) || + company.slug.toLowerCase().includes(term) || + (company.domain?.toLowerCase().includes(term) ?? false) || + company.contacts.some((contact) => contact.fullName.toLowerCase().includes(term)) + if (!matchesTerm) return false } - return [] - }, - [machinesByCompanyId] - ) - - const editingCompany = useMemo( - () => (editingId ? companies.find((company) => company.id === editingId) ?? null : null), - [companies, editingId] - ) - - const editingCompanyMachines = useMemo( - () => getMachinesForCompany(editingCompany), - [getMachinesForCompany, editingCompany] - ) - - const machinesDialogCompany = useMemo( - () => (machinesDialog ? companies.find((company) => company.id === machinesDialog.companyId) ?? null : null), - [companies, machinesDialog] - ) - - const machinesDialogList = useMemo( - () => getMachinesForCompany(machinesDialogCompany), - [getMachinesForCompany, machinesDialogCompany] - ) - - const resetForm = () => setForm({}) - - async function refresh() { - const r = await fetch("/api/admin/companies", { credentials: "include" }) - const json = (await r.json()) as { companies?: Company[] } - const nextCompanies = Array.isArray(json.companies) ? json.companies : [] - setCompanies(nextCompanies) - void loadLastAlerts(nextCompanies) - } - - function handleEdit(c: Company) { - setEditingId(c.id) - setForm({ - ...c, - contractedHoursPerMonth: c.contractedHoursPerMonth ?? undefined, - cnpj: c.cnpj ? formatCnpjInput(c.cnpj) : null, + if (contractFilter !== "all") { + const types = new Set(company.contracts.map((contract) => contract.contractType)) + if (!types.has(contractFilter as CompanyContract["contractType"])) return false + } + if (regulatedFilter !== "all") { + if (!company.regulatedEnvironments.includes(regulatedFilter)) return false + } + return true }) - } + }, [companies, contractFilter, regulatedFilter, search]) - const loadLastAlerts = useCallback(async (list: Company[] = companies) => { - if (!list || list.length === 0) return - const params = new URLSearchParams({ slugs: list.map((c) => c.slug).join(",") }) - try { - const r = await fetch(`/api/admin/companies/last-alerts?${params.toString()}`, { credentials: "include" }) - const json = (await r.json()) as { items: Record } - setLastAlerts(json.items ?? {}) - } catch { - // ignore + const contractOptions = useMemo(() => { + const entries = new Set() + companies.forEach((company) => { + company.contracts.forEach((contract) => entries.add(contract.contractType)) + }) + return Array.from(entries.values()).sort() + }, [companies]) + + const regulatedOptions = useMemo(() => { + const entries = new Set() + companies.forEach((company) => + company.regulatedEnvironments.forEach((item) => entries.add(item)), + ) + return Array.from(entries.values()).sort() + }, [companies]) + + useEffect(() => { + const slugs = companies.map((company) => company.slug).filter(Boolean) + if (slugs.length === 0) { + setAlertsBySlug({}) + return + } + let active = true + void fetch(`/api/admin/companies/last-alerts?${new URLSearchParams({ slugs: slugs.join(",") })}`, { + credentials: "include", + }) + .then(async (response) => { + if (!response.ok) return + const json = (await response.json()) as { items?: Record } + if (active && json?.items) { + setAlertsBySlug(json.items) + } + }) + .catch((error) => { + console.warn("Failed to load last alerts", error) + }) + return () => { + active = false } }, [companies]) - useEffect(() => { void loadLastAlerts(companies) }, [loadLastAlerts, companies]) - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - const contractedHours = - typeof form.contractedHoursPerMonth === "number" && Number.isFinite(form.contractedHoursPerMonth) - ? form.contractedHoursPerMonth - : null - - const cnpjDigits = form.cnpj ? form.cnpj.replace(/\D/g, "") : "" - - const payload = { - name: form.name?.trim(), - slug: form.slug?.trim(), - isAvulso: Boolean(form.isAvulso ?? false), - cnpj: cnpjDigits ? cnpjDigits : null, - domain: form.domain?.trim() || null, - phone: form.phone?.trim() || null, - description: form.description?.trim() || null, - address: form.address?.trim() || null, - contractedHoursPerMonth: contractedHours, - } - if (!payload.name || !payload.slug) { - toast.error("Informe nome e apelido válidos") - return - } - startTransition(async () => { - toast.loading(editingId ? "Atualizando empresa..." : "Criando empresa...", { id: "companies" }) + const refresh = useCallback(() => { + startRefresh(async () => { try { - if (editingId) { - const r = await fetch(`/api/admin/companies/${editingId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - credentials: "include", - }) - const data = (await r.json().catch(() => ({}))) as { error?: string } - if (!r.ok) throw new Error(data?.error ?? "Falha ao atualizar empresa") - } else { - const r = await fetch(`/api/admin/companies`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - credentials: "include", - }) - const data = (await r.json().catch(() => ({}))) as { error?: string } - if (!r.ok) throw new Error(data?.error ?? "Falha ao criar empresa") - } - await refresh() - resetForm() - setEditingId(null) - toast.success(editingId ? "Empresa atualizada" : "Empresa criada", { id: "companies" }) + const response = await fetch("/api/admin/companies", { credentials: "include" }) + if (!response.ok) throw new Error("Falha ao atualizar a listagem de empresas.") + const json = (await response.json()) as { companies?: NormalizedCompany[] } + const nextCompanies = json.companies ?? [] + setCompanies(nextCompanies) + toast.success("Empresas atualizadas.") } catch (error) { - const message = error instanceof Error ? error.message : "Não foi possível salvar" - toast.error(message, { id: "companies" }) + const message = + error instanceof Error ? error.message : "Não foi possível atualizar as empresas." + toast.error(message) } }) - } + }, []) - async function toggleAvulso(c: Company) { - startTransition(async () => { - try { - const r = await fetch(`/api/admin/companies/${c.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ isAvulso: !c.isAvulso }), - credentials: "include", - }) - if (!r.ok) throw new Error("toggle_failed") - await refresh() - toast.success(`Cliente ${!c.isAvulso ? "marcado como avulso" : "marcado como recorrente"}`) - } catch { - toast.error("Não foi possível atualizar o cliente avulso") - } - }) - } + const openCreate = useCallback(() => { + setEditor({ mode: "create" }) + }, []) - async function handleDeleteConfirmed() { - if (!deleteId) return - setIsDeleting(true) + const openEdit = useCallback((company: NormalizedCompany) => { + setEditor({ mode: "edit", company }) + }, []) + + const closeEditor = useCallback(() => { + setEditor(null) + }, []) + + const confirmDelete = useCallback((company: NormalizedCompany) => { + setIsDeleting(company) + }, []) + + const cancelDelete = useCallback(() => setIsDeleting(null), []) + + const handleDelete = useCallback(async () => { + if (!isDeleting) return try { - const response = await fetch(`/api/admin/companies/${deleteId}`, { + const response = await fetch(`/api/admin/companies/${isDeleting.id}`, { method: "DELETE", credentials: "include", }) - const data = (await response.json().catch(() => ({}))) as { - error?: string - detachedUsers?: number - detachedTickets?: number - } if (!response.ok) { - throw new Error(data?.error ?? "Falha ao excluir empresa") + const payload = await response.json().catch(() => null) + throw new Error(payload?.error ?? "Não foi possível remover a empresa.") } - const detachedUsers = data?.detachedUsers ?? 0 - const detachedTickets = data?.detachedTickets ?? 0 - const details: string[] = [] - if (detachedUsers > 0) { - details.push(`${detachedUsers} usuário${detachedUsers > 1 ? "s" : ""} desvinculado${detachedUsers > 1 ? "s" : ""}`) - } - if (detachedTickets > 0) { - details.push(`${detachedTickets} ticket${detachedTickets > 1 ? "s" : ""} atualizado${detachedTickets > 1 ? "s" : ""}`) - } - const successMessage = details.length > 0 ? `Empresa removida (${details.join(", ")})` : "Empresa removida" - toast.success(successMessage) - if (editingId === deleteId) { - resetForm() - setEditingId(null) - } - await refresh() + setCompanies((prev) => prev.filter((company) => company.id !== isDeleting.id)) + toast.success(`Empresa “${isDeleting.name}” removida.`) + setIsDeleting(null) } catch (error) { - const message = error instanceof Error ? error.message : "Não foi possível remover a empresa" + const message = + error instanceof Error ? error.message : "Não foi possível remover a empresa." toast.error(message) - } finally { - setIsDeleting(false) - setDeleteId(null) } - } + }, [isDeleting]) - const editingCompanyName = useMemo(() => companies.find((company) => company.id === editingId)?.name ?? null, [companies, editingId]) - const deleteTarget = useMemo(() => companies.find((company) => company.id === deleteId) ?? null, [companies, deleteId]) - const filteredCompanies = useMemo(() => { - const query = searchTerm.trim().toLowerCase() - if (!query) return companies - return companies.filter((company) => { - return [ - company.name, - company.slug, - company.domain, - company.cnpj, - company.phone, - company.description, - ].some((value) => value?.toLowerCase().includes(query)) + const boardGroups = useMemo(() => { + const map = new Map() + filtered.forEach((company) => { + const bucket = inferBoardBucket(company) + const bucketId = BOARD_COLUMNS.some((column) => column.id === bucket) ? bucket : "other" + const list = map.get(bucketId) ?? [] + list.push(company) + map.set(bucketId, list) }) - }, [companies, searchTerm]) - const hasCompanies = filteredCompanies.length > 0 + return map + }, [filtered]) - const emptyContent = ( - - - - - - Nenhuma empresa encontrada - - {searchTerm - ? "Nenhum cadastro corresponde à busca realizada. Ajuste os termos e tente novamente." - : "Cadastre uma empresa para começar a gerenciar clientes por aqui."} - - - {searchTerm ? ( - - - - ) : null} - - ) - return ( -
- - - - {editingId ? `Editar empresa${editingCompanyName ? ` · ${editingCompanyName}` : ""}` : "Nova empresa"} - - {editingId - ? "Atualize os dados cadastrais e as informações de faturamento do cliente selecionado." - : "Cadastre um cliente/empresa e defina se é avulso."} - - - -
-
- - setForm((p) => ({ ...p, name: e.target.value }))} - placeholder="Nome da empresa" - /> + const renderBoardCard = (company: NormalizedCompany) => { + const alert = alertsBySlug[company.slug] + const firstContract = company.contracts[0] + return ( + + +
+
+ {company.name} + + {company.slug} +
-
- - setForm((p) => ({ ...p, slug: e.target.value }))} - placeholder="empresa-exemplo" - /> -
-
- - setForm((p) => ({ ...p, description: e.target.value }))} - placeholder="Resumo, segmento ou observações internas" - /> -
-
- - setForm((p) => ({ ...p, cnpj: formatCnpjInput(e.target.value) }))} - placeholder="00.000.000/0000-00" - /> -
-
- - setForm((p) => ({ ...p, domain: e.target.value }))} - placeholder="empresa.com.br" - /> -
-
- - setForm((p) => ({ ...p, phone: value || null }))} - /> -
-
- - setForm((p) => ({ ...p, address: e.target.value }))} - placeholder="Rua, número, bairro, cidade/UF" - /> -
-
- - setForm((p) => ({ ...p, contractedHoursPerMonth: e.target.value === "" ? undefined : Number(e.target.value) }))} - placeholder="Ex.: 40" - /> -
-
- setForm((p) => ({ ...p, isAvulso: Boolean(v) }))} - id="is-avulso" - /> - -
-
- - {editingId ? ( - - ) : null} -
- - - - - {editingId ? ( - - -
-
- Máquinas desta empresa - - Status e último sinal das máquinas vinculadas. - -
- - {editingCompanyMachines.length} máquina{editingCompanyMachines.length === 1 ? "" : "s"} - -
-
- - {editingCompanyMachines.length > 0 ? ( -
- {editingCompanyMachines.map((machine) => { - const variant = getMachineStatusVariant(machine.status) - return ( -
-
-

{machine.hostname}

- - {variant.label} - -
-

- Último sinal: {formatRelativeTimestamp(machine.lastHeartbeatAt) ?? "nunca"} -

-
- ) - })} -
- ) : ( -

Nenhuma máquina vinculada a esta empresa.

- )} -
-
- ) : null} - - - -
-
- Empresas cadastradas - Gerencie empresas e o status de cliente avulso. -
- - {filteredCompanies.length} {filteredCompanies.length === 1 ? "empresa" : "empresas"} + +
+
+ {company.isAvulso ? Avulso : null} + {company.regulatedEnvironments.map((env) => ( + + {env.toUpperCase()} -
-
-
- - setSearchTerm(event.target.value)} - placeholder="Buscar por nome, apelido ou domínio..." - className="h-9 pl-9" - /> -
- -
-
- - {isMobile ? ( -
- {hasCompanies ? ( - filteredCompanies.map((company) => { - const companyMachines = getMachinesForCompany(company) - const formattedPhone = formatPhoneDisplay(company.phone) - const alertInfo = lastAlerts[company.slug] ?? null - const usagePct = alertInfo?.usagePct ?? 0 - const threshold = alertInfo?.threshold ?? 0 - const isThresholdExceeded = Boolean(alertInfo) && usagePct >= threshold - const lastAlertDistance = alertInfo - ? formatDistanceToNow(alertInfo.createdAt, { addSuffix: true, locale: ptBR }) - : null - return ( -
-
-
- - - {getInitials(company.name)} - - -
-

{company.name}

-

{company.slug}

- {company.domain ? ( -

{company.domain}

- ) : null} - {company.description ? ( -

{company.description}

- ) : null} -
-
- - - - - - handleEdit(company)}> - - Editar empresa - - setMachinesDialog({ companyId: company.id, name: company.name })}> - - Ver máquinas - - void toggleAvulso(company)}> - - {company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"} - - - setDeleteId(company.id)} - > - - Remover empresa - - - -
-
- {formattedPhone ? {formattedPhone} : null} - {company.address ? {company.address} : null} -
- {companyMachines.length > 0 ? ( -
-
- - {companyMachines.length} máquina{companyMachines.length > 1 ? "s" : ""} - - {Object.entries(summarizeStatus(companyMachines)).map(([status, count]) => ( - - {getMachineStatusVariant(status).label}: {count} - - ))} -
-
- {companyMachines.slice(0, 3).map((machine) => { - const variant = getMachineStatusVariant(machine.isActive === false ? "deactivated" : machine.status) - return ( - - - - {machine.hostname} - - - -

{machine.hostname}

-

- Último sinal: {formatRelativeTimestamp(machine.lastHeartbeatAt) ?? "nunca"} -

-
-
- ) - })} - {companyMachines.length > 3 ? ( - - +{companyMachines.length - 3} {companyMachines.length - 3 === 1 ? "outra" : "outras"} - - ) : null} -
-
- ) : ( -

Nenhuma máquina vinculada.

- )} -
- -
-
- - {company.isAvulso ? "Cliente avulso" : "Recorrente"} - - -
-
- {alertInfo ? ( - <> - - {lastAlertDistance} - - · Consumo {Math.round(usagePct)}% / limite {threshold}% - - - ) : ( - <> - - Sem alertas recentes - - )} -
-
- ) - }) - ) : ( - emptyContent - )} + ))} +
+ + +
+

+ Contratos +

+ {firstContract ? ( +
+

{firstContract.contractType}

+

+ {firstContract.scope.length + ? firstContract.scope.join(", ") + : "Escopo indefinido"} +

) : ( -
- - - - Empresa - Provisionamento - Cliente avulso - Uso e alertas - Ações - - - - {hasCompanies ? ( - filteredCompanies.map((company) => { - const alertInfo = lastAlerts[company.slug] ?? null - const usagePct = alertInfo?.usagePct ?? 0 - const threshold = alertInfo?.threshold ?? 0 - const isThresholdExceeded = Boolean(alertInfo) && usagePct >= threshold - const lastAlertDistance = alertInfo - ? formatDistanceToNow(alertInfo.createdAt, { addSuffix: true, locale: ptBR }) - : null - const formattedPhone = formatPhoneDisplay(company.phone) - const companyMachines = getMachinesForCompany(company) - const machineCount = companyMachines.length - return ( - - -
- - - {getInitials(company.name)} - - -
-
- - {company.name} - - {company.cnpj ? ( - - CNPJ - - ) : null} - {typeof company.contractedHoursPerMonth === "number" ? ( - - {company.contractedHoursPerMonth}h/mês - - ) : null} - - {machineCount} máquina{machineCount === 1 ? "" : "s"} - -
-
- - {company.slug} - - {company.domain ? ( - <> - - {company.domain} - - ) : null} -
-
- {formattedPhone ? {formattedPhone} : null} - {formattedPhone && company.address ? ( - - ) : null} - {company.address ? {company.address} : null} - {!formattedPhone && !company.address && company.description ? ( - {company.description} - ) : null} -
-
-
-
- - - - -
- - {company.isAvulso ? "Cliente avulso" : "Recorrente"} - - - - - - - {company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"} - - -
-
- - {alertInfo ? ( -
-
- - {lastAlertDistance} -
- -
- {isThresholdExceeded ? ( - - ) : ( - - )} - - Consumo {Math.round(usagePct)}% · Limite {threshold}% - -
-
- ) : ( -
- - Sem alertas recentes -
- )} -
- -
- - - - - - setMachinesDialog({ companyId: company.id, name: company.name })}> - - Ver máquinas - - handleEdit(company)}> - - Editar empresa - - void toggleAvulso(company)}> - - {company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"} - - - setDeleteId(company.id)} - > - - Remover empresa - - - -
-
-
- ) - }) - ) : ( - - - {emptyContent} - - - )} -
-
-
+

Nenhum contrato cadastrado.

)} - - { if (!open) setMachinesDialog(null) }}> - - - Máquinas — {machinesDialog?.name ?? ""} - - {machinesDialogList.length === 0 ? ( -

Nenhuma máquina vinculada a esta empresa.

- ) : ( -
    - {machinesDialogList.map((machine) => { - const statusKey = machine.isActive === false ? "deactivated" : machine.status - const statusVariant = getMachineStatusVariant(statusKey) - return ( -
  • -
    -
    -

    {machine.hostname}

    -

    {machine.authEmail ?? "Sem e-mail definido"}

    -
    - - {statusVariant.label} - -
    -
    - {(() => { - const name = machine.osName ?? "SO desconhecido" - const ver = ((): string => { - const n = machine.osName ?? null - const v = machine.osVersion ?? null - if (!v) return "" - const m = (n ?? "").match(/^windows\s+(\d+)\b/i) - if (m) { - const major = m[1] - const re = new RegExp(`^\\s*${major}(?:\\b|\\.|-_|\\s)+(.*)$`, "i") - const mm = v.match(re) - if (mm) return (mm[1] ?? "").trim() - } - return v - })() - return ( - <> - {name} - {ver ? : null} - {ver ? {ver} : null} - - ) - })()} - {machine.architecture ? ( - - {machine.architecture.toUpperCase()} - - ) : null} -
    -
    - - - Último sinal: {formatRelativeTimestamp(machine.lastHeartbeatAt) ?? "nunca"} - -
    -
  • - ) - })} -
- )} -
-
- - - - { - if (!open) { - setDeleteId(null) - } - }} - > - - -
- - Confirmar exclusão -
- - Esta operação remove o cadastro do cliente e impede novos vínculos automáticos. - -
-
-

- Deseja remover definitivamente{" "} - {deleteTarget?.name ?? "a empresa selecionada"}? -

-

- Registros históricos podem impedir a exclusão. Usuários e tickets ainda vinculados serão desvinculados automaticamente. -

- - - - -
-
-
- ) -} - -const MACHINE_STATUS_VARIANTS: Record = { - online: { label: "Online", className: "border-emerald-200 bg-emerald-500/10 text-emerald-600" }, - offline: { label: "Offline", className: "border-rose-200 bg-rose-500/10 text-rose-600" }, - stale: { label: "Sem sinal", className: "border-slate-300 bg-slate-200/60 text-slate-700" }, - maintenance: { label: "Manutenção", className: "border-amber-200 bg-amber-500/10 text-amber-600" }, - blocked: { label: "Bloqueada", className: "border-orange-200 bg-orange-500/10 text-orange-600" }, - deactivated: { label: "Desativada", className: "border-slate-300 bg-slate-100 text-slate-600" }, - unknown: { label: "Desconhecida", className: "border-slate-200 bg-slate-100 text-slate-600" }, -} - -function getMachineStatusVariant(status?: string | null) { - const normalized = (status ?? "unknown").toLowerCase() - return MACHINE_STATUS_VARIANTS[normalized] ?? MACHINE_STATUS_VARIANTS.unknown -} - -function summarizeStatus(machines: MachineSummary[]): Record { - return machines.reduce>((acc, machine) => { - const normalized = (machine.isActive === false ? "deactivated" : machine.status ?? "unknown").toLowerCase() - acc[normalized] = (acc[normalized] ?? 0) + 1 - return acc - }, {}) -} - -function formatRelativeTimestamp(timestamp?: number | null): string | null { - if (!timestamp || !Number.isFinite(timestamp)) return null - try { - return formatDistanceToNow(new Date(timestamp), { addSuffix: true, locale: ptBR }) - } catch { - return null - } -} - -function getInitials(value: string) { - const cleaned = value.trim() - if (!cleaned) return "?" - const pieces = cleaned - .split(/\s+/) - .filter(Boolean) - .slice(0, 2) - .map((piece) => piece.charAt(0).toUpperCase()) - if (pieces.length === 0) { - return cleaned.slice(0, 2).toUpperCase() - } - return pieces.join("") || cleaned.slice(0, 2).toUpperCase() -} - -function ProvisioningCodeCard({ code }: { code: string | null }) { - const [isCopied, setIsCopied] = useState(false) - const resetTimerRef = useRef | null>(null) - - const handleCopied = useCallback(() => { - if (resetTimerRef.current) { - clearTimeout(resetTimerRef.current) - } - setIsCopied(true) - resetTimerRef.current = setTimeout(() => setIsCopied(false), 2600) - }, []) - - const handleCopy = useCallback(async () => { - if (!code) return - try { - await navigator.clipboard.writeText(code) - handleCopied() - } catch (error) { - console.error("Falha ao copiar código de provisionamento", error) - } - }, [code, handleCopied]) - - useEffect(() => { - return () => { - if (resetTimerRef.current) { - clearTimeout(resetTimerRef.current) - } - } - }, []) - - if (!code) { - return ( -
- Nenhum código provisionado no momento. -
+
+ +
+

Último alerta

+ {alert ? ( +

+ {alert.usagePct}% usado · limiar {alert.threshold}% ·{" "} + {new Date(alert.createdAt).toLocaleDateString("pt-BR")} +

+ ) : ( +

Nenhum alerta registrado

+ )} +
+
+
+

Canais principais

+
+ {company.supportEmail ?

Suporte: {company.supportEmail}

: null} + {company.billingEmail ?

Financeiro: {company.billingEmail}

: null} + {company.phone ?

Telefone: {company.phone}

: null} +
+
+
+ + + +
+
+
) } return ( -
-
-
- - {code} - - + <> +
+
+
+

Empresas atendidas

+

+ Cadastre, edite e visualize contratos, contatos e SLAs das empresas. +

+
+
+ + +
+ + + +
+
+
+ + setSearch(event.target.value)} + className="pl-9" + /> +
+
+
+ + +
+
+ + +
+
+
+ { + if (!next) return + setView(next as ViewMode) + }} + variant="outline" + className="rounded-md border border-border/60 bg-muted/30" + > + + + Lista + + + + Quadro + + +
+
+ + {view === "table" ? ( + + ) : ( +
+ {BOARD_COLUMNS.map((column) => { + const list = boardGroups.get(column.id) ?? [] + return ( +
+
+
+

{column.title}

+

{column.description}

+
+ {list.length} +
+
+ {list.length === 0 ? ( + + ) : ( + list.map((company) => renderBoardCard(company)) + )} +
+
+ ) + })} +
+ )} +
+
- {isCopied ? ( -
- - Código copiado para a área de transferência -
- ) : null} + + { + setCompanies((prev) => [...prev, company].sort((a, b) => a.name.localeCompare(b.name))) + }} + onUpdated={(company) => { + setCompanies((prev) => + prev.map((item) => (item.id === company.id ? company : item)).sort((a, b) => a.name.localeCompare(b.name)), + ) + }} + /> + + (!open ? cancelDelete() : null)}> + + + Remover empresa + + Esta ação desvincula usuários e tickets da empresa selecionada. Confirme para continuar. + + + + + + + + + + ) +} + +function EmptyColumn() { + return ( +
+ +

Sem empresas nesta categoria.

+
+ ) +} + +type TableViewProps = { + companies: NormalizedCompany[] + machineCountsBySlug: Record + onEdit(company: NormalizedCompany): void + onDelete(company: NormalizedCompany): void +} + +function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableViewProps) { + const [pageSize, setPageSize] = useState(10) + const [pageIndex, setPageIndex] = useState(0) + + const total = companies.length + const pageCount = Math.max(1, Math.ceil(total / pageSize)) + const start = total === 0 ? 0 : pageIndex * pageSize + 1 + const end = total === 0 ? 0 : Math.min(total, pageIndex * pageSize + pageSize) + + const current = useMemo( + () => companies.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize), + [companies, pageIndex, pageSize] + ) + + useEffect(() => { + if (pageIndex > pageCount - 1) setPageIndex(Math.max(0, pageCount - 1)) + }, [pageCount, pageIndex]) + + return ( +
+
+ + + + Empresa + Contratos ativos + Contatos + SLA + Máquinas + Ações + + + + {companies.length === 0 ? ( + + +
+ +

Nenhuma empresa encontrada com os filtros atuais.

+
+
+
+ ) : ( + current.map((company) => { + const contracts = company.contracts + const contacts = company.contacts.slice(0, 3) + const machineCount = machineCountsBySlug[company.slug] ?? 0 + + return ( + + +
+
+

{company.name}

+ {company.isAvulso ? Avulso : null} +
+
+ {company.domain ? {company.domain} : null} + {company.phone ? ( + + + {company.phone} + + ) : null} +
+
+ {company.tags.map((tag) => ( + + {tag} + + ))} +
+
+
+ + {contracts.length === 0 ? ( +

Nenhum contrato registrado.

+ ) : ( +
    + {contracts.map((contract) => ( +
  • +

    {contract.contractType}

    +

    + {contract.scope.length ? contract.scope.join(", ") : "Escopo base"} +

    +

    + Vigência: {formatDate(contract.startDate)} – {formatDate(contract.endDate)} +

    +

    + Valor: {formatCurrency(contract.price ?? null)} +

    +
  • + ))} +
+ )} +
+ + {contacts.length === 0 ? ( +

Nenhum contato cadastrado.

+ ) : ( +
    + {contacts.map((contact) => ( +
  • +

    {contact.fullName}

    +

    {contact.email}

    +

    {contact.role.replace("_", " ")}

    +
  • + ))} + {company.contacts.length > contacts.length ? ( +
  • + + {company.contacts.length - contacts.length} outros contatos +
  • + ) : null} +
+ )} +
+ + {company.sla ? ( +
+

+ {company.sla.calendar.toUpperCase()} +

+
    + {company.sla.severities.map((severity) => ( +
  • + {SLA_LEVEL_LABEL[severity.level]} + + {severity.responseMinutes}m · {severity.resolutionMinutes}m + +
  • + ))} +
+
+ ) : ( +

Sem SLA cadastrado.

+ )} +
+ + {machineCount} + + +
+ + + + +
+
+
+ ) + }) + )} +
+
+
+ +
+
+ {total === 0 ? "Nenhum registro" : `Mostrando ${start}-${end} de ${total}`} +
+
+
+ Itens por página + +
+ + + + setPageIndex((p) => Math.max(0, p - 1))} /> + + + {pageIndex + 1} + + + = pageCount - 1} onClick={() => setPageIndex((p) => Math.min(pageCount - 1, p + 1))} /> + + + +
+
+
+ ) +} + +type CompanySheetProps = { + tenantId: string + editor: EditorState | null + onClose(): void + onCreated(company: NormalizedCompany): void + onUpdated(company: NormalizedCompany): void +} + +function CompanySheet({ tenantId, editor, onClose, onCreated, onUpdated }: CompanySheetProps) { + const [isSubmitting, startSubmit] = useTransition() + const open = Boolean(editor) + + const form = useForm({ + resolver: zodResolver(companyFormSchema), + defaultValues: emptyCompany(tenantId), + mode: "onBlur", + }) + + const contactsArray = useFieldArray({ + control: form.control, + name: "contacts", + }) + + const locationsArray = useFieldArray({ + control: form.control, + name: "locations", + }) + + const contractsArray = useFieldArray({ + control: form.control, + name: "contracts", + }) + + const customFieldsArray = useFieldArray({ + control: form.control, + name: "customFields", + }) + + useEffect(() => { + if (!editor) return + if (editor.mode === "create") { + form.reset(emptyCompany(tenantId)) + return + } + const values = toFormValues(editor.company) + if (!values.businessHours) { + values.businessHours = emptyCompany(tenantId).businessHours + } + if (!values.sla) { + values.sla = emptyCompany(tenantId).sla + } + form.reset(values) + }, [editor, form, tenantId]) + + const close = () => { + form.reset(emptyCompany(tenantId)) + onClose() + } + + const handleSubmit = (values: CompanyFormValues) => { + startSubmit(async () => { + const payload = sanitisePayload(values) + try { + if (editor?.mode === "edit") { + const response = await fetch(`/api/admin/companies/${editor.company.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + if (!response.ok) { + const json = await response.json().catch(() => null) + throw new Error(json?.error ?? "Falha ao atualizar a empresa.") + } + const json = (await response.json()) as { company: NormalizedCompany } + onUpdated(json.company) + toast.success(`Empresa “${json.company.name}” atualizada com sucesso.`) + } else { + const response = await fetch("/api/admin/companies", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + if (!response.ok) { + const json = await response.json().catch(() => null) + throw new Error(json?.error ?? "Falha ao criar a empresa.") + } + const json = (await response.json()) as { company: NormalizedCompany } + onCreated(json.company) + toast.success(`Empresa “${json.company.name}” criada com sucesso.`) + } + close() + } catch (error) { + const message = + error instanceof Error ? error.message : "Não foi possível salvar a empresa." + toast.error(message) + } + }) + } + + return ( + (!value ? close() : null)}> + + +
+
+ + + {editor?.mode === "edit" ? ( + <> + + Editar empresa + + ) : ( + <> + + Nova empresa + + )} + + + Gerencie dados cadastrais, contatos, contratos e SLAs da organização. + + +
+ + +
+
+ +
+
+
+

Identificação

+

+ Dados básicos exibidos nas listagens e relatórios. +

+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + + +
+
+ + ( + + )} + /> + +
+
+
+ + + +
+
+ +