321 lines
14 KiB
Markdown
321 lines
14 KiB
Markdown
# Implementation Plan: Enriquecimento do Perfil de Cliente
|
|
|
|
**Branch**: `011-enrich-client-profile` | **Date**: 2026-04-14 | **Spec**: [spec.md](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 Alembic
|
|
- **`down_revision`**: `f1a2b3c4d5e6` (último migration existente: add_iptu_anual)
|
|
- **Estratégia**: `op.batch_alter_table` para 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**:
|
|
```python
|
|
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 `ClientUser` em `backend/app/models/user.py` já 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`
|
|
```python
|
|
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`
|
|
```python
|
|
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`
|
|
```python
|
|
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):
|
|
```python
|
|
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`
|
|
|
|
```typescript
|
|
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**:
|
|
- `useState` para lista de clientes, loading, erro, termo de busca, cliente selecionado, modo (`list` | `form`)
|
|
- `useEffect` → `GET /api/v1/admin/clientes` ao montar
|
|
- **Avatar com iniciais**: `div` circular com fundo `#5e6ad2`, iniciais em uppercase
|
|
- **Barra de busca**: input controlado que filtra `filteredClientes` em memória por `name`, `email`, `phone`, `cpf`
|
|
- **Tabela responsiva**:
|
|
- Mobile: nome, avatar, e-mail, ações
|
|
- Desktop: + telefone (`tel:` link), WhatsApp (ícone abre `https://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 `ClienteForm` sem `clienteId`
|
|
|
|
### 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/clientes` com `ClientUserCreateIn`
|
|
- Edição: `PUT /api/v1/admin/clientes/{id}` com `ClientUserUpdateIn` (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
|
|
|
|
1. Gerar migration vazia: `flask db revision --autogenerate -m "enrich_client_users_profile"`
|
|
2. Revisar script gerado — confirmar que `down_revision = 'f1a2b3c4d5e6'`
|
|
3. Usar `batch_alter_table` para compatibilidade com SQLite (conftest de testes usa SQLite in-memory)
|
|
4. Executar: `flask db upgrade head`
|
|
5. Verificar colunas: `\d client_users` no psql
|
|
|
|
> **Atenção**: O model já está sincronizado. Se `autogenerate` detectar diferença entre o model e o banco, ele gerará as colunas automaticamente. Caso contrário, escrever o `upgrade()` manualmente conforme o script acima.
|
|
|
|
## Project Structure
|
|
|
|
### Documentation (this feature)
|
|
|
|
```text
|
|
.specify/features/011-enrich-client-profile/
|
|
├── plan.md # Este arquivo
|
|
└── tasks.md # Fase 2 (gerado por /speckit.tasks)
|
|
```
|
|
|
|
### Source Code (repository root)
|
|
|
|
```text
|
|
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 |
|