feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
- feat(025): favoritos locais com FavoritesContext, HeartButton, PublicFavoritesPage
- feat(026): central de contatos admin (leads/contatos unificados)
- feat(027): configuração da página de contato via admin
- feat(028): trabalhe conosco - candidaturas com upload e admin
- feat(029): UX área do cliente - visitas, comparação, perfil
- feat(030): navbar UX - menu mobile, ThemeToggle, useFavorites
- feat(031): hero light/dark - imagens separadas por tema, upload, preview, seed
- feat(032): performance homepage - Promise.all parallel fetches, sessionStorage cache,
preload hero image, loading=lazy nos cards, useInView hook, will-change carrossel,
keyframes em index.css, AgentsCarousel e HomeScrollScene via props
- fix: light mode HomeScrollScene - gradiente, cores de texto, scroll hint
migrations: g1h2i3j4k5l6 (source em leads), h1i2j3k4l5m6 (contact_config),
i1j2k3l4m5n6 (job_applications), j2k3l4m5n6o7 (hero theme images)
This commit is contained in:
parent
6ef5a7a17e
commit
cf5603243c
106 changed files with 11927 additions and 1367 deletions
994
specs/029-ux-area-do-cliente/tasks.md
Normal file
994
specs/029-ux-area-do-cliente/tasks.md
Normal file
|
|
@ -0,0 +1,994 @@
|
|||
# Tasks — Feature 029: Revisão UX/UI da Área do Cliente
|
||||
|
||||
**Branch**: `029-ux-area-do-cliente`
|
||||
**Gerado em**: 2026-04-22
|
||||
**Status**: Ready for implementation
|
||||
|
||||
---
|
||||
|
||||
## Fase 1 — Backend: Schemas
|
||||
|
||||
### T01 — Adicionar `PropertyCard` e schemas de profile/senha em `client_area.py`
|
||||
|
||||
**Arquivo**: `backend/app/schemas/client_area.py`
|
||||
|
||||
**Contexto**: O schema `PropertyBrief` só carrega `id`, `title`, `slug`. Os cards de favoritos precisam de `price`, `city`, `neighborhood` e `cover_photo_url`. Além disso, os endpoints de perfil e senha precisam de schemas de entrada/saída que não existem.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Abrir `backend/app/schemas/client_area.py`.
|
||||
2. Adicionar import `from typing import Optional` (já existe) e `from decimal import Decimal` (já existe).
|
||||
3. Adicionar o schema `PropertyCard` logo após `PropertyBrief`:
|
||||
```python
|
||||
class PropertyCard(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
slug: str
|
||||
price: Optional[Decimal] = None
|
||||
city: Optional[str] = None
|
||||
neighborhood: Optional[str] = None
|
||||
cover_photo_url: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@classmethod
|
||||
def from_property(cls, prop) -> "PropertyCard":
|
||||
"""Constrói PropertyCard a partir de um ORM Property."""
|
||||
cover = prop.photos[0].url if prop.photos else None
|
||||
city = prop.city.name if prop.city else None
|
||||
neighborhood = prop.neighborhood.name if prop.neighborhood else None
|
||||
return cls(
|
||||
id=str(prop.id),
|
||||
title=prop.title,
|
||||
slug=prop.slug,
|
||||
price=prop.price,
|
||||
city=city,
|
||||
neighborhood=neighborhood,
|
||||
cover_photo_url=cover,
|
||||
)
|
||||
```
|
||||
4. Alterar `SavedPropertyOut` para usar `PropertyCard` em vez de `PropertyBrief`:
|
||||
```python
|
||||
class SavedPropertyOut(BaseModel):
|
||||
id: str
|
||||
property_id: Optional[str]
|
||||
property: Optional[PropertyCard] # era PropertyBrief
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
```
|
||||
5. Adicionar os schemas de profile e senha ao final do arquivo:
|
||||
```python
|
||||
class UpdateProfileIn(BaseModel):
|
||||
name: str
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("Nome não pode ser vazio")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class UpdateProfileOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UpdatePasswordIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def min_length(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("A nova senha deve ter pelo menos 8 caracteres")
|
||||
return v
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] `PropertyCard` existe em `client_area.py` com os campos: `id`, `title`, `slug`, `price`, `city`, `neighborhood`, `cover_photo_url`.
|
||||
- [ ] `PropertyCard.from_property()` extrai `photos[0].url` como cover e `city.name` / `neighborhood.name` dos relacionamentos ORM.
|
||||
- [ ] `SavedPropertyOut.property` usa `PropertyCard` (não mais `PropertyBrief`).
|
||||
- [ ] `UpdateProfileIn`, `UpdateProfileOut` e `UpdatePasswordIn` existem com as validações descritas.
|
||||
|
||||
---
|
||||
|
||||
## Fase 2 — Backend: Endpoints
|
||||
|
||||
### T02 — Adicionar eager load de fotos na query de favoritos
|
||||
|
||||
**Arquivo**: `backend/app/routes/client_area.py`
|
||||
|
||||
**Contexto**: `SavedProperty.property` usa `lazy="joined"`, mas `Property.photos` usa `lazy="select"`. Sem `selectinload`, cada card de favorito dispara uma query extra para buscar as fotos (N+1). Precisa carregar as fotos junto com os favoritos.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Adicionar import no topo do arquivo:
|
||||
```python
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.models.property import Property as PropertyModel
|
||||
```
|
||||
2. Localizar a função `get_favorites` (rota `GET /favorites`).
|
||||
3. Substituir a query atual:
|
||||
```python
|
||||
# Antes:
|
||||
saved = (
|
||||
SavedProperty.query.filter_by(user_id=g.current_user_id)
|
||||
.order_by(SavedProperty.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
```
|
||||
Por:
|
||||
```python
|
||||
# Depois:
|
||||
saved = (
|
||||
SavedProperty.query
|
||||
.filter_by(user_id=g.current_user_id)
|
||||
.options(selectinload(SavedProperty.property).selectinload(PropertyModel.photos))
|
||||
.order_by(SavedProperty.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
```
|
||||
4. Atualizar os imports de schemas no topo do arquivo — `SavedPropertyOut` agora serializa `PropertyCard`; não é necessária mudança de código aqui pois o schema foi alterado em T01, mas verificar que `PropertyCard` está disponível via `SavedPropertyOut`.
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] A query de favoritos usa `selectinload` para `property` → `photos`.
|
||||
- [ ] O endpoint `GET /me/favorites` retorna o campo `property.cover_photo_url` com a URL da primeira foto (ou `null`).
|
||||
- [ ] O endpoint retorna `property.price`, `property.city`, `property.neighborhood`.
|
||||
- [ ] Testado manualmente: chamada para `GET /api/me/favorites` retorna JSON com campos `price`, `city`, `neighborhood`, `cover_photo_url` no objeto `property`.
|
||||
|
||||
---
|
||||
|
||||
### T03 — Implementar `PATCH /me/profile`
|
||||
|
||||
**Arquivo**: `backend/app/routes/client_area.py`
|
||||
|
||||
**Contexto**: Endpoint inexistente. Deve atualizar `ClientUser.name` do usuário autenticado.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Adicionar import:
|
||||
```python
|
||||
import bcrypt
|
||||
from app.models.user import ClientUser
|
||||
from app.schemas.client_area import UpdateProfileIn, UpdateProfileOut, UpdatePasswordIn
|
||||
```
|
||||
2. Adicionar ao final do arquivo (antes de qualquer `if __name__`):
|
||||
```python
|
||||
@client_bp.patch("/profile")
|
||||
@require_auth
|
||||
def update_profile():
|
||||
try:
|
||||
data = UpdateProfileIn.model_validate(request.get_json() or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": e.errors(include_url=False)}), 422
|
||||
|
||||
user = db.session.get(ClientUser, g.current_user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "Usuário não encontrado"}), 404
|
||||
|
||||
user.name = data.name
|
||||
db.session.commit()
|
||||
return jsonify(UpdateProfileOut.model_validate(user).model_dump(mode="json")), 200
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] `PATCH /api/me/profile` com `{ "name": "Novo Nome" }` retorna `200` com `{ id, name, email }`.
|
||||
- [ ] `PATCH /api/me/profile` com `{ "name": "" }` retorna `422`.
|
||||
- [ ] `PATCH /api/me/profile` sem token retorna `401`.
|
||||
|
||||
---
|
||||
|
||||
### T04 — Implementar `PATCH /me/password`
|
||||
|
||||
**Arquivo**: `backend/app/routes/client_area.py`
|
||||
|
||||
**Contexto**: Endpoint inexistente. Deve verificar senha atual com bcrypt antes de gravar a nova.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Garantir que `bcrypt` e `ClientUser` estão importados (feito em T03).
|
||||
2. Adicionar ao final do arquivo:
|
||||
```python
|
||||
@client_bp.patch("/password")
|
||||
@require_auth
|
||||
def change_password():
|
||||
try:
|
||||
data = UpdatePasswordIn.model_validate(request.get_json() or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": e.errors(include_url=False)}), 422
|
||||
|
||||
user = db.session.get(ClientUser, g.current_user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "Usuário não encontrado"}), 404
|
||||
|
||||
# Verifica senha atual
|
||||
if not bcrypt.checkpw(
|
||||
data.current_password.encode("utf-8"),
|
||||
user.password_hash.encode("utf-8"),
|
||||
):
|
||||
return jsonify({"error": "Senha atual incorreta"}), 400
|
||||
|
||||
user.password_hash = bcrypt.hashpw(
|
||||
data.new_password.encode("utf-8"), bcrypt.gensalt()
|
||||
).decode("utf-8")
|
||||
db.session.commit()
|
||||
return "", 204
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] `PATCH /api/me/password` com senha atual correta e nova senha ≥ 8 chars retorna `204`.
|
||||
- [ ] `PATCH /api/me/password` com senha atual incorreta retorna `400` com `"Senha atual incorreta"`.
|
||||
- [ ] `PATCH /api/me/password` com nova senha < 8 chars retorna `422`.
|
||||
- [ ] `PATCH /api/me/password` sem token retorna `401`.
|
||||
|
||||
---
|
||||
|
||||
### T05 — Implementar `PATCH /me/visits/<id>/cancel`
|
||||
|
||||
**Arquivo**: `backend/app/routes/client_area.py`
|
||||
|
||||
**Contexto**: Endpoint inexistente. Deve cancelar visita com `status=pending` do usuário autenticado.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Adicionar ao final do arquivo:
|
||||
```python
|
||||
@client_bp.patch("/visits/<visit_id>/cancel")
|
||||
@require_auth
|
||||
def cancel_visit(visit_id: str):
|
||||
visit = db.session.get(VisitRequest, visit_id)
|
||||
if not visit:
|
||||
return jsonify({"error": "Visita não encontrada"}), 404
|
||||
|
||||
if visit.user_id != g.current_user_id:
|
||||
return jsonify({"error": "Acesso negado"}), 403
|
||||
|
||||
if visit.status != "pending":
|
||||
return jsonify({"error": "Apenas visitas pendentes podem ser canceladas"}), 400
|
||||
|
||||
visit.status = "cancelled"
|
||||
db.session.commit()
|
||||
return jsonify(VisitRequestOut.model_validate(visit).model_dump(mode="json")), 200
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] `PATCH /api/me/visits/<id>/cancel` com visita `status=pending` do próprio usuário retorna `200` com `status: "cancelled"`.
|
||||
- [ ] Cancelar visita com `status=confirmed` retorna `400`.
|
||||
- [ ] Cancelar visita de outro usuário retorna `403`.
|
||||
- [ ] Cancelar visita inexistente retorna `404`.
|
||||
- [ ] `VisitRequestOut` está importado (já estava) e serializa o resultado corretamente.
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Frontend: Tipos e Serviços
|
||||
|
||||
### T06 — Atualizar tipos em `clientArea.ts`
|
||||
|
||||
**Arquivo**: `frontend/src/types/clientArea.ts`
|
||||
|
||||
**Contexto**: `SavedProperty.property` só tem `{ id, title, slug }`. Precisa incluir `price`, `city`, `neighborhood`, `cover_photo_url`. Adicionar também os tipos dos payloads de profile/senha.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Substituir o tipo `SavedProperty`:
|
||||
```typescript
|
||||
export interface PropertyCard {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
price: string | null; // Decimal serializado como string pelo backend
|
||||
city: string | null;
|
||||
neighborhood: string | null;
|
||||
cover_photo_url: string | null;
|
||||
}
|
||||
|
||||
export interface SavedProperty {
|
||||
id: string;
|
||||
property_id: string | null;
|
||||
property: PropertyCard | null;
|
||||
created_at: string;
|
||||
}
|
||||
```
|
||||
2. Adicionar ao final do arquivo:
|
||||
```typescript
|
||||
export interface UpdateProfilePayload {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfileResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordPayload {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] `SavedProperty.property` é `PropertyCard | null`.
|
||||
- [ ] `PropertyCard` tem todos os 7 campos listados.
|
||||
- [ ] `UpdateProfilePayload`, `UpdateProfileResponse` e `ChangePasswordPayload` estão exportados.
|
||||
- [ ] Sem erros de TypeScript em arquivos que importam `SavedProperty`.
|
||||
|
||||
---
|
||||
|
||||
### T07 — Adicionar `updateProfile`, `changePassword` e `cancelVisit` em `clientArea.ts`
|
||||
|
||||
**Arquivo**: `frontend/src/services/clientArea.ts`
|
||||
|
||||
**Contexto**: O serviço atual tem `getFavorites`, `addFavorite`, `removeFavorite`, `getVisits`, `getBoletos`. Faltam os três novos métodos correspondentes aos endpoints criados em T03–T05.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Atualizar o import de tipos no topo:
|
||||
```typescript
|
||||
import type {
|
||||
Boleto,
|
||||
ChangePasswordPayload,
|
||||
SavedProperty,
|
||||
UpdateProfilePayload,
|
||||
UpdateProfileResponse,
|
||||
VisitRequest,
|
||||
} from '../types/clientArea';
|
||||
```
|
||||
2. Adicionar ao final do arquivo:
|
||||
```typescript
|
||||
export async function updateProfile(
|
||||
data: UpdateProfilePayload,
|
||||
): Promise<UpdateProfileResponse> {
|
||||
const response = await api.patch<UpdateProfileResponse>('/me/profile', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function changePassword(data: ChangePasswordPayload): Promise<void> {
|
||||
await api.patch('/me/password', data);
|
||||
}
|
||||
|
||||
export async function cancelVisit(visitId: string): Promise<VisitRequest> {
|
||||
const response = await api.patch<VisitRequest>(`/me/visits/${visitId}/cancel`);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] `updateProfile`, `changePassword` e `cancelVisit` são exportados de `clientArea.ts`.
|
||||
- [ ] Tipos corretos: `updateProfile` retorna `Promise<UpdateProfileResponse>`, `cancelVisit` retorna `Promise<VisitRequest>`.
|
||||
- [ ] Sem erros TypeScript no arquivo.
|
||||
|
||||
---
|
||||
|
||||
## Fase 4 — Frontend: AuthContext
|
||||
|
||||
### T08 — Expor `updateUser` no `AuthContext`
|
||||
|
||||
**Arquivo**: `frontend/src/contexts/AuthContext.tsx`
|
||||
|
||||
**Contexto**: `ProfilePage` (T10) precisará atualizar `user.name` no contexto após salvar com sucesso, para que o sidebar reflita imediatamente o novo nome sem reload. Atualmente `setUser` é interno ao Provider.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Adicionar `updateUser` à interface `AuthContextValue`:
|
||||
```typescript
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
token: string | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
login: (data: LoginCredentials) => Promise<void>
|
||||
register: (data: RegisterCredentials) => Promise<void>
|
||||
logout: () => void
|
||||
updateUser: (partial: Partial<User>) => void // NOVO
|
||||
}
|
||||
```
|
||||
2. Implementar `updateUser` dentro do `AuthProvider`, após a declaração de `logout`:
|
||||
```typescript
|
||||
const updateUser = useCallback((partial: Partial<User>) => {
|
||||
setUser(prev => (prev ? { ...prev, ...partial } : prev))
|
||||
}, [])
|
||||
```
|
||||
3. Adicionar `updateUser` ao objeto passado para `AuthContext.Provider`:
|
||||
```typescript
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateUser, // NOVO
|
||||
}}
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] `useAuth().updateUser({ name: "Novo Nome" })` atualiza `user.name` no contexto.
|
||||
- [ ] O sidebar (`ClientLayout.tsx`) reflete o novo nome sem recarga de página ao chamar `updateUser`.
|
||||
- [ ] Sem erros TypeScript no arquivo ou em consumidores de `useAuth`.
|
||||
|
||||
---
|
||||
|
||||
## Fase 5 — Frontend: Layout
|
||||
|
||||
### T09 — Refatorar `ClientLayout.tsx` com ícones SVG e nova navegação
|
||||
|
||||
**Arquivo**: `frontend/src/layouts/ClientLayout.tsx`
|
||||
|
||||
**Contexto**: O layout atual tem 5 itens de nav com emoji Unicode inconsistentes, inclui "Painel" e "Boletos", e o botão "Sair" usa `→`. A nova nav tem 4 itens com SVG Heroicons.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Substituir o array `navItems` por componentes SVG inline para cada ícone. Criar 4 componentes SVG pequenos no topo do arquivo (ou inline no array), usando Heroicons 2.0 outline (24×24, `stroke="currentColor"`, `strokeWidth={1.5}`):
|
||||
|
||||
- **Favoritos** → `HeartIcon` (coração vazio)
|
||||
- **Comparar** → `ScaleIcon` (balança/scale)
|
||||
- **Visitas** → `CalendarIcon` (calendário)
|
||||
- **Minha conta** → `UserCircleIcon` (usuário com círculo)
|
||||
- **Logout** → `ArrowRightOnRectangleIcon` (seta saindo de retângulo)
|
||||
|
||||
Exemplo de SVG inline para `HeartIcon`:
|
||||
```tsx
|
||||
function HeartIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
strokeWidth={1.5} stroke="currentColor" className="size-5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
Criar funções análogas: `ScaleIcon`, `CalendarIcon`, `UserCircleIcon`, `ArrowRightOnRectangleIcon`.
|
||||
|
||||
2. Substituir `navItems` por:
|
||||
```tsx
|
||||
const navItems = [
|
||||
{ to: '/area-do-cliente/favoritos', label: 'Favoritos', end: false, Icon: HeartIcon },
|
||||
{ to: '/area-do-cliente/comparar', label: 'Comparar', end: false, Icon: ScaleIcon },
|
||||
{ to: '/area-do-cliente/visitas', label: 'Visitas', end: false, Icon: CalendarIcon },
|
||||
{ to: '/area-do-cliente/conta', label: 'Minha conta', end: false, Icon: UserCircleIcon },
|
||||
];
|
||||
```
|
||||
Remover `adminNavItems` ou mantê-lo separado se o admin ainda precisar (não alterar funcionalidade admin).
|
||||
|
||||
3. No render de cada `NavLink` (sidebar e mobile), substituir `<span className="text-base">{item.icon}</span>` por `<item.Icon />`.
|
||||
|
||||
4. Substituir o botão de logout:
|
||||
```tsx
|
||||
{/* Antes */}
|
||||
<span>→</span>Sair
|
||||
{/* Depois */}
|
||||
<ArrowRightOnRectangleIcon />Sair
|
||||
```
|
||||
|
||||
5. **Mobile nav**: No bloco `lg:hidden`, os links agora têm 4 itens. Adicionar ícones SVG também na nav mobile e centralizar:
|
||||
```tsx
|
||||
<div className="flex flex-1 justify-center gap-1">
|
||||
{navItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center gap-0.5 rounded-lg px-3 py-1.5 text-xs transition ${
|
||||
isActive
|
||||
? 'bg-surface text-textPrimary font-medium'
|
||||
: 'text-textSecondary hover:text-textPrimary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.Icon />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] Menu lateral tem exatamente 4 itens: Favoritos, Comparar, Visitas, Minha conta.
|
||||
- [ ] "Painel" e "Boletos" não aparecem no menu.
|
||||
- [ ] Todos os ícones são SVG `<svg>` inline (sem emoji, sem texto Unicode).
|
||||
- [ ] Botão "Sair" usa `ArrowRightOnRectangleIcon`.
|
||||
- [ ] Item ativo está visualmente destacado em ambos: sidebar (desktop) e barra (mobile).
|
||||
- [ ] Mobile: 4 itens centralizados sem scroll horizontal.
|
||||
- [ ] Sem erros TypeScript/JSX no arquivo.
|
||||
|
||||
---
|
||||
|
||||
## Fase 6 — Frontend: Páginas
|
||||
|
||||
### T10 — Criar `ProfilePage.tsx`
|
||||
|
||||
**Arquivo**: `frontend/src/pages/client/ProfilePage.tsx` *(criar)*
|
||||
|
||||
**Contexto**: Página inexistente. Deve exibir dois formulários independentes: edição de nome e troca de senha.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Criar o arquivo com a seguinte estrutura:
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { changePassword, updateProfile } from '../../services/clientArea';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, updateUser } = useAuth();
|
||||
|
||||
// — Form de perfil —
|
||||
const [name, setName] = useState(user?.name ?? '');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [nameSaving, setNameSaving] = useState(false);
|
||||
const [nameSuccess, setNameSuccess] = useState(false);
|
||||
|
||||
// — Form de senha —
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
||||
|
||||
async function handleSaveName(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setNameError('');
|
||||
setNameSuccess(false);
|
||||
if (!name.trim()) {
|
||||
setNameError('O nome não pode ser vazio.');
|
||||
return;
|
||||
}
|
||||
setNameSaving(true);
|
||||
try {
|
||||
const updated = await updateProfile({ name: name.trim() });
|
||||
updateUser({ name: updated.name });
|
||||
setNameSuccess(true);
|
||||
} catch {
|
||||
setNameError('Erro ao salvar. Tente novamente.');
|
||||
} finally {
|
||||
setNameSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPasswordError('');
|
||||
setPasswordSuccess(false);
|
||||
if (newPassword.length < 8) {
|
||||
setPasswordError('A nova senha deve ter pelo menos 8 caracteres.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError('As senhas não coincidem.');
|
||||
return;
|
||||
}
|
||||
setPasswordSaving(true);
|
||||
try {
|
||||
await changePassword({ current_password: currentPassword, new_password: newPassword });
|
||||
setPasswordSuccess(true);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error ?? 'Erro ao alterar senha.';
|
||||
setPasswordError(msg);
|
||||
} finally {
|
||||
setPasswordSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-lg space-y-8">
|
||||
<h1 className="text-xl font-semibold text-textPrimary">Minha conta</h1>
|
||||
|
||||
{/* Formulário: dados pessoais */}
|
||||
<section className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-textPrimary">Dados pessoais</h2>
|
||||
<form onSubmit={handleSaveName} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-textSecondary mb-1">Nome</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-textSecondary mb-1">E-mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={user?.email ?? ''}
|
||||
readOnly
|
||||
className="w-full rounded-lg border border-borderSubtle bg-surface px-3 py-2 text-sm text-textTertiary cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
{nameError && <p className="text-xs text-red-400">{nameError}</p>}
|
||||
{nameSuccess && <p className="text-xs text-green-400">Nome atualizado com sucesso!</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={nameSaving}
|
||||
className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brandHover disabled:opacity-50 transition"
|
||||
>
|
||||
{nameSaving ? 'Salvando…' : 'Salvar alterações'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Formulário: trocar senha */}
|
||||
<section className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-textPrimary">Alterar senha</h2>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-textSecondary mb-1">Senha atual</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-textSecondary mb-1">Nova senha</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-textSecondary mb-1">Confirmar nova senha</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
|
||||
/>
|
||||
</div>
|
||||
{passwordError && <p className="text-xs text-red-400">{passwordError}</p>}
|
||||
{passwordSuccess && <p className="text-xs text-green-400">Senha alterada com sucesso!</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={passwordSaving}
|
||||
className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brandHover disabled:opacity-50 transition"
|
||||
>
|
||||
{passwordSaving ? 'Salvando…' : 'Alterar senha'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] Página renderiza sem erros.
|
||||
- [ ] Nome atual do usuário aparece pré-preenchido no campo "Nome".
|
||||
- [ ] E-mail aparece em campo `readOnly` (não editável).
|
||||
- [ ] Submit com nome vazio exibe erro inline sem chamar o servidor.
|
||||
- [ ] Submit bem-sucedido de nome exibe "Nome atualizado com sucesso!" e o sidebar reflete o novo nome.
|
||||
- [ ] Submit com `newPassword !== confirmPassword` exibe "As senhas não coincidem." sem chamar o servidor.
|
||||
- [ ] Submit com `newPassword.length < 8` exibe erro inline.
|
||||
- [ ] Senha atual incorreta → backend retorna `400` → frontend exibe "Senha atual incorreta".
|
||||
- [ ] Após troca de senha bem-sucedida, campos de senha são limpos.
|
||||
|
||||
---
|
||||
|
||||
### T11 — Melhorar `FavoritesPage.tsx` com cards enriquecidos
|
||||
|
||||
**Arquivo**: `frontend/src/pages/client/FavoritesPage.tsx`
|
||||
|
||||
**Contexto**: Cards atuais mostram só título e link. Com T01/T02, o backend já entrega `cover_photo_url`, `price`, `city`, `neighborhood`. Precisa exibir essas informações e melhorar o empty state.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Atualizar o import de tipos:
|
||||
```typescript
|
||||
import type { SavedProperty } from '../../types/clientArea';
|
||||
```
|
||||
2. Corrigir o tipo do estado: `useState<SavedProperty[]>([])`.
|
||||
3. Remover o componente `HeartButton` deste contexto — substituir pela ação "Remover dos favoritos" usando `removeFavorite` do serviço:
|
||||
```typescript
|
||||
import { getFavorites, removeFavorite } from '../../services/clientArea';
|
||||
```
|
||||
4. Implementar a função de remoção (optimistic update):
|
||||
```typescript
|
||||
async function handleRemove(savedId: string, propertyId: string | null) {
|
||||
if (!propertyId) return;
|
||||
setFavorites(prev => prev.filter(f => f.id !== savedId));
|
||||
try {
|
||||
await removeFavorite(propertyId);
|
||||
} catch {
|
||||
// Recarregar em caso de erro
|
||||
getFavorites().then(data => setFavorites(Array.isArray(data) ? data : []));
|
||||
}
|
||||
}
|
||||
```
|
||||
5. Substituir os cards no retorno JSX por:
|
||||
```tsx
|
||||
{favorites.map((item) => {
|
||||
const prop = item.property;
|
||||
const price = prop?.price
|
||||
? new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 })
|
||||
.format(parseFloat(prop.price))
|
||||
: null;
|
||||
const location = [prop?.neighborhood, prop?.city].filter(Boolean).join(', ');
|
||||
|
||||
return (
|
||||
<div key={item.id} className="rounded-xl border border-borderSubtle bg-panel overflow-hidden hover:border-borderStandard transition">
|
||||
{/* Thumbnail */}
|
||||
<div className="relative h-40 bg-surface">
|
||||
{prop?.cover_photo_url ? (
|
||||
<img
|
||||
src={prop.cover_photo_url}
|
||||
alt={prop.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-textQuaternary text-xs">
|
||||
Sem foto
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Info */}
|
||||
<div className="p-4">
|
||||
<p className="text-sm font-medium text-textPrimary line-clamp-2">{prop?.title ?? 'Imóvel'}</p>
|
||||
{price && <p className="mt-1 text-sm font-semibold text-brand">{price}</p>}
|
||||
{location && <p className="mt-0.5 text-xs text-textTertiary">{location}</p>}
|
||||
{/* Ações */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<a
|
||||
href={prop?.slug ? `/imoveis/${prop.slug}` : '#'}
|
||||
className="flex-1 rounded-lg border border-borderSubtle px-3 py-1.5 text-center text-xs text-textSecondary hover:text-textPrimary hover:border-borderStandard transition"
|
||||
>
|
||||
Ver imóvel
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleRemove(item.id, item.property_id)}
|
||||
className="rounded-lg border border-borderSubtle px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 hover:border-red-500/20 transition"
|
||||
>
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] Cada card exibe: thumbnail (ou placeholder "Sem foto"), título, preço formatado em BRL, cidade/bairro.
|
||||
- [ ] Botão "Remover" remove o card da lista imediatamente (optimistic update) e chama `removeFavorite`.
|
||||
- [ ] Botão "Ver imóvel" navega para `/imoveis/<slug>`.
|
||||
- [ ] Empty state exibe link para `/imoveis`.
|
||||
- [ ] Sem erros TypeScript.
|
||||
|
||||
---
|
||||
|
||||
### T12 — Melhorar `VisitsPage.tsx` com cancelamento de visita
|
||||
|
||||
**Arquivo**: `frontend/src/pages/client/VisitsPage.tsx`
|
||||
|
||||
**Contexto**: Página só exibe visitas. Precisa adicionar botão "Cancelar" para `status=pending`, com confirmação simples e optimistic update.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Adicionar import do serviço:
|
||||
```typescript
|
||||
import { cancelVisit, getVisits } from '../../services/clientArea';
|
||||
```
|
||||
2. Adicionar estado para controle de cancelamento:
|
||||
```typescript
|
||||
const [cancelling, setCancelling] = useState<string | null>(null); // visitId em progresso
|
||||
const [cancelError, setCancelError] = useState<string | null>(null);
|
||||
```
|
||||
3. Implementar `handleCancel`:
|
||||
```typescript
|
||||
async function handleCancel(visitId: string) {
|
||||
if (!window.confirm('Confirmar cancelamento desta visita?')) return;
|
||||
setCancelling(visitId);
|
||||
setCancelError(null);
|
||||
// Optimistic update
|
||||
setVisits(prev =>
|
||||
prev.map(v => (v.id === visitId ? { ...v, status: 'cancelled' as const } : v))
|
||||
);
|
||||
try {
|
||||
const updated = await cancelVisit(visitId);
|
||||
setVisits(prev => prev.map(v => (v.id === visitId ? updated : v)));
|
||||
} catch (err: any) {
|
||||
// Reverter
|
||||
setCancelError('Não foi possível cancelar. Tente novamente.');
|
||||
getVisits().then(setVisits).catch(() => {});
|
||||
} finally {
|
||||
setCancelling(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
4. Dentro do map de visitas, após o badge de status, adicionar o botão condicional:
|
||||
```tsx
|
||||
{visit.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleCancel(visit.id)}
|
||||
disabled={cancelling === visit.id}
|
||||
className="mt-3 rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition"
|
||||
>
|
||||
{cancelling === visit.id ? 'Cancelando…' : 'Cancelar visita'}
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
5. Exibir `cancelError` se presente, logo após o bloco de visitas:
|
||||
```tsx
|
||||
{cancelError && (
|
||||
<p className="mt-2 text-xs text-red-400">{cancelError}</p>
|
||||
)}
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] Visita com `status=pending` exibe botão "Cancelar visita".
|
||||
- [ ] Ao clicar, `window.confirm` é exibido; se cancelado pelo usuário, nenhuma ação.
|
||||
- [ ] Ao confirmar, o badge de status muda imediatamente para "Cancelada" (optimistic).
|
||||
- [ ] O botão "Cancelar visita" desaparece após status mudar.
|
||||
- [ ] Visitas com status diferente de `pending` não exibem o botão.
|
||||
- [ ] Em caso de erro de rede, mensagem de erro é exibida e lista é recarregada.
|
||||
|
||||
---
|
||||
|
||||
### T13 — Melhorar empty state de `ComparisonPage.tsx`
|
||||
|
||||
**Arquivo**: `frontend/src/pages/client/ComparisonPage.tsx`
|
||||
|
||||
**Contexto**: O empty state atual diz "Nenhum imóvel selecionado para comparação" com link para `/imoveis`. Falta instrução clara de como usar a feature.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Localizar o bloco do empty state (quando `properties.length === 0`).
|
||||
2. Substituir o conteúdo interno do `div` de empty state por:
|
||||
```tsx
|
||||
<div className="rounded-xl border border-borderSubtle bg-panel p-12 text-center max-w-sm mx-auto">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface">
|
||||
{/* ScaleIcon SVG inline */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
strokeWidth={1.5} stroke="currentColor" className="size-6 text-textTertiary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52 2.62 15.95M5.25 4.97l-2.62 15.95m0 0a48.959 48.959 0 0 0 3.32.65M5.63 20.92a48.958 48.958 0 0 0 3.32-.65m9.63.65a48.952 48.952 0 0 0 3.32-.65" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-textPrimary mb-2">Nenhum imóvel para comparar</p>
|
||||
<p className="text-xs text-textTertiary mb-4">
|
||||
Acesse um imóvel no catálogo e clique em{' '}
|
||||
<span className="font-medium text-textSecondary">"Comparar"</span>{' '}
|
||||
para adicioná-lo aqui. Você pode comparar até 3 imóveis lado a lado.
|
||||
</p>
|
||||
<Link
|
||||
to="/imoveis"
|
||||
className="inline-block rounded-lg bg-brand px-4 py-2 text-xs font-medium text-white hover:bg-brandHover transition"
|
||||
>
|
||||
Explorar imóveis
|
||||
</Link>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] Empty state exibe instrução explicando como adicionar imóveis à comparação.
|
||||
- [ ] Instrução menciona o botão "Comparar" nos cards de imóvel.
|
||||
- [ ] Link "Explorar imóveis" navega para `/imoveis`.
|
||||
- [ ] Quando há imóveis na comparação, a tabela é exibida normalmente (sem regressão).
|
||||
|
||||
---
|
||||
|
||||
## Fase 7 — Frontend: Roteamento
|
||||
|
||||
### T14 — Atualizar rotas em `App.tsx`
|
||||
|
||||
**Arquivo**: `frontend/src/App.tsx`
|
||||
|
||||
**Contexto**: Precisa: (a) redirecionar `/area-do-cliente` para `/area-do-cliente/favoritos`; (b) remover a rota `/boletos`; (c) adicionar a rota `/conta` apontando para `ProfilePage`.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Adicionar import de `Navigate` e `ProfilePage`:
|
||||
```typescript
|
||||
import { Navigate } from 'react-router-dom'; // adicionar ao import existente de react-router-dom
|
||||
import ProfilePage from './pages/client/ProfilePage';
|
||||
```
|
||||
2. Remover os imports de `BoletosPage` e `ClientDashboardPage`:
|
||||
```typescript
|
||||
// Remover estas linhas:
|
||||
import BoletosPage from './pages/client/BoletosPage';
|
||||
import ClientDashboardPage from './pages/client/ClientDashboardPage';
|
||||
```
|
||||
3. Localizar o bloco de rotas da área do cliente e substituir:
|
||||
```tsx
|
||||
{/* Antes */}
|
||||
<Route index element={<ClientDashboardPage />} />
|
||||
<Route path="favoritos" element={<FavoritesPage />} />
|
||||
<Route path="comparar" element={<ComparisonPage />} />
|
||||
<Route path="visitas" element={<VisitsPage />} />
|
||||
<Route path="boletos" element={<BoletosPage />} />
|
||||
|
||||
{/* Depois */}
|
||||
<Route index element={<Navigate to="favoritos" replace />} />
|
||||
<Route path="favoritos" element={<FavoritesPage />} />
|
||||
<Route path="comparar" element={<ComparisonPage />} />
|
||||
<Route path="visitas" element={<VisitsPage />} />
|
||||
<Route path="conta" element={<ProfilePage />} />
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] Acessar `/area-do-cliente` redireciona para `/area-do-cliente/favoritos` (Replace — sem entrada no histórico).
|
||||
- [ ] A rota `/area-do-cliente/boletos` não existe mais (retorna 404 do React Router).
|
||||
- [ ] A rota `/area-do-cliente/conta` renderiza `ProfilePage`.
|
||||
- [ ] Sem erros TypeScript/lint no arquivo.
|
||||
|
||||
---
|
||||
|
||||
## Fase 8 — Remoção de Arquivos Obsoletos
|
||||
|
||||
### T15 — Deletar `BoletosPage.tsx` e `ClientDashboardPage.tsx`
|
||||
|
||||
**Arquivos a deletar**:
|
||||
- `frontend/src/pages/client/BoletosPage.tsx`
|
||||
- `frontend/src/pages/client/ClientDashboardPage.tsx`
|
||||
|
||||
**Contexto**: Após T14, nenhum import desses componentes existe mais no projeto. Removê-los evita confusão futura.
|
||||
|
||||
**Passos**:
|
||||
|
||||
1. Verificar que nenhum arquivo do projeto importa `BoletosPage` ou `ClientDashboardPage`:
|
||||
```powershell
|
||||
Select-String -Path "frontend/src/**" -Pattern "BoletosPage|ClientDashboardPage" -Recurse
|
||||
```
|
||||
2. Se o resultado for vazio (apenas os próprios arquivos), deletar:
|
||||
```powershell
|
||||
Remove-Item "frontend/src/pages/client/BoletosPage.tsx"
|
||||
Remove-Item "frontend/src/pages/client/ClientDashboardPage.tsx"
|
||||
```
|
||||
|
||||
**Critérios de conclusão**:
|
||||
- [ ] Os dois arquivos não existem mais no projeto.
|
||||
- [ ] Nenhum arquivo do projeto os importa.
|
||||
- [ ] Build do frontend (ou `tsc --noEmit`) passa sem erros.
|
||||
|
||||
---
|
||||
|
||||
## Grafo de Dependências
|
||||
|
||||
```
|
||||
T01 (Schemas backend)
|
||||
└─ T02 (Eager load GET /favorites) → T11 (FavoritesPage)
|
||||
└─ T03 (PATCH /profile) → T07 (cancelVisit/updateProfile) → T10 (ProfilePage)
|
||||
└─ T04 (PATCH /password) ↗
|
||||
└─ T05 (PATCH /visits/cancel) → T07 (cancelVisit) → T12 (VisitsPage)
|
||||
|
||||
T06 (Tipos TS) → T07 (Serviços) → T10, T11, T12
|
||||
T08 (AuthContext.updateUser) → T10 (ProfilePage usa updateUser)
|
||||
T09 (ClientLayout) — independente, pode ser feito em paralelo com T01–T08
|
||||
T13 (ComparisonPage empty state) — independente
|
||||
T14 (App.tsx rotas) → depende de T10 (ProfilePage deve existir)
|
||||
T15 (Deletar arquivos) → depende de T14
|
||||
```
|
||||
|
||||
## Execução em Paralelo
|
||||
|
||||
| Grupo paralelo | Tasks |
|
||||
|---|---|
|
||||
| 1 (backend) | T01 → (T02, T03, T04, T05) em paralelo após T01 |
|
||||
| 2 (frontend infra) | T06, T08, T09, T13 podem ser feitos em paralelo entre si |
|
||||
| 3 (serviços) | T07 após T06 |
|
||||
| 4 (páginas) | T10 após T03+T04+T08+T07; T11 após T02+T06+T07; T12 após T05+T06+T07 |
|
||||
| 5 (finalização) | T14 após T10; T15 após T14 |
|
||||
|
||||
## Checklist Resumida
|
||||
|
||||
- [X] T01 [P] Schemas backend: `PropertyCard`, `UpdateProfileIn/Out`, `UpdatePasswordIn` em `backend/app/schemas/client_area.py`
|
||||
- [X] T12 [US3] Cancelamento em `frontend/src/pages/client/VisitsPage.tsx`
|
||||
- [X] T13 [P] [US6] Empty state em `frontend/src/pages/client/ComparisonPage.tsx`
|
||||
- [X] T14 Rotas em `frontend/src/App.tsx`
|
||||
- [X] T15 Deletar `BoletosPage.tsx` e `ClientDashboardPage.tsx`
|
||||
Loading…
Add table
Add a link
Reference in a new issue