feat: Administração > Configurações, remover usuário do tenant, Discador > Callbacks
Fecha os últimos gaps do módulo Administração (agente.md secao 169): tela de Configurações self-service do próprio tenant (GET/PATCH /tenant-settings, nunca aceita tenantId arbitrário — só user.tenantId das claims), e DELETE /users/:id pra remover alguém do tenant, com duas proteções que não existiam antes (não deixa remover a si mesmo, não deixa remover/rebaixar o último Tenant Admin). Corrige um bug real achado testando a remoção: o delete de TenantMembership (FORCE RLS) rodava dentro de um prisma.$transaction([...]) em forma de array, que nunca seta app.current_tenant_id — Prisma devolvia P2025 "not found" com a linha existindo (500 pro cliente). Mesma classe de bug já corrigida antes em TenantsController.create; corrigido com $transaction(async (tx) => ...) + set_config explícito. Adiciona Discador > Callbacks (GET/PATCH /leads/callbacks, tenant-wide): reagendar, tentar de novo sem esperar, ou desistir de um lead que pediu retorno em outro horário. Remove "Importações" do menu — decisão já registrada na PHASE 39 de não duplicar uma tela pro que já existe (CSV em lote no wizard/detalhe da campanha). Testado ponta a ponta via curl e Puppeteer contra o tenant Acme real: tenant-settings GET/PATCH, proteção de último-admin nos dois endpoints que a usam, convite+remoção de um admin temporário, e o fluxo completo de callback (lead forçado pra CALLBACK via SQL, reagendar rejeitado pro passado/aceito pro futuro, requeue confirmado). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
57
TODO.md
57
TODO.md
@@ -1483,6 +1483,63 @@ systemd (fecha um risco documentado desde a PHASE 01)
|
||||
`PredictiveDialerEngine` já popula (PHASE 16) mas sem tela nenhuma
|
||||
pra visualizar/gerenciar ainda
|
||||
|
||||
## PHASE 40 — Administração > Configurações (tenant) + últimos gaps de
|
||||
Usuários (agente.md secao 169)
|
||||
- [x] `GET/PATCH /tenant-settings`: self-service do próprio tenant — só
|
||||
lê/escreve `user.tenantId` das claims, nunca aceita um tenantId
|
||||
arbitrário no path/body, então não existe forma de um Tenant Admin
|
||||
mexer em outro tenant por aqui (diferente de `TenantsController`,
|
||||
que é platform-only). Editável: nome fantasia, CNPJ/CPF, fuso,
|
||||
idioma, privacidade de IA. Somente leitura: razão social, código,
|
||||
plano, status, moeda, domínio de telefonia (controlados pela
|
||||
plataforma)
|
||||
- [x] Tela `/app/administracao/configuracoes` — formulário + bloco
|
||||
read-only, mesmo padrão visual das outras telas de Administração
|
||||
- [x] `DELETE /users/:id` — remove só a membership+role do tenant (nunca
|
||||
a conta `User`, que pode ter acesso a outros tenants). Duas
|
||||
proteções novas (nenhuma existia antes): não deixa remover a si
|
||||
mesmo, e não deixa remover/rebaixar o último Tenant Admin do tenant
|
||||
(`isLastTenantAdmin`, aplicado também em `PATCH /users/:id/role`) —
|
||||
sem isso um tenant podia ficar sem ninguém que pudesse gerenciar
|
||||
usuários
|
||||
- [x] achado real: o primeiro `remove()` usava
|
||||
`prisma.$transaction([...])` (forma array) pra apagar
|
||||
`tenantMembership` — como essa tabela tem FORCE RLS (secao 32) e a
|
||||
forma array não abre uma transação com `app.current_tenant_id`
|
||||
setado, o Prisma devolvia P2025 ("not found") mesmo com a linha
|
||||
existindo (500 pro cliente). Mesma classe de bug já corrigida antes
|
||||
em `TenantsController.create`. Corrigido trocando pra
|
||||
`$transaction(async (tx) => ...)` com `set_config` explícito antes
|
||||
do delete — testado removendo de verdade um usuário de teste
|
||||
(invite → demote self (2 admins) → remove → 204 → sumiu da lista)
|
||||
- [x] Testado ponta a ponta: GET/PATCH tenant-settings via curl e via UI
|
||||
(nome fantasia editado, sidebar atualiza na hora), proteção de
|
||||
último-admin confirmada nos dois endpoints (403 nos dois), convite +
|
||||
remoção de um admin temporário confirmados
|
||||
|
||||
## PHASE 41 — Discador > Callbacks (agente.md secao 78-79, 169)
|
||||
- [x] `GET /leads/callbacks` (tenant-wide, todas as campanhas) +
|
||||
`PATCH /leads/callbacks/:id` com 3 ações: `RESCHEDULE` (nova data,
|
||||
rejeitada se não for no futuro), `REQUEUE` (volta pra `READY`,
|
||||
dialer pega de novo sem esperar), `CANCEL` (`DO_NOT_CALL`) — só
|
||||
atua sobre leads que ainda estão em `CALLBACK` (RLS +
|
||||
`withTenantContext`, mesmo padrão já auditado)
|
||||
- [x] Tela `/app/discador/callbacks` — lista com nome da campanha,
|
||||
telefone, tentativas, "remarcado para" com date-time picker inline
|
||||
- [x] "Importações" removido do menu (nunca virou tela real, decisão já
|
||||
registrada na PHASE 39: CSV em lote já existe no wizard e no
|
||||
detalhe da campanha, uma tela separada só faria sentido com
|
||||
histórico de import persistido, que não existe) — mesmo princípio
|
||||
já usado em Monitoramento (secao 168-169): não deixar link pra
|
||||
"em breve" quando a funcionalidade de verdade já está em outro
|
||||
lugar
|
||||
- [x] Testado ponta a ponta: lead de teste forçado pra `CALLBACK` via SQL
|
||||
direto (não existe fluxo de produto pra chegar nesse estado sem o
|
||||
dialer rodando uma chamada real), reagendamento rejeitado pro
|
||||
passado (400), aceito pro futuro (200), requeue confirmado (sai da
|
||||
lista de callbacks, volta pra `READY`), lead de teste removido no
|
||||
final
|
||||
|
||||
---
|
||||
|
||||
## Riscos conhecidos
|
||||
|
||||
76
apps/api/src/leads/callbacks.controller.ts
Normal file
76
apps/api/src/leads/callbacks.controller.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Patch, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { UpdateCallbackDto } from "./dto/update-callback.dto";
|
||||
|
||||
/**
|
||||
* "Discador > Callbacks" (agente.md secao 169) — visão tenant-wide (todas
|
||||
* as campanhas) dos leads que o `PredictiveDialerEngine` marcou como
|
||||
* `CALLBACK` (secao 78-79: agente pediu pra ligar de volta em outro
|
||||
* horário). Antes desta tela não existia forma nenhuma de ver ou agir
|
||||
* sobre esses leads — só ficavam esperando `nextAttemptAt` chegar, sem
|
||||
* ninguém poder adiantar, adiar ou cancelar.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("leads/callbacks")
|
||||
export class CallbacksController {
|
||||
@RequirePermission("campaigns.view")
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
return withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.lead.findMany({
|
||||
where: { tenantId, status: "CALLBACK" },
|
||||
include: { campaign: { select: { id: true, name: true } } },
|
||||
orderBy: { nextAttemptAt: "asc" },
|
||||
take: 500,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission("campaigns.update")
|
||||
@Patch(":id")
|
||||
async update(
|
||||
@CurrentUser() user: AccessTokenClaims,
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateCallbackDto,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const lead = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.lead.findFirst({ where: { id, tenantId, status: "CALLBACK" } }),
|
||||
);
|
||||
if (!lead) throw new NotFoundException("Callback nao encontrado (ou o lead ja saiu do status CALLBACK)");
|
||||
|
||||
if (dto.action === "RESCHEDULE" && new Date(dto.nextAttemptAt!).getTime() <= Date.now()) {
|
||||
throw new BadRequestException("nextAttemptAt precisa ser no futuro");
|
||||
}
|
||||
|
||||
const data =
|
||||
dto.action === "RESCHEDULE"
|
||||
? { nextAttemptAt: new Date(dto.nextAttemptAt!) }
|
||||
: dto.action === "REQUEUE"
|
||||
? { status: "READY" as const, nextAttemptAt: null }
|
||||
: { status: "DO_NOT_CALL" as const, nextAttemptAt: null };
|
||||
|
||||
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.lead.update({ where: { id }, data }));
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "LEAD_CALLBACK_UPDATE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "lead",
|
||||
entityId: id,
|
||||
after: { action: dto.action, nextAttemptAt: dto.nextAttemptAt ?? null },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
12
apps/api/src/leads/dto/update-callback.dto.ts
Normal file
12
apps/api/src/leads/dto/update-callback.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsIn, IsISO8601, ValidateIf } from "class-validator";
|
||||
|
||||
const CALLBACK_ACTIONS = ["RESCHEDULE", "REQUEUE", "CANCEL"];
|
||||
|
||||
export class UpdateCallbackDto {
|
||||
@IsIn(CALLBACK_ACTIONS)
|
||||
action!: "RESCHEDULE" | "REQUEUE" | "CANCEL";
|
||||
|
||||
@ValidateIf((dto) => dto.action === "RESCHEDULE")
|
||||
@IsISO8601()
|
||||
nextAttemptAt?: string;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { LeadsController } from "./leads.controller";
|
||||
import { CallbacksController } from "./callbacks.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [LeadsController],
|
||||
controllers: [LeadsController, CallbacksController],
|
||||
})
|
||||
export class LeadsModule {}
|
||||
|
||||
29
apps/api/src/tenants/dto/update-tenant-settings.dto.ts
Normal file
29
apps/api/src/tenants/dto/update-tenant-settings.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
const AI_PRIVACY_LEVELS = ["AI_OFF", "TRANSCRIPTION_ONLY", "TRANSCRIPTION_AND_ANALYSIS"];
|
||||
|
||||
export class UpdateTenantSettingsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
tradeName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
taxId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(60)
|
||||
timezone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
locale?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(AI_PRIVACY_LEVELS)
|
||||
aiPrivacyLevel?: string;
|
||||
}
|
||||
65
apps/api/src/tenants/tenant-settings.controller.ts
Normal file
65
apps/api/src/tenants/tenant-settings.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Body, Controller, Get, NotFoundException, Patch, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient, type AIPrivacyLevel } from "@b2bcall/database";
|
||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { UpdateTenantSettingsDto } from "./dto/update-tenant-settings.dto";
|
||||
|
||||
/**
|
||||
* "Administração > Configurações" (agente.md secao 169) — self-service do
|
||||
* próprio tenant, diferente de `TenantsController` (que é platform-only e
|
||||
* mexe em campos que só a plataforma controla: status, plano, code).
|
||||
* Nunca recebe `:id` — sempre `user.tenantId` das claims, então não existe
|
||||
* forma de um Tenant Admin editar outro tenant por aqui (a checagem
|
||||
* `isPlatformUser` do `TenantsController` não se aplica porque este
|
||||
* controller nem aceita um tenantId arbitrário).
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("tenant-settings")
|
||||
export class TenantSettingsController {
|
||||
@RequirePermission("users.manage")
|
||||
@Get()
|
||||
async get(@CurrentUser() user: AccessTokenClaims) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { id: user.tenantId!, deletedAt: null },
|
||||
include: { plan: { select: { key: true, name: true } } },
|
||||
});
|
||||
if (!tenant) throw new NotFoundException();
|
||||
const { priceBookId: _priceBookId, rateDeckId: _rateDeckId, ...rest } = tenant;
|
||||
return rest;
|
||||
}
|
||||
|
||||
@RequirePermission("users.manage")
|
||||
@Patch()
|
||||
async update(@CurrentUser() user: AccessTokenClaims, @Body() dto: UpdateTenantSettingsDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const tenant = await prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
...(dto.tradeName !== undefined ? { tradeName: dto.tradeName } : {}),
|
||||
...(dto.taxId !== undefined ? { taxId: dto.taxId } : {}),
|
||||
...(dto.timezone !== undefined ? { timezone: dto.timezone } : {}),
|
||||
...(dto.locale !== undefined ? { locale: dto.locale } : {}),
|
||||
...(dto.aiPrivacyLevel !== undefined ? { aiPrivacyLevel: dto.aiPrivacyLevel as AIPrivacyLevel } : {}),
|
||||
},
|
||||
include: { plan: { select: { key: true, name: true } } },
|
||||
});
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "TENANT_SETTINGS_UPDATE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "tenant",
|
||||
entityId: tenantId,
|
||||
after: { ...dto },
|
||||
});
|
||||
|
||||
const { priceBookId: _priceBookId, rateDeckId: _rateDeckId, ...rest } = tenant;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TenantsController } from "./tenants.controller";
|
||||
import { TenantSettingsController } from "./tenant-settings.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [TenantsController],
|
||||
controllers: [TenantsController, TenantSettingsController],
|
||||
})
|
||||
export class TenantsModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, ConflictException, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { Body, ConflictException, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { recordAuditEvent, hashPassword, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { generateStrongPassword } from "@b2bcall/shared";
|
||||
@@ -15,6 +15,16 @@ import { InviteUserDto, UpdateUserRoleDto } from "./dto/invite-user.dto";
|
||||
* (Platform > Clientes > Tenants) ou via script — nenhuma forma de um
|
||||
* Tenant Admin adicionar um colega ao próprio tenant.
|
||||
*/
|
||||
async function isLastTenantAdmin(tenantId: string, userId: string): Promise<boolean> {
|
||||
const prisma = getPrismaClient();
|
||||
const currentRole = await prisma.userRole.findFirst({ where: { userId, tenantId }, include: { role: true } });
|
||||
if (currentRole?.role.key !== "tenant_admin") return false;
|
||||
const otherAdmins = await prisma.userRole.count({
|
||||
where: { tenantId, userId: { not: userId }, role: { key: "tenant_admin" } },
|
||||
});
|
||||
return otherAdmins === 0;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("users")
|
||||
export class UsersController {
|
||||
@@ -135,6 +145,10 @@ export class UsersController {
|
||||
throw new ForbiddenException("roleKey precisa ser uma role de escopo TENANT");
|
||||
}
|
||||
|
||||
if (role.key !== "tenant_admin" && (await isLastTenantAdmin(tenantId, id))) {
|
||||
throw new ForbiddenException("Este e' o unico Tenant Admin do tenant — promova outra pessoa antes de trocar o papel dele(a)");
|
||||
}
|
||||
|
||||
// Simplificação deliberada: 1 role por usuário por tenant — trocar
|
||||
// substitui, não acumula (o schema permite várias, mas a UI não
|
||||
// oferece combinar papéis nesta primeira versão).
|
||||
@@ -154,4 +168,50 @@ export class UsersController {
|
||||
|
||||
return { id, role: { key: role.key, name: role.name } };
|
||||
}
|
||||
|
||||
/** Remove só a membership+role deste tenant (secao 169) — nunca a conta
|
||||
* `User` em si, que pode pertencer a outros tenants. Bloqueia remover a
|
||||
* si mesmo (evita um Tenant Admin se trancar fora sem querer) e remover
|
||||
* o último Tenant Admin (o tenant ficaria sem ninguém que possa
|
||||
* convidar/gerenciar gente). */
|
||||
@RequirePermission("users.manage")
|
||||
@Delete(":id")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
if (id === user.sub) {
|
||||
throw new ForbiddenException("Você não pode remover a si mesmo deste tenant");
|
||||
}
|
||||
|
||||
const membership = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.tenantMembership.findUnique({ where: { tenantId_userId: { tenantId, userId: id } } }),
|
||||
);
|
||||
if (!membership) throw new NotFoundException("Usuário não pertence a este tenant");
|
||||
|
||||
if (await isLastTenantAdmin(tenantId, id)) {
|
||||
throw new ForbiddenException("Este e' o unico Tenant Admin do tenant — promova outra pessoa antes de remove-lo(a)");
|
||||
}
|
||||
|
||||
// `tenant_memberships` tem FORCE RLS (secao 32) — precisa de
|
||||
// `app.current_tenant_id` setado na mesma transação, senão o delete
|
||||
// não enxerga a linha e Prisma devolve P2025 ("not found") mesmo com
|
||||
// a linha existindo (mesma classe de bug já corrigida em
|
||||
// TenantsController.create). `user_roles` não tem RLS, então o
|
||||
// deleteMany funciona em qualquer contexto.
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
|
||||
await tx.userRole.deleteMany({ where: { userId: id, tenantId } });
|
||||
await tx.tenantMembership.delete({ where: { tenantId_userId: { tenantId, userId: id } } });
|
||||
});
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "USER_REMOVED_FROM_TENANT",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "user",
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
BIN
apps/frontend/.impeccable/review/callbacks-with-data-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/callbacks-with-data-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/frontend/.impeccable/review/config-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/config-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
BIN
apps/frontend/.impeccable/review/config-saved-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/config-saved-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
BIN
apps/frontend/.impeccable/review/usuarios-remove-btn-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/usuarios-remove-btn-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import type { TenantSettings } from "@/lib/admin-types";
|
||||
|
||||
function extractErrorMessage(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
try {
|
||||
const parsed = JSON.parse(err.message);
|
||||
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
|
||||
if (typeof parsed.message === "string") return parsed.message;
|
||||
} catch {
|
||||
// corpo não era JSON
|
||||
}
|
||||
return err.message || "Falha inesperada na API.";
|
||||
}
|
||||
return "Falha inesperada. Tente novamente.";
|
||||
}
|
||||
|
||||
export interface UpdateTenantSettingsInput {
|
||||
tradeName: string;
|
||||
taxId: string;
|
||||
timezone: string;
|
||||
locale: string;
|
||||
aiPrivacyLevel: string;
|
||||
}
|
||||
|
||||
export async function updateTenantSettings(
|
||||
input: UpdateTenantSettingsInput,
|
||||
): Promise<{ ok: true; settings: TenantSettings } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const settings = await apiFetch<TenantSettings>("/tenant-settings", session.accessToken, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
revalidatePath("/app/administracao/configuracoes");
|
||||
return { ok: true, settings };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
import { AI_PRIVACY_LEVEL_LABELS, type TenantSettings } from "@/lib/admin-types";
|
||||
|
||||
type AiPrivacyLevel = TenantSettings["aiPrivacyLevel"];
|
||||
import { TENANT_STATUS_LABELS } from "@/lib/platform-types";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { updateTenantSettings } from "./actions";
|
||||
|
||||
export function ConfiguracoesView({ settings }: { settings: TenantSettings }) {
|
||||
const router = useRouter();
|
||||
const [tradeName, setTradeName] = useState(settings.tradeName ?? "");
|
||||
const [taxId, setTaxId] = useState(settings.taxId ?? "");
|
||||
const [timezone, setTimezone] = useState(settings.timezone);
|
||||
const [locale, setLocale] = useState(settings.locale);
|
||||
const [aiPrivacyLevel, setAiPrivacyLevel] = useState(settings.aiPrivacyLevel);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
startTransition(async () => {
|
||||
const result = await updateTenantSettings({ tradeName: tradeName.trim(), taxId: taxId.trim(), timezone, locale, aiPrivacyLevel });
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Configurações</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Dados do próprio tenant (agente.md secao 169). Razão social, código, plano e status são controlados pela
|
||||
plataforma — fale com o suporte pra mudar algum desses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Panel className="p-5">
|
||||
<PanelHeader title="Identificação" description="Somente leitura — controlado pela plataforma" />
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<ReadOnlyField label="Razão social" value={settings.legalName} />
|
||||
<ReadOnlyField label="Código" value={settings.code} mono />
|
||||
<div>
|
||||
<FieldLabel>Status</FieldLabel>
|
||||
<div className="pt-1.5">
|
||||
<Pill tone={settings.status === "ACTIVE" ? "accent" : "neutral"}>{TENANT_STATUS_LABELS[settings.status]}</Pill>
|
||||
</div>
|
||||
</div>
|
||||
<ReadOnlyField label="Plano" value={settings.plan.name} />
|
||||
<ReadOnlyField label="Moeda de faturamento" value={settings.billingCurrency} />
|
||||
<ReadOnlyField label="Domínio de telefonia" value={settings.telephonyDomain ?? "—"} mono />
|
||||
<ReadOnlyField label="Cliente desde" value={formatDate(settings.createdAt)} />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="p-5">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<PanelHeader title="Editável" description="Só um Tenant Admin pode alterar" />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<FieldLabel htmlFor="cf-tradename">Nome fantasia</FieldLabel>
|
||||
<Input id="cf-tradename" value={tradeName} onChange={(e) => setTradeName(e.target.value)} disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="cf-taxid">CNPJ/CPF</FieldLabel>
|
||||
<Input id="cf-taxid" value={taxId} onChange={(e) => setTaxId(e.target.value)} disabled={pending} className="font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="cf-timezone">Fuso horário</FieldLabel>
|
||||
<Input id="cf-timezone" value={timezone} onChange={(e) => setTimezone(e.target.value)} disabled={pending} className="font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="cf-locale">Idioma</FieldLabel>
|
||||
<Input id="cf-locale" value={locale} onChange={(e) => setLocale(e.target.value)} disabled={pending} className="font-mono" />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<FieldLabel htmlFor="cf-ai">Privacidade de IA</FieldLabel>
|
||||
<Select id="cf-ai" value={aiPrivacyLevel} onChange={(e) => setAiPrivacyLevel(e.target.value as AiPrivacyLevel)} disabled={pending}>
|
||||
{Object.entries(AI_PRIVACY_LEVEL_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Nível padrão deste tenant — campanha e fila podem sobrescrever com um nível mais restrito, nunca mais
|
||||
permissivo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{saved && !pending && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-status-green">
|
||||
<Check className="h-4 w-4" aria-hidden /> Salvo
|
||||
</span>
|
||||
)}
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Salvando…" : "Salvar alterações"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyField({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<p className={`pt-1.5 text-sm text-foreground ${mono ? "font-mono" : ""}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { TenantSettings } from "@/lib/admin-types";
|
||||
import { ConfiguracoesView } from "./configuracoes-view";
|
||||
|
||||
export default async function ConfiguracoesPage() {
|
||||
const session = await requireSession();
|
||||
const settings = await apiFetch<TenantSettings>("/tenant-settings", session.accessToken);
|
||||
return <ConfiguracoesView settings={settings} />;
|
||||
}
|
||||
@@ -50,3 +50,14 @@ export async function updateUserRole(id: string, roleKey: string): Promise<{ ok:
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUser(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
await apiFetch<void>(`/users/${id}`, session.accessToken, { method: "DELETE" });
|
||||
revalidatePath("/app/administracao/usuarios");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Plus, User as UserIcon, X } from "lucide-react";
|
||||
import { Check, Plus, Trash2, User as UserIcon, X } from "lucide-react";
|
||||
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
||||
@@ -10,7 +10,7 @@ import { Pill } from "@/components/ui/pill";
|
||||
import { SecretReveal } from "@/components/ui/secret-reveal";
|
||||
import { TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
||||
import { TENANT_ROLE_OPTIONS, type TenantMember } from "@/lib/admin-types";
|
||||
import { inviteUser, updateUserRole } from "./actions";
|
||||
import { inviteUser, removeUser, updateUserRole } from "./actions";
|
||||
|
||||
export function UsuariosView({ members, currentUserId }: { members: TenantMember[]; currentUserId: string }) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@@ -152,6 +152,7 @@ function MemberRow({ member, isSelf }: { member: TenantMember; isSelf: boolean }
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [roleKey, setRoleKey] = useState(member.role?.key ?? TENANT_ROLE_OPTIONS[2].key);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmingRemove, setConfirmingRemove] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function onSave() {
|
||||
@@ -167,6 +168,23 @@ function MemberRow({ member, isSelf }: { member: TenantMember; isSelf: boolean }
|
||||
});
|
||||
}
|
||||
|
||||
function onRemove() {
|
||||
if (!confirmingRemove) {
|
||||
setConfirmingRemove(true);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await removeUser(member.id);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setConfirmingRemove(false);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TR>
|
||||
<TD>
|
||||
@@ -201,10 +219,24 @@ function MemberRow({ member, isSelf }: { member: TenantMember; isSelf: boolean }
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill>{member.role?.name ?? "sem papel"}</Pill>
|
||||
{!isSelf && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(true)}>
|
||||
Trocar
|
||||
</Button>
|
||||
<>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(true)} disabled={pending}>
|
||||
Trocar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmingRemove ? "destructive" : "ghost"}
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
disabled={pending}
|
||||
aria-label={confirmingRemove ? `Confirmar remoção de ${member.name}` : `Remover ${member.name} do tenant`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{confirmingRemove ? "Confirmar" : ""}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
</div>
|
||||
)}
|
||||
</TD>
|
||||
|
||||
42
apps/frontend/src/app/app/discador/callbacks/actions.ts
Normal file
42
apps/frontend/src/app/app/discador/callbacks/actions.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
|
||||
function extractErrorMessage(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
try {
|
||||
const parsed = JSON.parse(err.message);
|
||||
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
|
||||
if (typeof parsed.message === "string") return parsed.message;
|
||||
} catch {
|
||||
// corpo não era JSON
|
||||
}
|
||||
return err.message || "Falha inesperada na API.";
|
||||
}
|
||||
return "Falha inesperada. Tente novamente.";
|
||||
}
|
||||
|
||||
async function updateCallback(id: string, body: { action: "RESCHEDULE" | "REQUEUE" | "CANCEL"; nextAttemptAt?: string }) {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
await apiFetch<void>(`/leads/callbacks/${id}`, session.accessToken, { method: "PATCH", body: JSON.stringify(body) });
|
||||
revalidatePath("/app/discador/callbacks");
|
||||
return { ok: true as const };
|
||||
} catch (err) {
|
||||
return { ok: false as const, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function rescheduleCallback(id: string, nextAttemptAt: string) {
|
||||
return updateCallback(id, { action: "RESCHEDULE", nextAttemptAt });
|
||||
}
|
||||
|
||||
export async function requeueCallback(id: string) {
|
||||
return updateCallback(id, { action: "REQUEUE" });
|
||||
}
|
||||
|
||||
export async function cancelCallback(id: string) {
|
||||
return updateCallback(id, { action: "CANCEL" });
|
||||
}
|
||||
170
apps/frontend/src/app/app/discador/callbacks/callbacks-view.tsx
Normal file
170
apps/frontend/src/app/app/discador/callbacks/callbacks-view.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CalendarClock, PhoneForwarded, Trash2 } from "lucide-react";
|
||||
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import type { CallbackLead } from "@/lib/campaign-types";
|
||||
import { cancelCallback, rescheduleCallback, requeueCallback } from "./actions";
|
||||
|
||||
export function CallbacksView({ callbacks }: { callbacks: CallbackLead[] }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Callbacks</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Leads que pediram pra ser chamados de volta em outro horário (agente.md secao 78-79, todas as campanhas
|
||||
deste tenant). O discador preditivo respeita "Remarcado para" sozinho — não precisa fazer nada aqui a
|
||||
menos que queira adiantar, adiar ou desistir de um contato.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Callbacks pendentes" description={`${callbacks.length} lead(s) aguardando retorno`} />
|
||||
{callbacks.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Nenhum callback pendente"
|
||||
description="Quando um agente marcar um lead pra ligar de volta depois, ele aparece aqui."
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Campanha</TH>
|
||||
<TH>Nome</TH>
|
||||
<TH>Telefone</TH>
|
||||
<TH>Tentativas</TH>
|
||||
<TH>Remarcado para</TH>
|
||||
<TH>
|
||||
<span className="sr-only">Ações</span>
|
||||
</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{callbacks.map((lead) => (
|
||||
<CallbackRow key={lead.id} lead={lead} />
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toLocalInputValue(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function CallbackRow({ lead }: { lead: CallbackLead }) {
|
||||
const router = useRouter();
|
||||
const [rescheduling, setRescheduling] = useState(false);
|
||||
const [when, setWhen] = useState(toLocalInputValue(lead.nextAttemptAt));
|
||||
const [confirmingCancel, setConfirmingCancel] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function onReschedule() {
|
||||
if (!rescheduling) {
|
||||
setRescheduling(true);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
if (!when) {
|
||||
setError("Escolha uma data/hora.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await rescheduleCallback(lead.id, new Date(when).toISOString());
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setRescheduling(false);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function onRequeue() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await requeueCallback(lead.id);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (!confirmingCancel) {
|
||||
setConfirmingCancel(true);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await cancelCallback(lead.id);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setConfirmingCancel(false);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TR>
|
||||
<TD>
|
||||
<Pill>{lead.campaign.name}</Pill>
|
||||
</TD>
|
||||
<TD className="text-foreground">{lead.name ?? "—"}</TD>
|
||||
<TD className="font-mono text-muted-foreground">{lead.phoneNormalized}</TD>
|
||||
<TD className="font-mono tabular-nums text-muted-foreground">{lead.attemptCount}</TD>
|
||||
<TD>
|
||||
{rescheduling ? (
|
||||
<Input type="datetime-local" value={when} onChange={(e) => setWhen(e.target.value)} disabled={pending} className="h-8 w-52" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">{lead.nextAttemptAt ? formatDateTime(lead.nextAttemptAt) : "—"}</span>
|
||||
)}
|
||||
</TD>
|
||||
<TD>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onReschedule} disabled={pending}>
|
||||
<CalendarClock className="h-3.5 w-3.5" aria-hidden />
|
||||
{rescheduling ? "Confirmar" : "Remarcar"}
|
||||
</Button>
|
||||
{!rescheduling && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onRequeue} disabled={pending} title="Tentar de novo agora, sem esperar a data">
|
||||
<PhoneForwarded className="h-3.5 w-3.5" aria-hidden />
|
||||
Tentar agora
|
||||
</Button>
|
||||
)}
|
||||
{!rescheduling && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmingCancel ? "destructive" : "ghost"}
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={pending}
|
||||
aria-label={confirmingCancel ? `Confirmar cancelamento do callback de ${lead.phoneNormalized}` : `Cancelar callback de ${lead.phoneNormalized}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{confirmingCancel ? "Confirmar" : ""}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TD>
|
||||
</TR>
|
||||
);
|
||||
}
|
||||
10
apps/frontend/src/app/app/discador/callbacks/page.tsx
Normal file
10
apps/frontend/src/app/app/discador/callbacks/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { CallbackLead } from "@/lib/campaign-types";
|
||||
import { CallbacksView } from "./callbacks-view";
|
||||
|
||||
export default async function CallbacksPage() {
|
||||
const session = await requireSession();
|
||||
const callbacks = await apiFetch<CallbackLead[]>("/leads/callbacks", session.accessToken);
|
||||
return <CallbacksView callbacks={callbacks} />;
|
||||
}
|
||||
@@ -42,8 +42,11 @@ export const TENANT_NAV: NavSection[] = [
|
||||
href: "/app/discador/leads",
|
||||
description: "Leads de uma campanha — buscar, adicionar, remover",
|
||||
},
|
||||
{ label: "Importações" },
|
||||
{ label: "Callbacks" },
|
||||
{
|
||||
label: "Callbacks",
|
||||
href: "/app/discador/callbacks",
|
||||
description: "Leads que pediram retorno em outro horário — remarcar, tentar agora ou cancelar",
|
||||
},
|
||||
{
|
||||
label: "Lista de Bloqueio",
|
||||
href: "/app/discador/bloqueio",
|
||||
@@ -177,7 +180,11 @@ export const TENANT_NAV: NavSection[] = [
|
||||
href: "/app/administracao/perfis",
|
||||
description: "O que cada papel pode fazer neste tenant",
|
||||
},
|
||||
{ label: "Configurações" },
|
||||
{
|
||||
label: "Configurações",
|
||||
href: "/app/administracao/configuracoes",
|
||||
description: "Dados do tenant — nome fantasia, fuso, idioma, privacidade de IA",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -16,3 +16,25 @@ export const TENANT_ROLE_OPTIONS: { key: string; name: string }[] = [
|
||||
{ key: "supervisor", name: "Supervisor" },
|
||||
{ key: "agent", name: "Agente" },
|
||||
];
|
||||
|
||||
export interface TenantSettings {
|
||||
id: string;
|
||||
code: string;
|
||||
legalName: string;
|
||||
tradeName: string | null;
|
||||
taxId: string | null;
|
||||
status: "TRIAL" | "ACTIVE" | "SUSPENDED" | "PAST_DUE" | "CANCELLED";
|
||||
timezone: string;
|
||||
locale: string;
|
||||
billingCurrency: string;
|
||||
telephonyDomain: string | null;
|
||||
aiPrivacyLevel: "AI_OFF" | "TRANSCRIPTION_ONLY" | "TRANSCRIPTION_AND_ANALYSIS";
|
||||
plan: { key: string; name: string };
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const AI_PRIVACY_LEVEL_LABELS: Record<string, string> = {
|
||||
AI_OFF: "Desligada — nenhum áudio/transcrição sai deste tenant",
|
||||
TRANSCRIPTION_ONLY: "Só transcrição — sem análise por IA",
|
||||
TRANSCRIPTION_AND_ANALYSIS: "Transcrição e análise por IA",
|
||||
};
|
||||
|
||||
@@ -88,6 +88,10 @@ export interface Lead {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CallbackLead extends Lead {
|
||||
campaign: { id: string; name: string };
|
||||
}
|
||||
|
||||
export const LEAD_STATUS_LABELS: Record<string, string> = {
|
||||
NEW: "Novo",
|
||||
READY: "Pronto",
|
||||
|
||||
Reference in New Issue
Block a user