14 KiB
Implementation Plan: Enriquecimento do Perfil de Cliente
Branch: 011-enrich-client-profile | Date: 2026-04-14 | Spec: spec.md
Depends On: Feature 005 — ClientUser model base; Feature 007 — admin panel routes/pages
Summary
Expansão do perfil de cliente com 12 novas colunas opcionais na tabela client_users (contato, dados pessoais, endereço, observações). Reescrita completa de AdminClientesPage.tsx (tabela rica com avatar, busca, links de contato) e ClienteForm.tsx (formulário full-screen com 5 seções e máscaras). Backend recebe migration Alembic, atualização do model SQLAlchemy e novos schemas Pydantic, além de atualização dos handlers de criação/edição.
Technical Context
Language/Version: Python 3.12 (backend) · TypeScript 5.5 (frontend)
Primary Dependencies: Flask 3.x, SQLAlchemy 2.x, Pydantic v2 (backend) · React 18, Tailwind CSS 3.4, react-router-dom v6, Axios (frontend)
Storage: PostgreSQL 16 — 12 novas colunas nullable em client_users
Testing: pytest (backend) — testes de rotas admin ; Vite build check (frontend)
Target Platform: Servidor Linux (Docker) + SPA na mesma origem via proxy Vite
Project Type: Web service (Flask REST API) + SPA (React)
Performance Goals: Listagem de clientes renderizada < 1 s; busca local (sem debounce de rede) < 100 ms
Constraints: Todos os 12 campos novos são opcionais (nullable no DB, Optional no schema); sem validação de CPF por dígito verificador no MVP; máscaras são apenas visuais (frontend-only)
Scale/Scope: MVP — dados pessoais e endereço completos; sem integração com correios/CEP lookup
Constitution Check
| Princípio | Status | Observação |
|---|---|---|
| I. Design-First | ✅ PASS | Avatar com iniciais usa fundo #5e6ad2; tabela usa tokens de cor dark existentes; formulário segue padrão PropertyForm. |
| II. Separation of Concerns | ✅ PASS | Backend retorna JSON puro. Máscaras são responsabilidade exclusiva do frontend. |
| III. Spec-Driven | ✅ PASS | spec.md aprovado → plan.md (este) → tasks.md → implementação. |
| IV. Data Integrity | ✅ PASS | 12 colunas nullable; entrada validada via Pydantic antes de persistir. cpf armazenado sem formatação (apenas dígitos) — máscara é aplicada só na UI. |
| V. Security | ✅ PASS | Endpoints /api/v1/admin/clientes/* já protegidos por require_auth + verificação de role admin. Nenhuma rota nova pública. |
| VI. Simplicity First | ✅ PASS | Busca filtrada no frontend (sem endpoint de search). Sem lookup de CEP externo. Sem validação de dígito verificador de CPF. |
POST-DESIGN RE-CHECK: ✅ A adição de 12 colunas nullable não introduz lógica de negócio nova. Reescrita de components é cirúrgica — apenas dois arquivos de página/formulário.
Architecture Overview
Migration Alembic
└── adiciona 12 colunas nullable em client_users
│
▼
SQLAlchemy Model (user.py) ◄── já contém as 12 colunas (model estava adiantado)
│
▼
Pydantic Schemas (auth.py ou novo admin_clients.py)
├── ClientUserOut — leitura (inclui todos os campos)
├── ClientUserCreateIn — criação (campos obrigatórios + 12 opcionais)
└── ClientUserUpdateIn — edição (todos Optional)
│
▼
Route Handlers (admin.py)
├── GET /api/v1/admin/clientes — lista com novos campos
├── POST /api/v1/admin/clientes — cria com novos campos
└── PUT /api/v1/admin/clientes/<id> — atualiza com novos campos
│
▼
Frontend Services (adminService.ts ou clienteService.ts)
│
▼
AdminClientesPage.tsx ◄── reescrita: tabela rica + busca + avatar
ClienteForm.tsx ◄── reescrita: full-screen + 5 seções + máscaras
Database Changes
Migration: xxxx_enrich_client_users_profile.py
- Arquivo:
backend/migrations/versions/xxxx_enrich_client_users_profile.py revision: gerado automaticamente pelo Alembicdown_revision:f1a2b3c4d5e6(último migration existente: add_iptu_anual)- Estratégia:
op.batch_alter_tablepara compatibilidade SQLite em testes
Colunas adicionadas (todas nullable=True):
| Coluna | Tipo SQLAlchemy | Tamanho | Observação |
|---|---|---|---|
phone |
String |
20 | Telefone fixo ou celular |
whatsapp |
String |
20 | Número WhatsApp |
cpf |
String |
14 | Armazenado sem máscara (11 dígitos) |
birth_date |
Date |
— | Data de nascimento |
address_street |
String |
200 | Logradouro |
address_number |
String |
20 | Número |
address_complement |
String |
100 | Complemento |
address_neighborhood |
String |
100 | Bairro |
address_city |
String |
100 | Cidade |
address_state |
String |
2 | UF (2 chars) |
address_zip |
String |
9 | CEP com hífen (8 dígitos) |
notes |
Text |
— | Observações internas do admin |
Upgrade:
def upgrade():
with op.batch_alter_table('client_users', schema=None) as batch_op:
batch_op.add_column(sa.Column('phone', sa.String(20), nullable=True))
batch_op.add_column(sa.Column('whatsapp', sa.String(20), nullable=True))
batch_op.add_column(sa.Column('cpf', sa.String(14), nullable=True))
batch_op.add_column(sa.Column('birth_date', sa.Date(), nullable=True))
batch_op.add_column(sa.Column('address_street', sa.String(200), nullable=True))
batch_op.add_column(sa.Column('address_number', sa.String(20), nullable=True))
batch_op.add_column(sa.Column('address_complement', sa.String(100), nullable=True))
batch_op.add_column(sa.Column('address_neighborhood', sa.String(100), nullable=True))
batch_op.add_column(sa.Column('address_city', sa.String(100), nullable=True))
batch_op.add_column(sa.Column('address_state', sa.String(2), nullable=True))
batch_op.add_column(sa.Column('address_zip', sa.String(9), nullable=True))
batch_op.add_column(sa.Column('notes', sa.Text(), nullable=True))
Downgrade: batch_op.drop_column para cada coluna na ordem inversa.
Nota: O model
ClientUserembackend/app/models/user.pyjá possui as 12 colunas declaradas (adiantado manualmente). A migration é necessária para sincronizar o banco existente.
Backend Changes
1. SQLAlchemy Model (backend/app/models/user.py)
Status: ✅ Já implementado — as 12 colunas estão declaradas no model atual. Nenhuma alteração necessária; apenas a migration precisa ser executada.
2. Pydantic Schemas
Arquivo: backend/app/schemas/auth.py (atualizar) ou extrair para backend/app/schemas/admin_clients.py
ClientUserOut
class ClientUserOut(BaseModel):
id: str
name: str
email: str
role: str
created_at: datetime
# Contato
phone: Optional[str] = None
whatsapp: Optional[str] = None
# Dados pessoais
cpf: Optional[str] = None
birth_date: Optional[date] = None
# Endereço
address_street: Optional[str] = None
address_number: Optional[str] = None
address_complement: Optional[str] = None
address_neighborhood: Optional[str] = None
address_city: Optional[str] = None
address_state: Optional[str] = None
address_zip: Optional[str] = None
# Observações
notes: Optional[str] = None
model_config = {"from_attributes": True}
ClientUserCreateIn
class ClientUserCreateIn(BaseModel):
name: str
email: EmailStr
password: str
role: str = "client"
# 12 campos opcionais (mesmos tipos de ClientUserOut)
phone: Optional[str] = None
# ... demais 11 campos
ClientUserUpdateIn
class ClientUserUpdateIn(BaseModel):
name: Optional[str] = None
email: Optional[EmailStr] = None
password: Optional[str] = None
role: Optional[str] = None
# 12 campos opcionais
phone: Optional[str] = None
# ... demais 11 campos
3. Route Handlers (backend/app/routes/admin.py)
Endpoints já existem. Alterações necessárias:
| Endpoint | Ação | Mudança |
|---|---|---|
GET /api/v1/admin/clientes |
Listar | Substituir UserOut → ClientUserOut na serialização |
POST /api/v1/admin/clientes |
Criar | Validar com ClientUserCreateIn; persistir os 12 novos campos |
PUT /api/v1/admin/clientes/<id> |
Editar | Validar com ClientUserUpdateIn; atualizar campos presentes (ignorar None) |
Padrão de update (apenas campos enviados):
data = ClientUserUpdateIn(**request.get_json())
for field, value in data.model_dump(exclude_none=True).items():
if field == "password":
setattr(user, "password_hash", bcrypt.hashpw(...))
else:
setattr(user, field, value)
Frontend Changes
1. Types (frontend/src/types/)
Arquivo: frontend/src/types/clientUser.ts (novo) ou atualizar frontend/src/types/index.ts
export interface ClientUser {
id: string;
name: string;
email: string;
role: string;
created_at: string;
phone?: string;
whatsapp?: string;
cpf?: string;
birth_date?: string;
address_street?: string;
address_number?: string;
address_complement?: string;
address_neighborhood?: string;
address_city?: string;
address_state?: string;
address_zip?: string;
notes?: string;
}
2. AdminClientesPage.tsx — Reescrita completa
Arquivo: frontend/src/pages/admin/AdminClientesPage.tsx
Funcionalidades:
useStatepara lista de clientes, loading, erro, termo de busca, cliente selecionado, modo (list|form)useEffect→GET /api/v1/admin/clientesao montar- Avatar com iniciais:
divcircular com fundo#5e6ad2, iniciais em uppercase - Barra de busca: input controlado que filtra
filteredClientesem memória porname,email,phone,cpf - Tabela responsiva:
- Mobile: nome, avatar, e-mail, ações
- Desktop: + telefone (
tel:link), WhatsApp (ícone abrehttps://wa.me/55{numero}), CPF, endereço resumido (bairro, cidade/UF), tipo, cadastro
- Ações por linha: botão Editar → abre
ClienteForm; botão Excluir →DELETE+ confirm - Botão "Novo Cliente" abre
ClienteFormsemclienteId
3. ClienteForm.tsx — Reescrita completa
Arquivo: frontend/src/pages/admin/ClienteForm.tsx
Layout: Full-screen (mesmo padrão de PropertyForm.tsx), com header fixo contendo título e botões Cancelar/Salvar.
5 Seções:
| Seção | Campos |
|---|---|
| Dados Pessoais | Nome*, CPF (máscara 000.000.000-00), Data de Nascimento |
| Contato | Telefone (máscara (00) 00000-0000), WhatsApp (máscara (00) 00000-0000) |
| Endereço | CEP (máscara 00000-000), Logradouro, Número, Complemento, Bairro, Cidade, Estado (select UF) |
| Acesso | E-mail*, Senha (obrigatória no create, opcional no edit), Tipo (client / admin) |
| Observações | textarea para notas internas |
Máscaras (implementação manual via onChange, sem lib externa):
- CPF:
###.###.###-## - Telefone/WhatsApp:
(##) #####-#### - CEP:
#####-###
Submit:
- Criação:
POST /api/v1/admin/clientescomClientUserCreateIn - Edição:
PUT /api/v1/admin/clientes/{id}comClientUserUpdateIn(apenas campos alterados) - Senha omitida no payload de edição se campo vazio
Validação frontend (mínima — backend é fonte da verdade):
- Nome: não vazio
- E-mail: formato básico
- Senha: mínimo 8 chars se preenchida
Migration Strategy
- Gerar migration vazia:
flask db revision --autogenerate -m "enrich_client_users_profile" - Revisar script gerado — confirmar que
down_revision = 'f1a2b3c4d5e6' - Usar
batch_alter_tablepara compatibilidade com SQLite (conftest de testes usa SQLite in-memory) - Executar:
flask db upgrade head - Verificar colunas:
\d client_usersno psql
Atenção: O model já está sincronizado. Se
autogeneratedetectar diferença entre o model e o banco, ele gerará as colunas automaticamente. Caso contrário, escrever oupgrade()manualmente conforme o script acima.
Project Structure
Documentation (this feature)
.specify/features/011-enrich-client-profile/
├── plan.md # Este arquivo
└── tasks.md # Fase 2 (gerado por /speckit.tasks)
Source Code (repository root)
backend/
├── app/
│ ├── models/
│ │ └── user.py # SEM ALTERAÇÃO — 12 colunas já presentes
│ ├── schemas/
│ │ └── auth.py # ATUALIZAR — adicionar ClientUserOut, ClientUserCreateIn, ClientUserUpdateIn
│ └── routes/
│ └── admin.py # ATUALIZAR — usar novos schemas nos endpoints de clientes
└── migrations/
└── versions/
└── xxxx_enrich_client_users_profile.py # NOVO — batch_alter_table 12 colunas
frontend/
└── src/
├── types/
│ └── clientUser.ts # NOVO — interface ClientUser expandida
└── pages/
└── admin/
├── AdminClientesPage.tsx # REESCRITA — tabela rica + busca + avatar
└── ClienteForm.tsx # REESCRITA — 5 seções + máscaras
Complexity Tracking
| Item | Decisão | Alternativas rejeitadas |
|---|---|---|
| Máscaras de campo | Implementação manual via onChange |
react-input-mask ou imask — dependência extra para funcionalidade trivial |
| Busca de clientes | Filtragem local no frontend | Endpoint GET /clientes?q=... — overkill para o volume esperado de clientes no MVP |
| Armazenamento de CPF | Somente dígitos no banco, máscara apenas na UI | Armazenar formatado — dificulta queries futuras |
| Validação de CPF | Apenas formato (tamanho) | Dígito verificador — custo/benefício baixo no MVP |
| Formulário full-screen | Padrão de PropertyForm reutilizado |
Modal/slide-over — inconsistente com o padrão do admin panel |