Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ npm run lint # ESLint
| Quando precisar de... | Ler |
|---|---|
| Estrutura, fluxo de dados, decisões | [docs/architecture.md](docs/architecture.md) |
| DashboardView, Calendar, Timeline, drag-drop | [docs/views/dashboard.md](docs/views/dashboard.md) |
| TasksView, filtros, badge de prazo, ActionMenu | [docs/views/tasks.md](docs/views/tasks.md) |
| PlanningView, Calendar, Timeline, Demandas, drag-drop | [docs/views/planning.md](docs/views/planning.md) |
| MembersView, capacidade | [docs/views/members.md](docs/views/members.md) |
| AdminView, UsersPanel, useAdminData, useAdminStore | [docs/views/admin.md](docs/views/admin.md) |
| ProfileView, useProfile, user_preferences | [docs/views/profile.md](docs/views/profile.md) |
Expand Down
57 changes: 44 additions & 13 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,22 @@ src/
├── App.tsx # Root: gates de auth + composição declarativa — ~80 linhas
├── main.tsx # Entry point
├── components/
│ ├── AppRouter.tsx # Mapeia view → componente; guards de cliente
│ ├── AppRouter.tsx # Mapeia view (lida da URL) → componente; guards de cliente
│ ├── AppRoutes.tsx # Árvore de rotas React Router (declarativa, sem renderização)
│ ├── AppLayout.tsx # Shell do layout: AppHeader + AppSidebar + main/AppRouter
│ ├── ClientPickerLayout.tsx # Layout leve para "/" sem slug: header + mini-sidebar recolhida + ClientPickerView
│ ├── AppModals.tsx # TaskModal + ConfirmModal + ClientTransitionOverlay agrupados
│ ├── TaskModal.tsx # Modal criar/editar demanda
│ ├── AppHeader.tsx # Header: logo, hamburger mobile, NotificationBell, theme toggle (desktop)
│ ├── AppSidebar.tsx # Sidebar de navegação; theme toggle no footer mobile
│ ├── ClientTransitionOverlay.tsx # Overlay animado exibido ao trocar de cliente
│ └── ui/ # Design system (Button, Input, Label, Badge)
├── views/
│ ├── home/ # HomeView — saudação, SearchLauncher, QuickAccess
│ ├── dashboard/ # DashboardView → Calendar/Timeline
│ ├── home/ # HomeView — saudação, SearchLauncher, QuickAccess (pós-seleção de cliente)
│ ├── client-picker/ # ClientPickerView — boas-vindas + grid de seleção de cliente (rota "/")
│ │ └── components/
│ │ └── ClientCard.tsx
│ ├── planning/ # PlanningView → Calendar/Timeline/List/Demandas
│ ├── calendar/ # CalendarView — calendário mensal com drag-drop
│ ├── timeline/ # TimelineView — Gantt com drag-drop por fase
│ ├── MembersView/ # Capacidade por membro
Expand All @@ -34,14 +39,15 @@ src/
│ ├── useTaskStore.ts # Estado local para update otimista (applyOptimisticUpdate/clearOptimistic)
│ └── useMemberStore.ts # Stub de compatibilidade (sem fetch — migrado para useMembersQuery)
├── hooks/
│ ├── useAppOrchestrator.ts # Agrega toda a lógica de orquestração do App (cliente, views, notificações, task actions)
│ ├── useAppOrchestrator.ts # Agrega toda a lógica de orquestração do App (cliente, views, notificações, task actions); expõe cachedClient, navigateTo e navigateToClient para App.tsx coordenar redirects sem estado intermediário
│ ├── useAppNavigation.ts # URL ↔ ViewType: urlToView, viewToPath, taskPath; lê clientSlug via location.pathname (não useParams)
│ ├── useSupabase.ts # Mutations CRUD (createTask, updateTask, deleteTask) via TanStack Query
│ ├── useTasksQuery.ts # Query hook TanStack Query para tasks
│ ├── useMembersQuery.ts # Query hook TanStack Query para members
│ ├── useHolidays.ts # Feriados
│ ├── useFormState.ts # Estado do formulário TaskModal
│ ├── useAppTheme.ts # Dark mode: estado + sync com localStorage e <html>
│ ├── useAppSidebar.ts # Sidebar desktop (persist) e mobile open/close
│ ├── useAppSidebar.ts # Sidebar desktop (persist) e mobile open/close; openSidebar() força expansão
│ ├── useTaskActions.ts # Estado e handlers de create/update/delete de tasks
│ └── useClientTransition.ts # Fluxo animado de troca de cliente (overlay + TanStack Query invalidate)
├── contexts/
Expand All @@ -52,6 +58,7 @@ src/
│ ├── adminApi.ts # Funções tipadas que chamam as Supabase Edge Functions admin
│ ├── queries.ts # fetchTasksFromDb, fetchMembersFromDb, queryKeys — valida rows com Zod antes do mapeamento
│ ├── validators.ts # Schemas Zod para rows do banco (DbTaskRowSchema, DbStepRowSchema, DbStepAssigneeSchema)
│ ├── clientSlug.ts # clientToSlug, nameToSlug, slugToClient — converte entre ClientOption e slug de URL
│ ├── steps.ts # Definição e lógica de steps
│ └── utils.ts # Utilitários gerais
├── types/
Expand All @@ -67,6 +74,11 @@ src/
AuthContext (AuthProvider)
↓ session, member, clients (filtrado por access_role), isAdmin, refreshProfile
App.tsx (gates de auth + composição)
├── !session → LoginView
├── !hasClients → OnboardingView
├── !effectiveClientId (sem slug, slug inválido, /clients…) e não é /profile
│ ├── cachedClient válido → useEffect: navigateTo(view, cachedClient) → redirect preservando a view (ex: /clients → /:slug/client-info); renderiza null enquanto navega
│ └── sem cache → ClientPickerLayout → ClientPickerView
└── useAppOrchestrator (toda a lógica de orquestração)
├── useClientStore → selectedClientId (persist)
├── useMembersQuery → members com cache TanStack Query
Expand Down Expand Up @@ -109,14 +121,18 @@ openTaskModal() / closeTaskModal()
Cliente selecionado. **Persiste no localStorage** (`client-store`). Não persiste `undefined`.
```ts
selectedClientId: string | null | undefined
setClient(id)
selectedAt: number | null // timestamp (ms) da última seleção
setClient(id) // grava id + selectedAt = Date.now()
isClientBuffValid() // true se selectedAt < 4h atrás
```
| Valor | Significado |
| Valor de `selectedClientId` | Significado |
|---|---|
| `undefined` | Não inicializado — aguarda resolução da auth |
| `null` | Admin vê todos os clientes (sem filtro no fetch) |
| `string` | Cliente específico selecionado |

