# Tasks: Feature 027 — Configuração da Página de Contato (Admin) **Branch**: `027-config-pagina-contato` **Spec**: `specs/027-config-pagina-contato/spec.md` **Última migration**: `g1h2i3j4k5l6_add_source_to_contact_leads.py` --- ## Fase 1 — Foundational: Backend Core (Pré-requisito para todos os user stories) > **Objetivo**: Criar a tabela `contact_config`, o modelo ORM, os schemas Pydantic e o > endpoint público de leitura. Nenhum user story pode ser implementado antes desta fase. > > **⚠️ CRÍTICO**: Concluir inteiramente antes de iniciar as fases 2 e 3. - [ ] T001 Gerar migration Alembic para criar tabela `contact_config` com INSERT inicial em `backend/migrations/versions/h2i3j4k5l6m7_add_contact_config.py` **Comando para gerar a migration** (executar de dentro do container ou com `.venv`): ```bash flask --app run:app db revision --autogenerate -m "add_contact_config" ``` A migration deve: 1. Criar a tabela com os campos abaixo: ```python op.create_table( "contact_config", sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), sa.Column("address_street", sa.String(200), nullable=False), sa.Column("address_neighborhood_city", sa.String(200), nullable=False), sa.Column("address_zip", sa.String(20), nullable=False), sa.Column("phone", sa.String(30), nullable=False), sa.Column("email", sa.String(254), nullable=False), sa.Column("business_hours", sa.Text, nullable=False), sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.func.now()), ) ``` 2. Inserir o registro singleton com os valores atualmente hardcoded: ```python op.execute(""" INSERT INTO contact_config (id, address_street, address_neighborhood_city, address_zip, phone, email, business_hours, updated_at) VALUES (1, 'Rua das Imobiliárias, 123', 'Centro — São Paulo, SP', 'CEP 01000-000', '(11) 99999-0000', 'contato@imobiliariahub.com.br', 'Segunda a Sexta: 9h às 18h\nSábado: 9h às 13h', NOW()) """) ``` - [ ] T002 Criar modelo `ContactConfig` em `backend/app/models/contact_config.py` seguindo o padrão de `HomepageConfig` ```python from app.extensions import db class ContactConfig(db.Model): __tablename__ = "contact_config" id = db.Column(db.Integer, primary_key=True, autoincrement=True) address_street = db.Column(db.String(200), nullable=False) address_neighborhood_city = db.Column(db.String(200), nullable=False) address_zip = db.Column(db.String(20), nullable=False) phone = db.Column(db.String(30), nullable=False) email = db.Column(db.String(254), nullable=False) business_hours = db.Column(db.Text, nullable=False) updated_at = db.Column( db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now(), ) def __repr__(self) -> str: return f"" ``` - [ ] T003 [P] Criar schemas Pydantic em `backend/app/schemas/contact_config.py` seguindo o padrão de `HomepageConfigOut`/`HomepageConfigIn` ```python from __future__ import annotations from datetime import datetime from pydantic import BaseModel, ConfigDict, EmailStr, field_validator class ContactConfigOut(BaseModel): model_config = ConfigDict(from_attributes=True) address_street: str address_neighborhood_city: str address_zip: str phone: str email: str business_hours: str updated_at: datetime class ContactConfigIn(BaseModel): address_street: str address_neighborhood_city: str address_zip: str phone: str email: EmailStr business_hours: str @field_validator("address_street", "address_neighborhood_city", "address_zip", "phone", "business_hours") @classmethod def not_empty(cls, v: str) -> str: if not v.strip(): raise ValueError("Campo não pode ser vazio") return v ``` - [ ] T004 Criar rota pública `GET /api/v1/contact-config` em `backend/app/routes/contact_config.py` e registrar o blueprint em `backend/app/__init__.py` **`backend/app/routes/contact_config.py`**: ```python from flask import Blueprint, jsonify from app.models.contact_config import ContactConfig from app.schemas.contact_config import ContactConfigOut contact_config_bp = Blueprint("contact_config", __name__, url_prefix="/api/v1") @contact_config_bp.get("/contact-config") def get_contact_config(): config = ContactConfig.query.first() if config is None: return jsonify({"error": "Contact config not found"}), 404 return jsonify(ContactConfigOut.model_validate(config).model_dump(mode="json")) ``` **`backend/app/__init__.py`** — adicionar após o import de `homepage_bp`: ```python from app.routes.contact_config import contact_config_bp ``` E registrar junto aos demais blueprints: ```python app.register_blueprint(contact_config_bp) ``` **Checkpoint — Fase 1 concluída**: `GET /api/v1/contact-config` retorna os dados do banco. As fases 2 e 3 podem ser iniciadas em paralelo. --- ## Fase 2 — User Stories 1 + 3: Admin Edita Configuração e Endpoint Protegido (P1) > **Objetivo**: Administrador acessa `/admin/contact-config`, vê o formulário preenchido > com os valores atuais, edita e salva. O endpoint PUT rejeita acessos não autorizados. > > **Teste independente**: Autenticar como admin, acessar `/admin/contact-config`, > alterar o telefone, clicar em "Salvar". Verificar `GET /api/v1/contact-config` retorna o > novo valor. Verificar que `PUT /api/v1/admin/contact-config` sem token retorna HTTP 401. ### Implementação — User Stories 1 + 3 - [ ] T005 [US1] Adicionar rota `PUT /api/v1/admin/contact-config` em `backend/app/routes/admin.py` com `@require_admin` **Adicionar imports** no topo de `backend/app/routes/admin.py`: ```python from app.models.contact_config import ContactConfig from app.schemas.contact_config import ContactConfigIn, ContactConfigOut ``` **Adicionar a rota** (em qualquer ponto lógico do arquivo, ex.: próximo a outras rotas de configuração): ```python @admin_bp.put("/contact-config") @require_admin def update_contact_config(): try: data = ContactConfigIn.model_validate(request.get_json(force=True) or {}) except ValidationError as exc: return jsonify({"errors": exc.errors()}), 422 config = ContactConfig.query.first() if config is None: config = ContactConfig(id=1, **data.model_dump()) db.session.add(config) else: for field, value in data.model_dump().items(): setattr(config, field, value) db.session.commit() db.session.refresh(config) return jsonify(ContactConfigOut.model_validate(config).model_dump(mode="json")) ``` > `@require_admin` garante HTTP 401 para não-autenticados e HTTP 403 para não-admins (US3). - [ ] T006 [P] [US1] Criar `frontend/src/services/contactConfig.ts` com `getContactConfig()` e `updateContactConfig()` ```typescript import { api } from './api' export interface ContactConfig { address_street: string address_neighborhood_city: string address_zip: string phone: string email: string business_hours: string updated_at: string } export interface ContactConfigInput { address_street: string address_neighborhood_city: string address_zip: string phone: string email: string business_hours: string } export async function getContactConfig(): Promise { const response = await api.get('/contact-config') return response.data } export async function updateContactConfig(data: ContactConfigInput): Promise { const response = await api.put('/admin/contact-config', data) return response.data } ``` - [ ] T007 [US1] Criar `frontend/src/pages/admin/AdminContactConfigPage.tsx` seguindo o padrão visual das demais páginas admin (ex.: `AdminAgentsPage.tsx`) **Comportamento esperado**: - `useEffect` faz `GET /api/v1/contact-config` ao montar e pré-preenche o form - Estado local `form` com os 6 campos editáveis - `handleSubmit` chama `updateContactConfig(form)`, exibe toast de sucesso ou erro - Botão "Salvar" desabilitado enquanto `saving === true` (FR-019) - Validação frontend: e-mail com formato válido, campos não vazios antes de submeter (FR-017) - Erros de campo exibidos inline; toast global para erros de rede **Estrutura do componente**: ```tsx import { useState, useEffect } from 'react' import { getContactConfig, updateContactConfig } from '../../services/contactConfig' import type { ContactConfigInput } from '../../services/contactConfig' const emptyForm: ContactConfigInput = { address_street: '', address_neighborhood_city: '', address_zip: '', phone: '', email: '', business_hours: '', } export default function AdminContactConfigPage() { const [form, setForm] = useState(emptyForm) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [success, setSuccess] = useState(false) const [error, setError] = useState(null) useEffect(() => { getContactConfig() .then(data => { const { updated_at, ...editable } = data setForm(editable) }) .finally(() => setLoading(false)) }, []) async function handleSubmit(e: React.FormEvent) { e.preventDefault() setSaving(true) setError(null) setSuccess(false) try { await updateContactConfig(form) setSuccess(true) } catch { setError('Erro ao salvar. Tente novamente.') } finally { setSaving(false) } } // Renderizar: loading skeleton → formulário com 5 inputs + 1 textarea + botão salvar // Campos: Logradouro, Bairro/Cidade, CEP, Telefone, E-mail, Horário de Atendimento } ``` - [ ] T008 [US1] Registrar rota `/admin/contact-config` em `frontend/src/App.tsx` e adicionar item `{ to: '/admin/contact-config', label: 'Conf. Contato' }` em `adminNavItems` em `frontend/src/components/Navbar.tsx` **`frontend/src/App.tsx`** — localizar o trecho de rotas admin e adicionar: ```tsx import AdminContactConfigPage from './pages/admin/AdminContactConfigPage' // ... } /> ``` **`frontend/src/components/Navbar.tsx`** — acrescentar ao array `adminNavItems`: ```typescript { to: '/admin/contact-config', label: 'Conf. Contato' }, ``` **Checkpoint — Fase 2 concluída**: Admin consegue editar e salvar a configuração de contato. Endpoint PUT retorna 401/403 para acessos não autorizados. --- ## Fase 3 — User Story 2: Página Pública de Contato Exibe Dados Dinâmicos (P1) > **Objetivo**: A página `/contato` deixa de usar dados hardcoded e passa a consumir > `GET /api/v1/contact-config`, preservando layout e estrutura visual existentes. > > **Teste independente**: Sem autenticação, acessar `/contato` e verificar que os dados > exibidos correspondem ao banco (alterado via painel admin). A estrutura visual não muda. ### Implementação — User Story 2 - [ ] T009 [US2] Atualizar `frontend/src/pages/ContactPage.tsx` para consumir `getContactConfig()` no lugar dos dados hardcoded **Comportamento esperado**: - `useEffect` chama `getContactConfig()` ao montar - Estado `config` inicializado como `null`; enquanto `loading === true` exibir skeleton ou spinner no lugar dos dados de contato (FR-021) - Em caso de erro na requisição, exibir mensagem informativa em lugar dos dados — não renderizar valores obsoletos nem lançar erro de renderização (FR-022) - Layout, classes CSS e demais seções da página NÃO devem ser alterados (FR-023) **Campos a substituir** (remover literais hardcoded e usar `config.campo`): - Endereço: `config.address_street`, `config.address_neighborhood_city`, `config.address_zip` - Telefone: `config.phone` - E-mail: `config.email` - Horário de atendimento: `config.business_hours` (renderizar com `white-space: pre-line` ou equivalente para preservar quebras de linha) **Exemplo de estrutura**: ```tsx import { useState, useEffect } from 'react' import { getContactConfig } from '../services/contactConfig' import type { ContactConfig } from '../services/contactConfig' export default function ContactPage() { const [config, setConfig] = useState(null) const [loading, setLoading] = useState(true) const [fetchError, setFetchError] = useState(false) useEffect(() => { getContactConfig() .then(setConfig) .catch(() => setFetchError(true)) .finally(() => setLoading(false)) }, []) // ...restante do JSX existente — apenas substituir as strings hardcoded // por {loading ? : fetchError ?

