diff --git a/TODO.md b/TODO.md index c9f7e30..3319755 100644 --- a/TODO.md +++ b/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 diff --git a/apps/api/src/leads/callbacks.controller.ts b/apps/api/src/leads/callbacks.controller.ts new file mode 100644 index 0000000..a16c4d8 --- /dev/null +++ b/apps/api/src/leads/callbacks.controller.ts @@ -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[]> { + 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> { + 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; + } +} diff --git a/apps/api/src/leads/dto/update-callback.dto.ts b/apps/api/src/leads/dto/update-callback.dto.ts new file mode 100644 index 0000000..237830a --- /dev/null +++ b/apps/api/src/leads/dto/update-callback.dto.ts @@ -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; +} diff --git a/apps/api/src/leads/leads.module.ts b/apps/api/src/leads/leads.module.ts index 076d53b..8203ddb 100644 --- a/apps/api/src/leads/leads.module.ts +++ b/apps/api/src/leads/leads.module.ts @@ -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 {} diff --git a/apps/api/src/tenants/dto/update-tenant-settings.dto.ts b/apps/api/src/tenants/dto/update-tenant-settings.dto.ts new file mode 100644 index 0000000..1451c4f --- /dev/null +++ b/apps/api/src/tenants/dto/update-tenant-settings.dto.ts @@ -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; +} diff --git a/apps/api/src/tenants/tenant-settings.controller.ts b/apps/api/src/tenants/tenant-settings.controller.ts new file mode 100644 index 0000000..a992401 --- /dev/null +++ b/apps/api/src/tenants/tenant-settings.controller.ts @@ -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; + } +} diff --git a/apps/api/src/tenants/tenants.module.ts b/apps/api/src/tenants/tenants.module.ts index c479e79..36317ff 100644 --- a/apps/api/src/tenants/tenants.module.ts +++ b/apps/api/src/tenants/tenants.module.ts @@ -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 {} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 3f98820..4aa7375 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -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 { + 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, + }); + } } diff --git a/apps/frontend/.impeccable/review/callbacks-reschedule-open-desktop.png b/apps/frontend/.impeccable/review/callbacks-reschedule-open-desktop.png new file mode 100644 index 0000000..6727d1d Binary files /dev/null and b/apps/frontend/.impeccable/review/callbacks-reschedule-open-desktop.png differ diff --git a/apps/frontend/.impeccable/review/callbacks-with-data-desktop.png b/apps/frontend/.impeccable/review/callbacks-with-data-desktop.png new file mode 100644 index 0000000..f9ff835 Binary files /dev/null and b/apps/frontend/.impeccable/review/callbacks-with-data-desktop.png differ diff --git a/apps/frontend/.impeccable/review/config-desktop.png b/apps/frontend/.impeccable/review/config-desktop.png new file mode 100644 index 0000000..cc176df Binary files /dev/null and b/apps/frontend/.impeccable/review/config-desktop.png differ diff --git a/apps/frontend/.impeccable/review/config-saved-desktop.png b/apps/frontend/.impeccable/review/config-saved-desktop.png new file mode 100644 index 0000000..a5732e5 Binary files /dev/null and b/apps/frontend/.impeccable/review/config-saved-desktop.png differ diff --git a/apps/frontend/.impeccable/review/usuarios-remove-btn-desktop.png b/apps/frontend/.impeccable/review/usuarios-remove-btn-desktop.png new file mode 100644 index 0000000..cc35cca Binary files /dev/null and b/apps/frontend/.impeccable/review/usuarios-remove-btn-desktop.png differ diff --git a/apps/frontend/src/app/app/administracao/configuracoes/actions.ts b/apps/frontend/src/app/app/administracao/configuracoes/actions.ts new file mode 100644 index 0000000..86bbf6f --- /dev/null +++ b/apps/frontend/src/app/app/administracao/configuracoes/actions.ts @@ -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("/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) }; + } +} diff --git a/apps/frontend/src/app/app/administracao/configuracoes/configuracoes-view.tsx b/apps/frontend/src/app/app/administracao/configuracoes/configuracoes-view.tsx new file mode 100644 index 0000000..3a88506 --- /dev/null +++ b/apps/frontend/src/app/app/administracao/configuracoes/configuracoes-view.tsx @@ -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(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 ( +
+
+

Configurações

+

+ 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. +

+
+ + + +
+ + +
+ Status +
+ {TENANT_STATUS_LABELS[settings.status]} +
+
+ + + + +
+
+ + +
+ +
+
+ Nome fantasia + setTradeName(e.target.value)} disabled={pending} /> +
+
+ CNPJ/CPF + setTaxId(e.target.value)} disabled={pending} className="font-mono" /> +
+
+ Fuso horário + setTimezone(e.target.value)} disabled={pending} className="font-mono" /> +
+
+ Idioma + setLocale(e.target.value)} disabled={pending} className="font-mono" /> +
+
+ Privacidade de IA + +