O "buff" de 4h (`CLIENT_BUFF_MS = 4 * 60 * 60 * 1000`) é verificado em `useAppOrchestrator` antes de restaurar o cliente automaticamente. Após expirar, o usuário vê o `ClientPickerView` independentemente do valor persistido.

### `useTaskStore`
Estado local mínimo para suporte a **update otimista**. O fetch de tasks foi migrado para `useTasksQuery` (TanStack Query).
```ts
Expand Down Expand Up @@ -188,20 +204,35 @@ Relê o perfil do usuário atual (member + clients) sem reiniciar o ciclo de aut

**Chave:** `App.tsx` usa `AuthContext.clients` diretamente (não `useUserClients`). `useUserClients` existe apenas em `UserClientsView` para `linkToClient`/`unlinkFromClient`.

## Leitura do clientSlug na URL

`useAppNavigation` deriva `currentSlug` diretamente de `location.pathname` em vez de `useParams`. O motivo: `App.tsx` está montado diretamente dentro de `<BrowserRouter>` sem nenhum `<Route path="/:clientSlug">` como ancestral — logo `useParams()` retorna sempre `{}`. A extração manual lê o primeiro segmento do pathname e descarta rotas globais conhecidas (`profile`, `clients`, `""`). Nota: `admin` **não** é uma rota global — sua URL é `/:clientSlug/admin` e `currentSlug` é derivado normalmente.

`urlTaskId` é derivado da mesma forma: busca o segmento `"id"` no pathname e retorna o próximo segmento.

## Persistência e Restauração do Cliente Selecionado

`useAppOrchestrator` integra `useClientStore` para dois comportamentos:

1. **Persistência:** sempre que `effectiveClientId` muda (URL com slug válido), grava o ID na `useClientStore` (localStorage via zustand/persist) junto com `selectedAt` (timestamp da seleção).
2. **Restauração com TTL de 4h ("buff"):** ao cair em `/` sem slug (ex: pós-login), redireciona automaticamente para `/:slug` **somente se** `storedClientId` existir, corresponder a um cliente válido do usuário, **e** a seleção tiver menos de 4 horas (`isClientBuffValid()`). Após expirar, o usuário vê o `ClientPickerView` normalmente.

Usuários com apenas 1 cliente já tinham redirect automático no `ClientPickerView`. Este mecanismo cobre usuários com múltiplos clientes que já fizeram uma escolha anterior, com expiração automática para forçar re-seleção após uma sessão longa.

## Troca de Cliente

A troca de cliente exibe um overlay de transição animado antes de efetivar a mudança:

```
handleSelectClient(newId)
→ setTransitionClient({ id, name }) ← exibe ClientTransitionOverlay (~3.2s)
→ após 650ms: setClient(newId) ← persiste no localStorage
queryClient.invalidateQueries(['tasks']) ← TanStack Query refetch automático
selectClient(clientId)
→ sidebar.openSidebar() ← garante sidebar expandida ao chegar na HomeView
→ setTransitionTarget({ id, name }) ← exibe ClientTransitionOverlay (~3.2s)
→ após 650ms: queryClient.invalidateQueries(['tasks']) ← TanStack Query refetch automático
queryClient.invalidateQueries(['members']) ← TanStack Query refetch automático
setView("home")
navigateToClient(client) → navigate('/:slug') → view="home"
↓ onComplete (após fade-out do overlay)
→ toast cinza "Trocado para <Cliente>" (sonner, 3s)
→ setTransitionClient(null)
→ setTransitionTarget(null)
```

> O TanStack Query revalida automaticamente ao mudar as queries keys (clientId muda) — o `invalidateQueries` força refetch imediato mesmo que ainda esteja dentro do `staleTime`.
Expand Down
29 changes: 28 additions & 1 deletion docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Registro de decisões arquiteturais significativas do projeto Run/Way.

## ADR-003: Roteamento manual sem React Router

**Status:** Aceito (Abr 2026)
**Status:** Substituído por ADR-016 (Abr 2026)
**Decisão:** Roteamento implementado via `useUIStore` (view string enum) + renderização condicional em `App.tsx`
**Racional:** a aplicação é um SPA com estados bem definidos (loading → não autenticado → onboarding → app); o grafo de navegação é simples e não se beneficia de URLs parametrizadas ou lazy-loading por rota
**Consequências:** sem URLs navegáveis por deep link; adicionar URL-based routing no futuro exigiria refatoração da orquestração em `App.tsx`
Expand Down Expand Up @@ -136,3 +136,30 @@ Registro de decisões arquiteturais significativas do projeto Run/Way.
**Decisão:** a policy de leitura de `members` usa `USING (true)` para qualquer usuário autenticado — sem filtro por cliente
**Racional:** todas as abordagens tentadas para restringir visibilidade por cliente (subquery em `members`, `SECURITY DEFINER`, tabela auxiliar `member_roles`) resultaram em `infinite recursion` no Supabase; a visibilidade plana é aceitável dado que a aplicação é interna
**Consequências:** qualquer usuário autenticado vê todos os membros; filtro por cliente é feito no cliente via `user_clients` (com policy `user_read_same_client_user_clients` que não causa recursão)

---

## ADR-016: Roteamento via React Router DOM com slugs de cliente

**Status:** Aceito (Abr 2026)
**Decisão:** React Router DOM v6 substituiu o roteamento manual (ADR-003); cada view tem uma URL própria baseada no slug do cliente; `useAppNavigation` encapsula URL ↔ ViewType; `BrowserRouter` wraps o `App` no `main.tsx`
**Racional:** deep linking (compartilhar link de uma task, de uma view específica), navegação pelo browser (botões voltar/avançar), abertura de tasks por URL (`/:clientSlug/tasks/:subview/id/:taskId`) — todos impossíveis sem URL-based routing; o crescimento da app tornou a manutenção do roteamento manual custosa
**Consequências:** slug do cliente é lido do campo `slug` da tabela `clients` (já existente); `useUIStore.view` e `useClientStore` são mantidos para compatibilidade com código legado mas a source of truth é a URL; fechar o modal de task limpa o segmento `/id/:taskId` da URL; `vercel.json` já possuía rewrite `/*` → `/` (sem mudança necessária)

---

## ADR-017: ClientPickerView como tela obrigatória de seleção de cliente

**Status:** Aceito (Abr 2026)
**Decisão:** qualquer rota sem `effectiveClientId` válido (sem slug, slug inválido, `/clients`, etc.) exibe `ClientPickerView` com cards dos clientes disponíveis — exceto `/profile`, que é genuinamente global; se houver um cliente válido em cache (`cachedClient` de `useClientStore`), o redirect ocorre automaticamente sem exibir a tela de seleção; `App.tsx` renderiza `ClientPickerLayout` quando `!effectiveClientId && !isProfileView`
**Racional:** o guard anterior (`!clientSlug`) só cobria ausência de slug, deixando rotas como `/clients` ou slugs inválidos caírem no `AppLayout` sem cliente, causando estado ambíguo; expandir o guard para `!effectiveClientId` cobre todos os casos estruturalmente; o redirect via `cachedClient` preserva a experiência de retorno sem fricção
**Consequências:** `/clients` não é mais uma rota global tratada separadamente — redireciona para o cliente em cache preservando a view (ex: `/clients` → `/:slug/client-info`) ou exibe a tela de seleção; `useAppOrchestrator` expõe `cachedClient`, `navigateTo` e `navigateToClient`; o redirect usa `useEffect` para evitar loop de re-render (`selectClient` durante render causava "Too many re-renders"); `isGlobalView` foi simplificado para `isProfileView` em `App.tsx`

---

## ADR-018: AdminView com rota escopada por cliente (`/:clientSlug/admin`)

**Status:** Aceito (Abr 2026)
**Decisão:** a `AdminView` passa a ser acessada via `/:clientSlug/admin` em vez de `/admin` (rota global sem slug); `admin` foi removido de `GLOBAL_ROUTES` em `useAppNavigation`; a regra em `accessControl.ts` passou a ter `requiresClient: true`
**Racional:** admin gerencia dados (clientes, membros, notificações) que pertencem a uma empresa específica; ter o `clientSlug` na URL mantém consistência com o restante da aplicação, permite deep links contextuais e prepara a estrutura para um futuro multi-tenant onde cada empresa terá seu próprio escopo de admin
**Consequências:** a URL `/admin` deixa de existir (redireciona para `/` via wildcard); é necessário ter um cliente selecionado para acessar admin; `isGlobalView` em `App.tsx` não inclui mais `"admin"`, logo admin usa o `AppLayout` normal com sidebar
27 changes: 22 additions & 5 deletions docs/hooks/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ useNotifications(userId?: string | null, clientIds?: string[])
unreadCount: number
loading: boolean
error: string | null
hasMore: boolean
loadingOlder: boolean
reload: () => Promise<void>
loadOlder: () => Promise<void>
markAsRead: (id: string) => Promise<void>
markAllAsRead: () => Promise<void>
createNotification: (title, message, type?, metadata?) => Promise<void>
Expand All @@ -33,7 +36,10 @@ useNotifications(userId?: string | null, clientIds?: string[])
## Comportamento

### Fetch inicial
Chama `fetchNotifications(userId, clientIds)` na montagem. Evita refetch desnecessário com `loadedRef`.
Busca apenas os **últimos 7 dias** de notificações (`created_at >= hoje - 7d`). Evita refetch desnecessário com `loadedRef`.

### Paginação para notificações antigas
`loadOlder` busca a página anterior ao cursor (`nextBeforeRef`), trazendo até 20 itens por chamada. O cursor avança para o `created_at` da notificação mais antiga retornada. Quando o retorno vier com menos de 20 itens ou vazio, `hasMore` passa a `false`. Notificações antigas ficam em `olderNotificationsRef` e sobrevivem a reloads de polling.

### Realtime
Escuta `INSERT` na tabela `notifications` via Supabase channel. Aceita a notificação se:
Expand All @@ -58,14 +64,18 @@ const { notifications, unreadCount, ... } = useNotifications(member?.id, allClie
# fetchNotifications

```ts
fetchNotifications(userId: string, clientIds?: string[]): Promise<Notification[]>
fetchNotifications(
userId: string,
clientIds?: string[],
options?: { after?: string; before?: string; limit?: number }
): Promise<Notification[]>
```

Busca as últimas 50 notificações relevantes para o usuário:
Busca notificações relevantes para o usuário:
- `user_id = userId` — notificações pessoais
- `user_id IS NULL AND client_id IN (clientIds)` — broadcasts dos clientes do usuário

Ordena por `created_at DESC`.
Aceita filtros de data (`after` / `before` em ISO 8601) e `limit` (padrão 50). Ordena por `created_at DESC`.

---

Expand Down Expand Up @@ -93,13 +103,20 @@ Componente de sino com dropdown. Recebe notificações já carregadas via props
| `onMarkAllAsRead` | `() => void` | Marca todas como lidas |
| `onNotificationClick` | `(n) => void` | Ação ao clicar — navega dentro do cliente atual, não troca de cliente |
| `reload` | `() => void` | Callback de reload (usado pelo polling) |
| `onLoadOlder` | `() => void` | Carrega a próxima página de notificações antigas |
| `hasMore` | `boolean` | Se ainda há notificações mais antigas a buscar |
| `loadingOlder` | `boolean` | Estado de loading do `onLoadOlder` |
| `selectedClientId` | `string \| null` | Cliente ativo — usado apenas para filtrar a aba "Cliente atual" |

## Tabs

- **Todas** — todas as notificações do usuário
- **Todas** — notificações dos últimos 7 dias + antigas carregadas via "Ver anteriores"
- **Cliente atual** — filtra por `client_id === selectedClientId`

## Paginação

O botão **"Ver anteriores"** aparece no rodapé da aba "Todas" enquanto `hasMore = true`. Cada clique chama `onLoadOlder`, que busca a próxima página (até 20 itens) anterior ao cursor atual. O botão some quando não há mais páginas disponíveis.

---

# createNotificationForAll
Expand Down
Loading
Loading