Informações indisponíveis

: config?.campo} } ``` **Checkpoint — Fase 3 concluída**: `/contato` exibe dados dinâmicos da API. Todos os user stories são funcionais e testáveis de forma independente. --- ## Fase 4 — Polish & Verificações Finais - [ ] T010 [P] Verificar que `backend/app/models/__init__.py` exporta `ContactConfig` (se o arquivo contiver imports explícitos dos modelos) Se o arquivo importar modelos explicitamente, adicionar: ```python from app.models.contact_config import ContactConfig # noqa: F401 ``` - [ ] T011 [P] Aplicar a migration no banco de dados e verificar o registro singleton ```bash # Dentro do container ou com .venv ativo: flask --app run:app db upgrade # Verificar: # SELECT * FROM contact_config; → deve retornar 1 linha com id=1 ``` --- ## Dependências entre Tasks ``` T001 → T002 → T003 → T004 (blueprint público) ↓ T005 (PUT admin) ↓ T006 → T007 → T008 (rotas frontend) T006 → T009 (ContactPage) ``` **Execução paralela possível**: - T003 pode começar em paralelo com T002 (schemas não importam o modelo diretamente) - T006, T007, T008, T009 podem ser desenvolvidos em paralelo após T001–T004 --- ## Escopo MVP O **MVP mínimo** é completar as fases 1, 2 e 3 integralmente — as três user stories têm prioridade P1 e são interdependentes para entregar valor. A fase 4 é verificação final.