+ Nível padrão deste tenant — campanha e fila podem sobrescrever com um nível mais restrito, nunca mais + permissivo. +

+
+
+ {error && ( +

+ {error} +

+ )} +
+ {saved && !pending && ( + + Salvo + + )} + +
+ +
+
+ ); +} + +function ReadOnlyField({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} +

{value}

+
+ ); +} diff --git a/apps/frontend/src/app/app/administracao/configuracoes/page.tsx b/apps/frontend/src/app/app/administracao/configuracoes/page.tsx new file mode 100644 index 0000000..901e7c5 --- /dev/null +++ b/apps/frontend/src/app/app/administracao/configuracoes/page.tsx @@ -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("/tenant-settings", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/app/administracao/usuarios/actions.ts b/apps/frontend/src/app/app/administracao/usuarios/actions.ts index 4e73269..bb65fcc 100644 --- a/apps/frontend/src/app/app/administracao/usuarios/actions.ts +++ b/apps/frontend/src/app/app/administracao/usuarios/actions.ts @@ -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(`/users/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/app/administracao/usuarios"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/administracao/usuarios/usuarios-view.tsx b/apps/frontend/src/app/app/administracao/usuarios/usuarios-view.tsx index 4abf572..3d6aa3b 100644 --- a/apps/frontend/src/app/app/administracao/usuarios/usuarios-view.tsx +++ b/apps/frontend/src/app/app/administracao/usuarios/usuarios-view.tsx @@ -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(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 ( @@ -201,10 +219,24 @@ function MemberRow({ member, isSelf }: { member: TenantMember; isSelf: boolean }
{member.role?.name ?? "sem papel"} {!isSelf && ( - + <> + + + )} + {error && {error}}
)} diff --git a/apps/frontend/src/app/app/discador/callbacks/actions.ts b/apps/frontend/src/app/app/discador/callbacks/actions.ts new file mode 100644 index 0000000..1b97b41 --- /dev/null +++ b/apps/frontend/src/app/app/discador/callbacks/actions.ts @@ -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(`/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" }); +} diff --git a/apps/frontend/src/app/app/discador/callbacks/callbacks-view.tsx b/apps/frontend/src/app/app/discador/callbacks/callbacks-view.tsx new file mode 100644 index 0000000..b0d685c --- /dev/null +++ b/apps/frontend/src/app/app/discador/callbacks/callbacks-view.tsx @@ -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 ( +
+
+

Callbacks

+

+ 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. +

+
+ + + + {callbacks.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {callbacks.map((lead) => ( + + ))} + +
CampanhaNomeTelefoneTentativasRemarcado para + Ações +
+ )} +
+
+ ); +} + +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(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 ( + + + {lead.campaign.name} + + {lead.name ?? "—"} + {lead.phoneNormalized} + {lead.attemptCount} + + {rescheduling ? ( + setWhen(e.target.value)} disabled={pending} className="h-8 w-52" /> + ) : ( + {lead.nextAttemptAt ? formatDateTime(lead.nextAttemptAt) : "—"} + )} + + +
+ {error && {error}} + + {!rescheduling && ( + + )} + {!rescheduling && ( + + )} +
+ + + ); +} diff --git a/apps/frontend/src/app/app/discador/callbacks/page.tsx b/apps/frontend/src/app/app/discador/callbacks/page.tsx new file mode 100644 index 0000000..291a88d --- /dev/null +++ b/apps/frontend/src/app/app/discador/callbacks/page.tsx @@ -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("/leads/callbacks", session.accessToken); + return ; +} diff --git a/apps/frontend/src/components/tenant-shell/nav-data.ts b/apps/frontend/src/components/tenant-shell/nav-data.ts index 92723ef..ff02904 100644 --- a/apps/frontend/src/components/tenant-shell/nav-data.ts +++ b/apps/frontend/src/components/tenant-shell/nav-data.ts @@ -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", + }, ], }, ]; diff --git a/apps/frontend/src/lib/admin-types.ts b/apps/frontend/src/lib/admin-types.ts index 1282b12..3cf1ab6 100644 --- a/apps/frontend/src/lib/admin-types.ts +++ b/apps/frontend/src/lib/admin-types.ts @@ -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 = { + 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", +}; diff --git a/apps/frontend/src/lib/campaign-types.ts b/apps/frontend/src/lib/campaign-types.ts index ddfb63b..a062d28 100644 --- a/apps/frontend/src/lib/campaign-types.ts +++ b/apps/frontend/src/lib/campaign-types.ts @@ -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 = { NEW: "Novo", READY: "Pronto",