From 3a4d5f911e2bf251eb5d6c5628f217ee97b0fb66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Tue, 21 Apr 2026 01:00:45 -0300 Subject: [PATCH 01/21] feat: :zap: implementado tanstack queries --- .claude/settings.local.json | 5 +- docs/architecture.md | 71 +-- docs/hooks/supabase.md | 38 +- docs/tech-audit.md | 489 ++++++++++++++++++ package-lock.json | 27 + package.json | 1 + src/components/TaskModal.tsx | 10 +- .../__templates__/useEntityQuery.template.ts | 29 ++ src/hooks/useAppOrchestrator.ts | 17 +- src/hooks/useClientTransition.ts | 14 +- src/hooks/useMembersQuery.ts | 10 + src/hooks/useSupabase.ts | 72 ++- src/hooks/useTasksQuery.ts | 13 + src/lib/queries.ts | 112 ++++ src/main.tsx | 19 +- src/store/useMemberStore.ts | 90 +--- src/store/useTaskStore.ts | 111 +--- src/views/MembersView/MembersView.tsx | 37 +- .../hooks/__tests__/useAdminData.test.ts | 19 +- src/views/admin/hooks/useAdminData.ts | 22 +- src/views/dashboard/DashboardView.tsx | 35 +- src/views/list/ListView.tsx | 38 +- src/views/reports/useReportsData.ts | 37 +- src/views/tasks/TasksView.tsx | 23 +- 24 files changed, 911 insertions(+), 428 deletions(-) create mode 100644 docs/tech-audit.md create mode 100644 src/hooks/__templates__/useEntityQuery.template.ts create mode 100644 src/hooks/useMembersQuery.ts create mode 100644 src/hooks/useTasksQuery.ts create mode 100644 src/lib/queries.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e1ddaa6..e50744f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -38,7 +38,10 @@ "Bash(npx --yes tsc --noEmit --project \"c:/Users/dinam/Documents/Projetos/run-way/tsconfig.json\")", "Bash(npm show:*)", "Bash(ls c:/Users/dinam/Documents/Projetos/run-way/node_modules/boneyard-js/react*)", - "Bash(npm list *)" + "Bash(npm list *)", + "Bash(xargs wc:*)", + "Bash(wc -l /c/Users/dinam/Documents/Projetos/run-way/src/lib/*.ts /c/Users/dinam/Documents/Projetos/run-way/src/store/*.ts /c/Users/dinam/Documents/Projetos/run-way/src/hooks/*.ts)", + "Bash(node -e \"const p = require\\('./node_modules/@tanstack/react-query/package.json'\\); console.log\\(p.version\\)\")" ] }, "hooks": { diff --git a/docs/architecture.md b/docs/architecture.md index d7b56cc..62b1c72 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,21 +31,24 @@ src/ ├── store/ │ ├── useUIStore.ts # Estado de UI: view ativa, modal aberto/fechado │ ├── useClientStore.ts # Cliente selecionado (persist localStorage) -│ ├── useTaskStore.ts # Cache de tasks, fetchTasks, invalidate -│ └── useMemberStore.ts # Cache de members por cliente, fetchMembers, invalidate +│ ├── 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) -│ ├── useSupabase.ts # Mutations CRUD (createTask, updateTask, deleteTask) +│ ├── 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 │ ├── useAppSidebar.ts # Sidebar desktop (persist) e mobile open/close │ ├── useTaskActions.ts # Estado e handlers de create/update/delete de tasks -│ └── useClientTransition.ts # Fluxo animado de troca de cliente (overlay + stores) +│ └── useClientTransition.ts # Fluxo animado de troca de cliente (overlay + TanStack Query invalidate) ├── contexts/ │ └── AuthContext.tsx # Sessão, member, clients, isAdmin, refreshProfile ├── lib/ │ ├── supabase.ts # Cliente Supabase +│ ├── queries.ts # fetchTasksFromDb, fetchMembersFromDb, queryKeys │ ├── steps.ts # Definição e lógica de steps │ └── utils.ts # Utilitários gerais ├── types/ @@ -62,11 +65,11 @@ AuthContext (AuthProvider) ↓ session, member, clients (filtrado por access_role), isAdmin, refreshProfile App.tsx (gates de auth + composição) └── useAppOrchestrator (toda a lógica de orquestração) - ├── useClientStore → selectedClientId (persist) - ├── useTaskStore → tasks, loading (lido pelas views diretamente) - ├── useMemberStore → members, loading (lido pelas views diretamente) - ├── useUIStore → view, isTaskModalOpen + ├── useClientStore → selectedClientId (persist) + ├── useMembersQuery → members com cache TanStack Query + ├── useUIStore → view, isTaskModalOpen ├── useSupabase({ memberId, clientId, isAdmin }) → mutations CRUD + └── QueryClientProvider (main.tsx) → staleTime 5min, gcTime 30min ├── view="home" → HomeView ├── view="clients" → UserClientsView ├── view="overview" → DashboardView (subview="overview") — métricas e resumo @@ -112,29 +115,34 @@ setClient(id) | `string` | Cliente específico selecionado | ### `useTaskStore` -Cache de tasks. Fetch lazy — nenhum dado é buscado no boot. Guard: retorna imediatamente se `clientId === undefined` ou se já há dados para o mesmo `cacheKey`. +Estado local mínimo para suporte a **update otimista**. O fetch de tasks foi migrado para `useTasksQuery` (TanStack Query). ```ts -tasks: Task[] -loading: boolean -error: string | null -cacheKey: string | undefined // `${clientId ?? 'all'}:${isAdmin}` -fetchTasks(clientId, isAdmin) // idempotente; guard se loading=true ou cacheKey inalterado -invalidate() // reseta tasks, cacheKey, error e loading=false +optimisticTasks: Task[] | null +applyOptimisticUpdate(tasks) // aplica snapshot otimista durante updateTask +clearOptimistic() // limpa após sucesso ou rollback ``` -> **Importante:** `invalidate()` reseta `loading: false`. Sem isso, um fetch em andamento no momento da troca de cliente deixaria `loading` preso em `true`, bloqueando todos os fetches seguintes via o guard `if (state.loading) return`. - ### `useMemberStore` -Cache de members filtrados por cliente. Fetch lazy por view. +Stub de compatibilidade de imports. O fetch de members foi migrado para `useMembersQuery` (TanStack Query). Pode ser removido quando não houver mais imports. + +### `useTasksQuery` / `useMembersQuery` +Query hooks baseados em TanStack Query v5. Configuração global: `staleTime: 5min`, `gcTime: 30min`, `retry: 2`. +```ts +// src/hooks/useTasksQuery.ts +const { data: tasks, isLoading, error } = useTasksQuery(clientId, isAdmin) + +// src/hooks/useMembersQuery.ts +const { data: members, isLoading, error } = useMembersQuery(clientId) +``` + +**Invalidação após mutação:** ```ts -members: Member[] -loading: boolean -error: string | null -cachedClientId: string | null | undefined -fetchMembers(clientId) // idempotente; guard se loading=true ou clientId inalterado -invalidate() // reseta members, cachedClientId, error e loading=false +queryClient.invalidateQueries({ queryKey: ['tasks'] }) // invalida todas as queries de tasks +queryClient.invalidateQueries({ queryKey: ['members'] }) // invalida todas as queries de members ``` +As query keys estão centralizadas em `src/lib/queries.ts` (`queryKeys.tasks`, `queryKeys.members`). + ## Fluxo de Autenticação — Gates em App.tsx `App.tsx` delega toda a lógica ao `useAppOrchestrator` e aplica retornos condicionais em ordem antes de montar o layout: @@ -185,17 +193,15 @@ A troca de cliente exibe um overlay de transição animado antes de efetivar a m handleSelectClient(newId) → setTransitionClient({ id, name }) ← exibe ClientTransitionOverlay (~3.2s) → após 650ms: setClient(newId) ← persiste no localStorage - invalidateTasks() ← reseta tasks + loading=false - invalidateMembers() ← reseta members + loading=false + queryClient.invalidateQueries(['tasks']) ← TanStack Query refetch automático + queryClient.invalidateQueries(['members']) ← TanStack Query refetch automático setView("home") ↓ onComplete (após fade-out do overlay) → toast cinza "Trocado para " (sonner, 3s) → setTransitionClient(null) ``` -> `invalidate` dos dois stores é chamado com `loading: false` para evitar o bug de loading eterno: se um fetch estava em andamento no momento da troca, o guard `if (state.loading) return` bloquearia todos os fetches seguintes sem o reset. - -> A store e o fetch são disparados **imediatamente** ao clicar, antes do overlay fechar. Quando a animação termina, os dados já chegaram ou estão a caminho — sem espera visível após a transição. +> 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`. ### `ClientTransitionOverlay` (`src/components/ClientTransitionOverlay.tsx`) @@ -233,9 +239,9 @@ interface ClientOption { - **admin** → todos os clientes - **user** → apenas os associados via `user_clients` -## Views lendo da store +## Views lendo dados -`DashboardView`, `MembersView`, `ReportsView` e `TasksView` leem `tasks` e `members` diretamente de `useTaskStore()` e `useMemberStore()`. Não recebem essas props via `App.tsx`. As views ainda chamam `fetchTasks`/`fetchMembers` no mount, mas o `App.tsx` já dispara esses fetchs ao resolver o `effectiveClientId` — reduzindo o delay de loading nas views. +`DashboardView`, `MembersView`, `ReportsView`, `TasksView`, `ListView` e `TaskModal` leem `tasks` e `members` via `useTasksQuery` e `useMembersQuery` diretamente. O TanStack Query deduplica fetches automaticamente — múltiplas views usando a mesma query key compartilham o mesmo cache sem requests duplicados. ## Papéis de Acesso @@ -283,5 +289,6 @@ A segunda policy é necessária para que `fetchMembersFromDb` consiga buscar tod ## Decisões - Sem router — navegação via `useUIStore.view` (poucas views) -- State manager: Zustand (4 stores separadas por responsabilidade: UI, Client, Tasks, Members) +- State manager: Zustand (UI + Client) + TanStack Query v5 (fetch/cache de tasks e members) +- Query keys centralizadas em `src/lib/queries.ts`; template para novos hooks em `src/hooks/__templates__/` - `any` intencional em dados do DB sem schema fixo em runtime diff --git a/docs/hooks/supabase.md b/docs/hooks/supabase.md index 5f0f85b..62d1f1f 100644 --- a/docs/hooks/supabase.md +++ b/docs/hooks/supabase.md @@ -5,29 +5,30 @@ ## Responsabilidade -Hook de **mutations apenas**. Não armazena estado — lê e escreve na store (`useDataStore`) após cada operação. +Hook de **mutations apenas**. Não armazena estado — após cada operação usa `queryClient.invalidateQueries` (TanStack Query) ou `queryClient.setQueryData` para cache otimista. ## Funções -- `createTask(data)` — insere tarefa + steps + assignees; chama `invalidate()` + `fetchData()` no fim -- `updateTask(data)` — update otimista na store, depois persiste no DB; reverte em caso de erro -- `deleteTask(id)` — remove do DB e atualiza a store localmente (sem re-fetch) +- `createTask(data)` — insere tarefa + steps + assignees; invalida a query `['tasks', ...]` no fim +- `updateTask(data)` — update otimista no cache do TanStack Query, depois persiste no DB; reverte em caso de erro +- `deleteTask(id)` — remove do DB e atualiza o cache local sem re-fetch ## Update otimista (`updateTask`) ``` -1. Snapshot de previousTasks -2. useDataStore.setState → aplica alteração localmente (UI actualiza imediatamente) -3. Persiste no DB (tasks + steps + assignees) -4. Se erro → restaura previousTasks (sem race condition com fetch concorrente) +1. Snapshot de cachedTasks via queryClient.getQueryData +2. queryClient.setQueryData → aplica alteração localmente (UI actualiza imediatamente) +3. useTaskStore.applyOptimisticUpdate → sincroniza o store local (para rollback via clearOptimistic) +4. Persiste no DB (tasks + steps + assignees) +5. Se erro → queryClient.setQueryData(prev) + useTaskStore.clearOptimistic() ``` ## Steps (`upsertSteps`) -Função privada que faz insert/update de cada step e reseta os assignees: -- Step novo (sem `id`): INSERT em `task_steps` → obtém `stepId` -- Step existente (com `id`): UPDATE em `task_steps` -- Sempre: DELETE + INSERT em `step_assignees` +Função privada que faz insert/update de cada step e compara diff de assignees: +- Step existente: UPDATE em `task_steps` apenas se mudou +- Assignees adicionados: INSERT em `step_assignees` +- Assignees removidos: DELETE em `step_assignees` ## Tabelas Supabase @@ -38,6 +39,17 @@ Função privada que faz insert/update de cada step e reseta os assignees: ## Estado `useSupabase` **não retorna** `tasks`, `members`, `loading` nem `error`. -Esses dados vêm de `useDataStore` (ou `useAppStore`) diretamente nas views. +Esses dados vêm de `useTasksQuery` / `useMembersQuery` diretamente nas views. Retorna apenas: `createTask`, `updateTask`, `deleteTask`. + +## Query Keys + +As query keys estão centralizadas em `src/lib/queries.ts`: + +```ts +queryKeys.tasks(clientId, isAdmin) // ['tasks', clientId ?? 'all', isAdmin] +queryKeys.members(clientId) // ['members', clientId ?? 'all'] +``` + +Para invalidar tudo sem saber o clientId exato: `queryClient.invalidateQueries({ queryKey: ['tasks'] })` diff --git a/docs/tech-audit.md b/docs/tech-audit.md new file mode 100644 index 0000000..dd9b69d --- /dev/null +++ b/docs/tech-audit.md @@ -0,0 +1,489 @@ +# Auditoria Técnica — Run/Way + +> Análise de oportunidades de melhoria técnica (não funcional). Foco em performance, type safety, segurança, testabilidade e DX. + +--- + +## 1. Estado Global e Data Fetching + +### O que está bom +- 4 stores Zustand independentes por responsabilidade (`useUIStore`, `useClientStore`, `useTaskStore`, `useMemberStore`) — sem store monolítico +- AuthContext isolado, sem contaminar stores de dados +- Devtools middleware em todas as stores +- `App.tsx` enxuto (~100 linhas) via `useAppOrchestrator` + +### Problema crítico: sem caching estratégico + +Em `useSupabase.ts`, o `refresh()` invalida **tudo** e dispara um novo fetch a cada mutação: + +```typescript +const refresh = useCallback(async () => { + useTaskStore.getState().invalidate() + await useTaskStore.getState().fetchTasks(clientId, isAdmin ?? false) +}, [clientId, isAdmin]) +``` + +Para 200+ tarefas, isso é custoso e desnecessário. + +**Solução recomendada — TanStack Query (React Query):** + +```bash +npm install @tanstack/react-query +``` + +```typescript +// Fetch com cache automático +const { data: tasks } = useQuery({ + queryKey: ['tasks', clientId], + queryFn: () => fetchTasksFromDb(clientId, isAdmin), + staleTime: 5 * 60 * 1000, // 5 min antes de revalidar + gcTime: 30 * 60 * 1000, // garbage collect após 30 min +}) + +// Mutation com invalidation seletiva +const { mutate: updateTask } = useMutation({ + mutationFn: (task: Task) => supabase.from('tasks').update(task).eq('id', task.id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tasks', clientId] }) + }, +}) +``` + +**Benefícios:** caching inteligente, retry automático, sem refetch desnecessário, devtools próprio. + +**Prioridade:** Alta + +--- + +## 2. Type Safety + +### Problema: `any` vindo do Supabase + +Dados do banco chegam como `any[]`, forçando type assertions manuais por todo o codebase. + +**Solução — Codegen de tipos Supabase:** + +```bash +npx supabase gen types typescript --project-id > src/types/supabase.ts +``` + +Gera automaticamente: + +```typescript +export type Database = { + public: { + Tables: { + tasks: { Row: { id: string; title: string; ... } } + members: { Row: { id: string; name: string; role: 'Designer' | 'Developer'; ... } } + } + } +} +``` + +Uso com o client: + +```typescript +import { createClient } from '@supabase/supabase-js' +import type { Database } from '@/types/supabase' + +export const supabase = createClient(url, key) +// Agora supabase.from('tasks').select() retorna Task['Row'][] — zero any +``` + +**Prioridade:** Alta + +--- + +### Problema: Props gigantes em AppLayout + +`AppLayoutProps` tem 28 propriedades. Difícil rastrear origem, refatoração arriscada. + +**Solução — agrupar em objetos e usar Context para evitar prop drilling:** + +```typescript +interface HeaderCtx { darkMode: boolean; onToggleDark: () => void; notifications: Notification[]; ... } +interface SidebarCtx { sidebarOpen: boolean; onToggleSidebar: () => void; view: ViewType; ... } + +const LayoutCtx = createContext<{ header: HeaderCtx; sidebar: SidebarCtx } | null>(null) + +function AppLayout(props: AppLayoutProps) { + return ( + + + + + ) +} +``` + +**Prioridade:** Alta + +--- + +### Problema: sem validação em runtime dos dados do banco + +Tipos TypeScript não existem em runtime. Se o schema muda, o código quebra silenciosamente. + +**Solução — Zod para validar respostas da API:** + +```bash +npm install zod +``` + +```typescript +// src/lib/validators.ts +const TaskStepSchema = z.object({ + id: z.string().uuid(), + type: z.enum(['design', 'approval', 'dev', 'qa']), + start_date: z.string().date().nullable(), + end_date: z.string().date().nullable(), + step_assignees: z.array(z.object({ member_id: z.string() })).default([]), +}) + +const DbTaskRowSchema = z.object({ + id: z.string().uuid(), + title: z.string().min(1), + task_steps: z.array(TaskStepSchema).default([]), +}) + +// Em useTaskStore.ts +const parsed = DbTaskRowSchema.parse(row) // lança erro com mensagem clara se inválido +``` + +**Prioridade:** Média + +--- + +## 3. Performance + +### Problema: sem code splitting + +Todas as views são importadas no top-level de `AppRouter.tsx`. O bundle inicial carrega 100% do código independente de qual view o usuário acessa. + +**Solução — React.lazy + Suspense:** + +```typescript +// src/components/AppRouter.tsx +import { lazy, Suspense } from 'react' + +const DashboardView = lazy(() => import('@/views/dashboard')) +const MembersView = lazy(() => import('@/views/MembersView')) +const ReportsView = lazy(() => import('@/views/reports')) +// ... demais views + +function AppRouter({ view, ...props }: AppRouterProps) { + return ( + }> + {view === 'calendar' && } + {view === 'members' && } + {/* ... */} + + ) +} +``` + +**Impacto estimado:** bundle inicial reduz ~40%. Cada view carrega sob demanda. + +**Prioridade:** Alta + +--- + +### Problema: listas sem virtualização + +`MembersView`, `TasksView` e listas de notificações podem crescer para 200+ itens. Cada item gera um nó DOM, mesmo se fora da viewport. + +**Solução — react-window para listas > 50 items:** + +```bash +npm install react-window +``` + +```typescript +import { FixedSizeList as List } from 'react-window' + + + {({ index, style }) => ( +
+ +
+ )} +
+``` + +**Impacto:** renderiza apenas ~10-15 itens visíveis por vez, -90% de nós DOM para listas grandes. + +**Prioridade:** Média (aplicar quando listas crescerem) + +--- + +### Problema: componentes UI não memoizados + +`NotificationBell` e `MemberRow` re-renderizam desnecessariamente quando props de contexto mudam. + +**Solução — `memo` com comparação customizada:** + +```typescript +const NotificationBell = memo( + function NotificationBell({ notifications, unreadCount, ...props }: Props) { ... }, + (prev, next) => + prev.unreadCount === next.unreadCount && + prev.notifications.length === next.notifications.length +) +``` + +**Prioridade:** Média + +--- + +## 4. Segurança + +### CRÍTICO: Service Role Key exposta no cliente + +```typescript +// src/lib/supabase.ts +const serviceRoleKey = import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY +export const supabaseAdmin = serviceRoleKey ? createClient(url, serviceRoleKey) : null +``` + +A service role bypassa toda RLS. Se a `.env` vazar (bundle público, log, etc.), qualquer pessoa pode deletar ou modificar todos os dados. + +**Solução — mover operações admin para um backend:** + +```typescript +// ❌ REMOVER do cliente +export const supabaseAdmin = createClient(url, serviceRoleKey) + +// ✅ Chamar endpoint autenticado +async function inviteUser(email: string) { + return fetch('/api/admin/users', { + method: 'POST', + headers: { Authorization: `Bearer ${session.access_token}` }, + body: JSON.stringify({ email }), + }) +} + +// Backend (ex: Supabase Edge Function ou Node.js) +// Apenas aqui a service role fica +``` + +**Prioridade:** Crítica — resolver antes do próximo deploy + +--- + +### ALTA: sem rate limiting em mutations + +`useSupabase.ts` não tem throttle em operações de escrita. Um usuário mal-intencionado pode disparar centenas de requisições por segundo. + +**Solução:** + +```typescript +import { throttle } from 'lodash-es' + +const updateTask = useCallback( + throttle(async (task: Task) => { + await supabase.from('tasks').update(task).eq('id', task.id) + }, 500), // máximo 2 req/s + [] +) +``` + +**Prioridade:** Alta + +--- + +### MÉDIA: sem Content Security Policy + +Sem CSP, scripts injetados (ex: via XSS ou extensão) podem exfiltrar tokens do Supabase. + +**Solução:** + +```typescript +// vite.config.ts +server: { + headers: { + 'Content-Security-Policy': + "default-src 'self'; connect-src 'self' https://*.supabase.co; script-src 'self'", + }, +} +``` + +**Prioridade:** Média + +--- + +## 5. Realtime Subscriptions + +### Problema: cleanup não robusto + +`useNotifications.ts` pode acumular subscriptions se um componente montar/desmontar rapidamente: + +```typescript +const channel = supabase.channel('notifications-realtime') +channel.subscribe() + +return () => { + supabase.removeChannel(channel) // pode ser chamado múltiplas vezes +} +``` + +**Solução:** + +```typescript +const subscriptionRef = useRef<{ status: string } | null>(null) + +useEffect(() => { + if (subscriptionRef.current) return // guard + + const channel = supabase.channel(`notifications:${userId}`) + subscriptionRef.current = channel.subscribe() + + return () => { + channel.unsubscribe() + subscriptionRef.current = null + } +}, [userId]) +``` + +**Prioridade:** Média + +--- + +## 6. Testes + +### Situação atual + +Cobertura estimada: ~5%. Existem testes em `AdminView`, `ProfileView` e algumas rotas de API. Os principais hooks, stores, contexts e utils não têm testes. + +### Arquivos sem cobertura (alta criticidade) + +| Arquivo | Por que testar | +|---|---| +| `useTaskStore.ts` | Core do estado, caching key, invalidação | +| `useAppOrchestrator.ts` | Orquestra todo o fluxo | +| `AuthContext.tsx` | Segurança, expiração, domain validation | +| `lib/accessControl.ts` | Permissões, guards de rota | +| `lib/cascadePhases.ts` / `dateUtils.ts` | Lógica de datas crítica | + +### Setup de cobertura recomendado + +```bash +npm install -D @vitest/coverage-v8 +``` + +```json +// package.json +"test:coverage": "vitest run --coverage" +``` + +```typescript +// vitest.config.ts +coverage: { + provider: 'v8', + thresholds: { lines: 60, functions: 60, branches: 50 }, + include: ['src/lib/**', 'src/store/**', 'src/hooks/**'], +} +``` + +### Exemplo de teste de store + +```typescript +// src/store/__tests__/useTaskStore.test.ts +import { describe, it, expect, beforeEach } from 'vitest' +import { useTaskStore } from '@/store/useTaskStore' + +describe('useTaskStore', () => { + beforeEach(() => useTaskStore.setState({ tasks: [], loading: false, cacheKey: undefined })) + + it('invalidate reseta o estado', () => { + useTaskStore.setState({ tasks: [{ id: '1' }] as any, loading: true }) + useTaskStore.getState().invalidate() + expect(useTaskStore.getState().tasks).toEqual([]) + }) +}) +``` + +**Prioridade:** Alta + +--- + +## 7. Developer Experience + +### Env vars sem validação no boot + +`import.meta.env.VITE_*` são acessados diretamente por todo o código. Se uma var faltar, o erro aparece tarde (runtime, não boot). + +**Solução:** + +```typescript +// src/lib/env.ts +const required = (key: string): string => { + const v = import.meta.env[key] + if (!v) throw new Error(`Env var ${key} não configurada`) + return v +} + +export const config = { + supabaseUrl: required('VITE_SUPABASE_URL'), + supabaseAnonKey: required('VITE_SUPABASE_ANON_KEY'), + googleClientId: import.meta.env.VITE_GOOGLE_CLIENT_ID as string | undefined, + allowedDomain: required('VITE_ALLOWED_DOMAIN'), +} as const +``` + +**Prioridade:** Baixa + +--- + +### Sem ADRs (Architecture Decision Records) + +Não há registro de por que Zustand, por que roteamento manual, por que sem Next.js. + +**Solução — criar `docs/decisions.md`** com entradas como: + +```markdown +## ADR-001: Zustand vs Redux + +**Status:** Aceito (Abr 2026) +**Decision:** 4 stores Zustand independentes +**Rationale:** minimal boilerplate, bundle 2 KB, devtools built-in, sem Provider +**Consequências:** sem time-travel debug; logging manual necessário +``` + +**Prioridade:** Baixa + +--- + +## 8. Tabela de Prioridades + +| Prioridade | Item | Esforço | +|---|---|---| +| **Crítica** | Remover service role key do cliente | 2h | +| **Alta** | TanStack Query (caching + invalidação seletiva) | 16h | +| **Alta** | Code splitting com React.lazy | 6h | +| **Alta** | Codegen de tipos Supabase (zero `any`) | 4h | +| **Alta** | Agrupar props AppLayout em Context | 4h | +| **Alta** | Testes unitários de stores e hooks críticos | 8h | +| **Média** | Rate limiting em mutations | 2h | +| **Média** | Cleanup robusto de Realtime subscriptions | 2h | +| **Média** | Memoização com `memo` em componentes UI | 4h | +| **Média** | Validação de dados com Zod | 6h | +| **Média** | CSP headers | 1h | +| **Baixa** | Virtual scrolling (listas > 50 items) | 4h | +| **Baixa** | Centralizar env vars em `config` | 1h | +| **Baixa** | ADR documentation | 3h | + +**Total:** ~63h ≈ 8 dias de trabalho + +--- + +## 9. O que está bem e deve ser mantido + +- Estrutura de views como pastas com co-location (convençã de `views//`) +- Zustand com stores separadas por responsabilidade +- `useAppOrchestrator` como ponto único de orquestração +- Domain validation e session expiration em AuthContext +- `getSafeRedirectUrl()` e `toSafeUiErrorMessage()` no fluxo de auth +- RLS policies no Supabase bem-pensadas (sem recursão) +- `cascadePhases` isolado em `dateUtils` +- CLAUDE.md + docs/ detalhados e atualizados +- CI/CD com Gitleaks, CodeQL e block-on-friday diff --git a/package-lock.json b/package-lock.json index a12aec1..53aa5da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", "@supabase/supabase-js": "^2.101.1", + "@tanstack/react-query": "^5.99.2", "boneyard-js": "^1.6.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -3263,6 +3264,32 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tanstack/query-core": { + "version": "5.99.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", + "integrity": "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.99.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz", + "integrity": "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.99.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index 5a8872f..f035f85 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", "@supabase/supabase-js": "^2.101.1", + "@tanstack/react-query": "^5.99.2", "boneyard-js": "^1.6.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/components/TaskModal.tsx b/src/components/TaskModal.tsx index c896c35..223fc0c 100644 --- a/src/components/TaskModal.tsx +++ b/src/components/TaskModal.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import { useMemberStore } from '@/store/useMemberStore'; +import React, { useState } from 'react'; +import { useMembersQuery } from '@/hooks/useMembersQuery'; import { useClients } from '@/hooks/useClients'; import { Input, Label, Button, ConfirmModal } from './ui'; import { Save, ExternalLink, Trash2, Users, AlertCircle, CheckCircle2 } from 'lucide-react'; @@ -17,11 +17,7 @@ import { isWeekendOrHoliday, getHolidayName, nextNonHolidayBusinessDay } from '. const TaskModal: React.FC = ({ task, members: propMembers, onClose, onSave, onDelete, holidays }) => { const { effectiveClientId } = useClients(); - const { fetchMembers, members: storeMembers } = useMemberStore(); - - useEffect(() => { - fetchMembers(effectiveClientId); - }, [effectiveClientId, fetchMembers]); + const { data: storeMembers = [] } = useMembersQuery(effectiveClientId); const resolvedMembers = storeMembers.length > 0 ? storeMembers : propMembers; diff --git a/src/hooks/__templates__/useEntityQuery.template.ts b/src/hooks/__templates__/useEntityQuery.template.ts new file mode 100644 index 0000000..a65b635 --- /dev/null +++ b/src/hooks/__templates__/useEntityQuery.template.ts @@ -0,0 +1,29 @@ +/** + * TEMPLATE: Query hook com TanStack Query + * + * Como usar: + * 1. Copie este arquivo para src/hooks/useQuery.ts + * 2. Substitua Entity/entity pelos nomes reais + * 3. Adicione a query key em src/lib/queries.ts + * 4. Adicione a função de fetch em src/lib/queries.ts + * 5. Consuma com: const { data, isLoading, error } = useQuery(...) + * + * Invalidação após mutação: + * queryClient.invalidateQueries({ queryKey: queryKeys.entity(param) }) + * + * Update otimista (se necessário): + * const prev = queryClient.getQueryData(queryKeys.entity(param)) + * queryClient.setQueryData(queryKeys.entity(param), newData) + * // em caso de erro: queryClient.setQueryData(queryKeys.entity(param), prev) + */ + +import { useQuery } from '@tanstack/react-query' +// import { fetchEntityFromDb, queryKeys } from '@/lib/queries' + +// export function useEntityQuery(param: string | null | undefined) { +// return useQuery({ +// queryKey: queryKeys.entity(param), +// queryFn: () => fetchEntityFromDb(param), +// enabled: param !== undefined, +// }) +// } diff --git a/src/hooks/useAppOrchestrator.ts b/src/hooks/useAppOrchestrator.ts index ed26256..029e0bd 100644 --- a/src/hooks/useAppOrchestrator.ts +++ b/src/hooks/useAppOrchestrator.ts @@ -2,8 +2,6 @@ import { useEffect, useCallback } from "react"; import { toast } from "sonner"; import { useClientStore } from "@/store/useClientStore"; import { useUIStore } from "@/store/useUIStore"; -import { useMemberStore } from "@/store/useMemberStore"; -import { useTaskStore } from "@/store/useTaskStore"; import { useAuthContext } from "@/contexts/AuthContext"; import { useSupabase } from "@/hooks/useSupabase"; import { useHolidays } from "@/hooks/useHolidays"; @@ -12,6 +10,7 @@ import { useAppTheme } from "@/hooks/useAppTheme"; import { useAppSidebar } from "@/hooks/useAppSidebar"; import { useTaskActions } from "@/hooks/useTaskActions"; import { useClientTransition } from "@/hooks/useClientTransition"; +import { useMembersQuery } from "@/hooks/useMembersQuery"; import { resolveNotificationRoute } from "@/lib/notifications"; import { canAccessView, resolveAccessRole } from "@/lib/accessControl"; import type { Notification } from "@/types/notification"; @@ -26,8 +25,6 @@ export function useAppOrchestrator() { const hasClients = auth.clients.length > 0; const { selectedClientId, setClient } = useClientStore(); - const { members, fetchMembers } = useMemberStore(); - const { fetchTasks } = useTaskStore(); const effectiveClientId = selectedClientId === undefined @@ -39,27 +36,19 @@ export function useAppOrchestrator() { useEffect(() => { if (auth.loading || !auth.session) return; - let resolvedClientId: string | null | undefined = selectedClientId; - if (selectedClientId === undefined && hasClients) { setClient(auth.clients[0].id); - resolvedClientId = auth.clients[0].id; } else if (!hasClients) { setClient(undefined); - resolvedClientId = null; } else if ( typeof selectedClientId === "string" && !auth.clients.find((c) => c.id === selectedClientId) ) { setClient(auth.clients[0]?.id ?? undefined); - resolvedClientId = auth.clients[0]?.id ?? null; } + }, [auth.loading, auth.session, hasClients, auth.clients, selectedClientId, setClient]); - if (resolvedClientId !== undefined) { - fetchTasks(resolvedClientId, auth.isAdmin); - fetchMembers(resolvedClientId); - } - }, [auth.loading, auth.session, hasClients, auth.clients, selectedClientId, setClient, fetchTasks, fetchMembers, auth.isAdmin]); + const { data: members = [] } = useMembersQuery(effectiveClientId); const { createTask, updateTask, deleteTask } = useSupabase({ memberId: auth.member?.id, diff --git a/src/hooks/useClientTransition.ts b/src/hooks/useClientTransition.ts index bdb31cc..e523196 100644 --- a/src/hooks/useClientTransition.ts +++ b/src/hooks/useClientTransition.ts @@ -1,9 +1,9 @@ import { useState, useCallback } from "react"; import { toast } from "sonner"; +import { useQueryClient } from "@tanstack/react-query"; import { useClientStore } from "@/store/useClientStore"; -import { useTaskStore } from "@/store/useTaskStore"; -import { useMemberStore } from "@/store/useMemberStore"; import { useUIStore } from "@/store/useUIStore"; +import { queryKeys } from "@/lib/queries"; import type { ClientOption } from "@/contexts/AuthContext"; interface TransitionTarget { @@ -19,9 +19,8 @@ export function useClientTransition( clients: ClientOption[], effectiveClientId: string | null | undefined ) { + const queryClient = useQueryClient(); const setClient = useClientStore((s) => s.setClient); - const invalidateTasks = useTaskStore((s) => s.invalidate); - const invalidateMembers = useMemberStore((s) => s.invalidate); const setView = useUIStore((s) => s.setView); const [transitionTarget, setTransitionTarget] = useState(null); @@ -34,11 +33,12 @@ export function useClientTransition( setTransitionTarget({ id: clientId, name: target.name }); setTimeout(() => { setClient(clientId); - invalidateTasks(); - invalidateMembers(); + queryClient.invalidateQueries({ queryKey: queryKeys.tasks(clientId ?? null, false) }); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['members'] }); setView("home"); }, 650); - }, [clients, effectiveClientId, setClient, invalidateTasks, invalidateMembers, setView]); + }, [clients, effectiveClientId, setClient, queryClient, setView]); const onTransitionComplete = useCallback(() => { if (!transitionTarget) return; diff --git a/src/hooks/useMembersQuery.ts b/src/hooks/useMembersQuery.ts new file mode 100644 index 0000000..39a06b6 --- /dev/null +++ b/src/hooks/useMembersQuery.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchMembersFromDb, queryKeys } from '@/lib/queries' + +export function useMembersQuery(clientId: string | null | undefined) { + return useQuery({ + queryKey: queryKeys.members(clientId), + queryFn: () => fetchMembersFromDb(clientId), + enabled: clientId !== undefined, + }) +} diff --git a/src/hooks/useSupabase.ts b/src/hooks/useSupabase.ts index 53aa3b5..1da97d5 100644 --- a/src/hooks/useSupabase.ts +++ b/src/hooks/useSupabase.ts @@ -1,10 +1,12 @@ import { useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { supabase } from '@/lib/supabase' import type { Task, Step } from '@/lib/steps' import { toast } from 'sonner' import { logAudit } from '@/lib/audit' import { useTaskStore } from '@/store/useTaskStore' import { toSafeUiErrorMessage } from '@/lib/errorSanitizer' +import { queryKeys } from '@/lib/queries' const devLog = import.meta.env.DEV ? (...args: unknown[]) => console.warn(...args) @@ -83,11 +85,11 @@ function didTaskFieldsChange(prevTask: Task | undefined, nextTask: Task, resolve export function useSupabase(options: UseSupabaseOptions = {}) { const { memberId, clientId, isAdmin } = options + const queryClient = useQueryClient() - const refresh = useCallback(async () => { - useTaskStore.getState().invalidate() - await useTaskStore.getState().fetchTasks(clientId, isAdmin ?? false) - }, [clientId, isAdmin]) + const invalidateTasks = useCallback(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.tasks(clientId ?? null, isAdmin ?? false) }) + }, [queryClient, clientId, isAdmin]) const createTask = useCallback(async (taskData: Omit): Promise => { const resolvedClientId = taskData.clientId ?? clientId ?? null @@ -112,7 +114,7 @@ export function useSupabase(options: UseSupabaseOptions = {}) { const ok = await createAllSteps(taskRow.id, taskData.steps) if (!ok) { toast.error('Tarefa criada mas erro ao guardar etapas') - await refresh() + invalidateTasks() return false } @@ -128,24 +130,35 @@ export function useSupabase(options: UseSupabaseOptions = {}) { }) } - await refresh() + invalidateTasks() return true - }, [clientId, memberId, refresh]) + }, [clientId, memberId, invalidateTasks]) const updateTask = useCallback(async (taskData: Task): Promise => { const resolvedClientId = taskData.clientId ?? clientId ?? null - const previousTasks = useTaskStore.getState().tasks - const prevTask = previousTasks.find(t => t.id === taskData.id) + + const cachedTasks = queryClient.getQueryData( + queryKeys.tasks(clientId ?? null, isAdmin ?? false) + ) ?? [] + + const prevTask = cachedTasks.find(t => t.id === taskData.id) if (!prevTask) { toast.error('Não foi possível comparar alterações da demanda') - await refresh() + invalidateTasks() return false } - useTaskStore.setState(s => ({ - tasks: s.tasks.map(t => t.id === taskData.id ? { ...taskData, clientId: resolvedClientId ?? undefined } : t) - })) + const updatedTasks = cachedTasks.map(t => + t.id === taskData.id ? { ...taskData, clientId: resolvedClientId ?? undefined } : t + ) + queryClient.setQueryData(queryKeys.tasks(clientId ?? null, isAdmin ?? false), updatedTasks) + useTaskStore.getState().applyOptimisticUpdate(updatedTasks) + + const rollback = () => { + queryClient.setQueryData(queryKeys.tasks(clientId ?? null, isAdmin ?? false), cachedTasks) + useTaskStore.getState().clearOptimistic() + } if (didTaskFieldsChange(prevTask, taskData, resolvedClientId)) { const { error: taskErr } = await supabase @@ -162,7 +175,7 @@ export function useSupabase(options: UseSupabaseOptions = {}) { .eq('id', taskData.id) if (taskErr) { - useTaskStore.setState({ tasks: previousTasks }) + rollback() toast.error(toSafeUiErrorMessage(taskErr.message)) return false } @@ -206,7 +219,7 @@ export function useSupabase(options: UseSupabaseOptions = {}) { .eq('id', update.id) if (error) { - useTaskStore.setState({ tasks: previousTasks }) + rollback() toast.error(toSafeUiErrorMessage(error.message)) return false } @@ -223,13 +236,12 @@ export function useSupabase(options: UseSupabaseOptions = {}) { const prevAssignees = new Set(prevStep.assignees) const nextAssignees = new Set(step.assignees) - const toAdd = step.assignees.filter(memberId => !prevAssignees.has(memberId)) - const toRemove = prevStep.assignees.filter(memberId => !nextAssignees.has(memberId)) + const toAdd = step.assignees.filter(id => !prevAssignees.has(id)) + const toRemove = prevStep.assignees.filter(id => !nextAssignees.has(id)) if (toAdd.length > 0) { - assigneesToAdd.push(...toAdd.map(memberId => ({ step_id: stepId, member_id: memberId }))) + assigneesToAdd.push(...toAdd.map(id => ({ step_id: stepId, member_id: id }))) } - if (toRemove.length > 0) { assigneesToRemoveByStep.set(stepId, toRemove) } @@ -243,7 +255,7 @@ export function useSupabase(options: UseSupabaseOptions = {}) { .in('member_id', memberIds) if (error) { - useTaskStore.setState({ tasks: previousTasks }) + rollback() toast.error(toSafeUiErrorMessage(error.message)) return false } @@ -255,13 +267,15 @@ export function useSupabase(options: UseSupabaseOptions = {}) { .insert(assigneesToAdd) if (error) { - useTaskStore.setState({ tasks: previousTasks }) + rollback() toast.error(toSafeUiErrorMessage(error.message)) return false } } + useTaskStore.getState().clearOptimistic() toast.success(`Demanda "${taskData.title}" atualizada`) + if (memberId && prevTask) { if (prevTask.status?.blocked !== taskData.status?.blocked) { await logAudit({ @@ -292,10 +306,14 @@ export function useSupabase(options: UseSupabaseOptions = {}) { } return true - }, [clientId, memberId, refresh]) + }, [clientId, isAdmin, memberId, queryClient, invalidateTasks]) const deleteTask = useCallback(async (id: string): Promise => { - const deletedTask = useTaskStore.getState().tasks.find(t => t.id === id) + const cachedTasks = queryClient.getQueryData( + queryKeys.tasks(clientId ?? null, isAdmin ?? false) + ) ?? [] + + const deletedTask = cachedTasks.find(t => t.id === id) const { error } = await supabase.from('tasks').delete().eq('id', id) if (error) { @@ -303,7 +321,11 @@ export function useSupabase(options: UseSupabaseOptions = {}) { return false } - useTaskStore.setState(s => ({ tasks: s.tasks.filter(t => t.id !== id) })) + queryClient.setQueryData( + queryKeys.tasks(clientId ?? null, isAdmin ?? false), + cachedTasks.filter(t => t.id !== id) + ) + toast.success(`Demanda "${deletedTask?.title ?? id}" eliminada`) if (memberId && deletedTask) { @@ -318,7 +340,7 @@ export function useSupabase(options: UseSupabaseOptions = {}) { } return true - }, [memberId]) + }, [clientId, isAdmin, memberId, queryClient]) return { createTask, updateTask, deleteTask } } diff --git a/src/hooks/useTasksQuery.ts b/src/hooks/useTasksQuery.ts new file mode 100644 index 0000000..c5e794d --- /dev/null +++ b/src/hooks/useTasksQuery.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchTasksFromDb, queryKeys } from '@/lib/queries' + +export function useTasksQuery( + clientId: string | null | undefined, + isAdmin: boolean +) { + return useQuery({ + queryKey: queryKeys.tasks(clientId ?? null, isAdmin), + queryFn: () => fetchTasksFromDb(clientId ?? null, isAdmin), + enabled: clientId !== undefined, + }) +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts new file mode 100644 index 0000000..fc302c2 --- /dev/null +++ b/src/lib/queries.ts @@ -0,0 +1,112 @@ +import { supabase } from '@/lib/supabase' +import type { Task, Step, StepType } from '@/lib/steps' +import type { Member } from '@/hooks/useSupabase' +import type { DbTaskRow } from '@/types/db' + +// ─── Query Keys ─────────────────────────────────────────────────────────────── + +export const queryKeys = { + tasks: (clientId: string | null, isAdmin: boolean) => + ['tasks', clientId ?? 'all', isAdmin] as const, + members: (clientId: string | null | undefined) => + ['members', clientId ?? 'all'] as const, +} + +// ─── Task Queries ────────────────────────────────────────────────────────────── + +function dbRowToTask(row: DbTaskRow): Task { + const steps: Step[] = (row.task_steps ?? []) + .sort((a, b) => a.step_order - b.step_order) + .map(s => ({ + id: s.id, + type: s.type as StepType, + order: s.step_order, + active: s.active, + start: s.start_date ?? '', + end: s.end_date ?? '', + assignees: (s.step_assignees ?? []).map((a: { member_id: string }) => a.member_id), + })) + + return { + id: row.id, + title: row.title, + clickupLink: row.clickup_link ?? undefined, + clientId: row.client_id ?? undefined, + status: { + blocked: row.blocked, + blockedAt: row.blocked_at ?? undefined, + }, + createdAt: row.created_at, + concludedAt: row.concluded_at ?? undefined, + concludedBy: row.concluded_by ?? undefined, + steps, + } +} + +const TASK_SELECT = ` + id, title, clickup_link, blocked, blocked_at, created_at, client_id, concluded_at, concluded_by, + task_steps ( + id, type, step_order, active, start_date, end_date, + step_assignees ( member_id ) + ) +` as const + +export async function fetchTasksFromDb( + clientId: string | null, + isAdmin: boolean +): Promise { + if (clientId === null) { + if (!isAdmin) return [] + const { data, error } = await supabase + .from('tasks') + .select(TASK_SELECT) + .order('created_at', { ascending: false }) + if (error) throw new Error(error.message) + return (data ?? []).map(dbRowToTask) + } + + const { data, error } = await supabase + .from('tasks') + .select(TASK_SELECT) + .eq('client_id', clientId) + .order('created_at', { ascending: false }) + + if (error) throw new Error(error.message) + return (data ?? []).map(dbRowToTask) +} + +// ─── Member Queries ──────────────────────────────────────────────────────────── + +export async function fetchMembersFromDb( + clientId: string | null | undefined +): Promise { + if (clientId === undefined) return [] + + if (clientId === null) { + const { data, error } = await supabase + .from('members') + .select('id, name, role, avatar, avatar_url, email, auth_user_id, access_role') + .order('name') + if (error) throw new Error(error.message) + return data ?? [] + } + + const ucResult = await supabase + .from('user_clients') + .select('user_id') + .eq('client_id', clientId) + + if (ucResult.error) throw new Error(ucResult.error.message) + + const allIds = (ucResult.data ?? []).map(uc => uc.user_id) + if (allIds.length === 0) return [] + + const { data, error } = await supabase + .from('members') + .select('id, name, role, avatar, avatar_url, email, auth_user_id, access_role') + .in('id', allIds) + .order('name') + + if (error) throw new Error(error.message) + return data ?? [] +} diff --git a/src/main.tsx b/src/main.tsx index da4b481..4bead42 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,13 +1,26 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import './index.css' import App from './App.tsx' import { AuthProvider } from '@/contexts/AuthContext' +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, + retry: 2, + }, + }, +}) + createRoot(document.getElementById('root')!).render( - - - + + + + + , ) diff --git a/src/store/useMemberStore.ts b/src/store/useMemberStore.ts index e933694..45f912d 100644 --- a/src/store/useMemberStore.ts +++ b/src/store/useMemberStore.ts @@ -1,92 +1,16 @@ +// Stub mantido para compatibilidade de imports. +// O fetch de membros foi migrado para useMembersQuery (TanStack Query). +// Esta store pode ser removida quando todos os call sites forem atualizados. import { create } from 'zustand' import { devtools } from 'zustand/middleware' -import { supabase } from '@/lib/supabase' -import type { Member } from '@/hooks/useSupabase' -async function fetchMembersFromDb(clientId: string | null | undefined): Promise { - if (clientId === undefined) return [] - - if (clientId === null) { - const { data, error } = await supabase - .from('members') - .select('id, name, role, avatar, avatar_url, email, auth_user_id, access_role') - .order('name') - if (error) throw new Error(error.message) - return data ?? [] - } - - // Busca todos os membros vinculados ao cliente via user_clients - const ucResult = await supabase - .from('user_clients') - .select('user_id') - .eq('client_id', clientId) - - if (ucResult.error) throw new Error(ucResult.error.message) - - const allIds = (ucResult.data ?? []).map(uc => uc.user_id) - - if (allIds.length === 0) return [] - - const { data, error } = await supabase - .from('members') - .select('id, name, role, avatar, avatar_url, email, auth_user_id, access_role') - .in('id', allIds) - .order('name') - - if (error) throw new Error(error.message) - return data ?? [] -} - -interface MemberState { - members: Member[] - loading: boolean - error: string | null - cachedClientId: string | null | undefined +interface MemberStoreState { + _unused: null } -interface MemberActions { - fetchMembers: (clientId: string | null | undefined) => Promise - invalidate: () => void -} - -type MemberStore = MemberState & MemberActions - -export const useMemberStore = create()( +export const useMemberStore = create()( devtools( - (set, get) => ({ - members: [], - loading: false, - error: null, - cachedClientId: undefined, - - fetchMembers: async (clientId) => { - if (clientId === undefined) return - - const state = get() - if (state.loading) return - - const shouldFetch = - clientId !== state.cachedClientId || - state.members.length === 0 - - if (!shouldFetch) return - - set({ loading: true, error: null }) - try { - const members = await fetchMembersFromDb(clientId) - set({ members, cachedClientId: clientId, loading: false }) - } catch (err) { - set({ - error: err instanceof Error ? err.message : 'Erro ao carregar membros', - loading: false, - }) - } - }, - - invalidate: () => { - set({ members: [], cachedClientId: undefined, error: null, loading: false }) - }, - }), + () => ({ _unused: null }), { name: 'app/members', enabled: true } ) ) diff --git a/src/store/useTaskStore.ts b/src/store/useTaskStore.ts index f1fecab..1f0d504 100644 --- a/src/store/useTaskStore.ts +++ b/src/store/useTaskStore.ts @@ -1,120 +1,25 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' -import { supabase } from '@/lib/supabase' -import type { Task, Step, StepType } from '@/lib/steps' -import type { DbTaskRow } from '@/types/db' - -function dbRowToTask(row: DbTaskRow): Task { - const steps: Step[] = (row.task_steps ?? []) - .sort((a, b) => a.step_order - b.step_order) - .map(s => ({ - id: s.id, - type: s.type as StepType, - order: s.step_order, - active: s.active, - start: s.start_date ?? '', - end: s.end_date ?? '', - assignees: (s.step_assignees ?? []).map((a: { member_id: string }) => a.member_id), - })) - - return { - id: row.id, - title: row.title, - clickupLink: row.clickup_link ?? undefined, - clientId: row.client_id ?? undefined, - status: { - blocked: row.blocked, - blockedAt: row.blocked_at ?? undefined, - }, - createdAt: row.created_at, - concludedAt: row.concluded_at ?? undefined, - concludedBy: row.concluded_by ?? undefined, - steps, - } -} - -async function fetchTasksFromDb( - clientId: string | null, - isAdmin: boolean -): Promise { - if (clientId === null) { - if (!isAdmin) return [] - const { data, error } = await supabase - .from('tasks') - .select(` - id, title, clickup_link, blocked, blocked_at, created_at, client_id, concluded_at, concluded_by, - task_steps ( - id, type, step_order, active, start_date, end_date, - step_assignees ( member_id ) - ) - `) - .order('created_at', { ascending: false }) - if (error) throw new Error(error.message) - return (data ?? []).map(dbRowToTask) - } - - const { data, error } = await supabase - .from('tasks') - .select(` - id, title, clickup_link, blocked, blocked_at, created_at, client_id, concluded_at, concluded_by, - task_steps ( - id, type, step_order, active, start_date, end_date, - step_assignees ( member_id ) - ) - `) - .eq('client_id', clientId) - .order('created_at', { ascending: false }) - - if (error) throw new Error(error.message) - return (data ?? []).map(dbRowToTask) -} +import type { Task } from '@/lib/steps' interface TaskState { - tasks: Task[] - loading: boolean - error: string | null - cacheKey: string | undefined // `${clientId ?? 'all'}:${isAdmin}` + optimisticTasks: Task[] | null } interface TaskActions { - fetchTasks: (clientId: string | null | undefined, isAdmin: boolean) => Promise - invalidate: () => void + applyOptimisticUpdate: (tasks: Task[]) => void + clearOptimistic: () => void } type TaskStore = TaskState & TaskActions export const useTaskStore = create()( devtools( - (set, get) => ({ - tasks: [], - loading: false, - error: null, - cacheKey: undefined, - - fetchTasks: async (clientId, isAdmin) => { - if (clientId === undefined) return - - const state = get() - if (state.loading) return - - const key = `${clientId ?? 'all'}:${isAdmin}` - if (state.cacheKey === key && state.tasks.length > 0) return - - set({ loading: true, error: null }) - try { - const tasks = await fetchTasksFromDb(clientId, isAdmin) - set({ tasks, cacheKey: key, loading: false }) - } catch (err) { - set({ - error: err instanceof Error ? err.message : 'Erro ao carregar tarefas', - loading: false, - }) - } - }, + (set) => ({ + optimisticTasks: null, - invalidate: () => { - set({ tasks: [], cacheKey: undefined, error: null, loading: false }) - }, + applyOptimisticUpdate: (tasks) => set({ optimisticTasks: tasks }), + clearOptimistic: () => set({ optimisticTasks: null }), }), { name: 'app/tasks', enabled: true } ) diff --git a/src/views/MembersView/MembersView.tsx b/src/views/MembersView/MembersView.tsx index d3b937a..f28bfe9 100644 --- a/src/views/MembersView/MembersView.tsx +++ b/src/views/MembersView/MembersView.tsx @@ -1,6 +1,7 @@ -import React, { useEffect } from 'react'; -import { useTaskStore } from '@/store/useTaskStore'; -import { useMemberStore } from '@/store/useMemberStore'; +import React from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useTasksQuery } from '@/hooks/useTasksQuery'; +import { useMembersQuery } from '@/hooks/useMembersQuery'; import { useUIStore } from '@/store/useUIStore'; import { useAuthContext } from '@/contexts/AuthContext'; import { useClients } from '@/hooks/useClients'; @@ -32,38 +33,20 @@ function todayStr() { const MembersView: React.FC = () => { const { isAdmin } = useAuthContext(); const { effectiveClientId } = useClients(); + const queryClient = useQueryClient(); const setView = useUIStore((s) => s.setView); const setDashboardRedirect = useUIStore((s) => s.setDashboardRedirect); - const { - tasks, - loading: tasksLoading, - error: tasksError, - fetchTasks, - invalidate: invalidateTasks, - } = useTaskStore(); - const { - members, - loading: membersLoading, - error: membersError, - fetchMembers, - invalidate: invalidateMembers, - } = useMemberStore(); + const { data: tasks = [], isLoading: tasksLoading, error: tasksErr } = useTasksQuery(effectiveClientId, isAdmin); + const { data: members = [], isLoading: membersLoading, error: membersErr } = useMembersQuery(effectiveClientId); const today = todayStr(); - useEffect(() => { - fetchTasks(effectiveClientId, isAdmin); - fetchMembers(effectiveClientId); - }, [effectiveClientId, isAdmin, fetchTasks, fetchMembers]); - const hasData = tasks.length > 0 || members.length > 0; const isLoading = tasksLoading || membersLoading; - const errorMessage = tasksError || membersError; + const errorMessage = tasksErr?.message ?? membersErr?.message ?? null; const handleRetry = () => { - invalidateTasks(); - invalidateMembers(); - fetchTasks(effectiveClientId, isAdmin); - fetchMembers(effectiveClientId); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['members'] }); }; if (errorMessage && !hasData) { diff --git a/src/views/admin/hooks/__tests__/useAdminData.test.ts b/src/views/admin/hooks/__tests__/useAdminData.test.ts index 26ee746..b75cdad 100644 --- a/src/views/admin/hooks/__tests__/useAdminData.test.ts +++ b/src/views/admin/hooks/__tests__/useAdminData.test.ts @@ -14,15 +14,16 @@ vi.mock('@/lib/supabase', () => ({ }, })) -// Mock das stores para evitar erros de contexto -vi.mock('@/store/useTaskStore', () => ({ - useTaskStore: (selector: (s: { invalidate: () => void; fetchTasks: () => Promise }) => unknown) => - selector({ invalidate: vi.fn(), fetchTasks: vi.fn() }), -})) -vi.mock('@/store/useMemberStore', () => ({ - useMemberStore: (selector: (s: { invalidate: () => void; fetchMembers: () => Promise }) => unknown) => - selector({ invalidate: vi.fn(), fetchMembers: vi.fn() }), -})) +// Mock do queryClient (TanStack Query) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQueryClient: () => ({ + invalidateQueries: vi.fn(), + }), + } +}) vi.mock('@/store/useClientStore', () => ({ useClientStore: (selector: (s: { selectedClientId: string | null }) => unknown) => selector({ selectedClientId: null }), diff --git a/src/views/admin/hooks/useAdminData.ts b/src/views/admin/hooks/useAdminData.ts index cbb1915..065a554 100644 --- a/src/views/admin/hooks/useAdminData.ts +++ b/src/views/admin/hooks/useAdminData.ts @@ -1,9 +1,7 @@ import { useEffect, useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { supabaseAdmin } from '@/lib/supabase' import { toSafeUiErrorMessage } from '@/lib/errorSanitizer' -import { useTaskStore } from '@/store/useTaskStore' -import { useMemberStore } from '@/store/useMemberStore' -import { useClientStore } from '@/store/useClientStore' import { useAdminStore } from '@/store/useAdminStore' import type { PendingAuthUser, AuditFilters } from '@/store/useAdminStore' @@ -16,11 +14,7 @@ interface UseAdminDataOptions { export function useAdminData(options: UseAdminDataOptions = {}) { const { actorUserId } = options - const selectedClientId = useClientStore((state) => state.selectedClientId) - const invalidateTasks = useTaskStore((state) => state.invalidate) - const fetchTasks = useTaskStore((state) => state.fetchTasks) - const invalidateMembers = useMemberStore((state) => state.invalidate) - const fetchMembers = useMemberStore((state) => state.fetchMembers) + const queryClient = useQueryClient() const { clients, users, auditLogs, loading, loadingInitial, error, @@ -36,14 +30,10 @@ export function useAdminData(options: UseAdminDataOptions = {}) { } }, [initialized, loadingInitial, refreshAll]) - const reloadAppStores = useCallback(async () => { - invalidateTasks() - invalidateMembers() - await Promise.all([ - fetchTasks(selectedClientId, true), - fetchMembers(selectedClientId), - ]) - }, [fetchMembers, fetchTasks, invalidateMembers, invalidateTasks, selectedClientId]) + const reloadAppStores = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['tasks'] }) + queryClient.invalidateQueries({ queryKey: ['members'] }) + }, [queryClient]) // ── CRUD clients ──────────────────────────────────────────────────────────── diff --git a/src/views/dashboard/DashboardView.tsx b/src/views/dashboard/DashboardView.tsx index 1004b3a..9205a46 100644 --- a/src/views/dashboard/DashboardView.tsx +++ b/src/views/dashboard/DashboardView.tsx @@ -1,7 +1,8 @@ import { useEffect } from 'react'; -import { useTaskStore } from '@/store/useTaskStore'; -import { useMemberStore } from '@/store/useMemberStore'; import { useUIStore } from '@/store/useUIStore'; +import { useTasksQuery } from '@/hooks/useTasksQuery'; +import { useMembersQuery } from '@/hooks/useMembersQuery'; +import { useQueryClient } from '@tanstack/react-query'; import { useAuthContext } from '@/contexts/AuthContext'; import { useClients } from '@/hooks/useClients'; import { CalendarView } from '@/views/calendar'; @@ -37,27 +38,13 @@ const VIEW_TITLES: Record = { const DashboardView: React.FC = ({ subview, onEdit, onDelete, onUpdateTask, onOpenNew, onExport, holidays }) => { const { isAdmin } = useAuthContext(); const { effectiveClientId } = useClients(); + const queryClient = useQueryClient(); const dashboardRedirect = useUIStore((s) => s.dashboardRedirect); const clearDashboardRedirect = useUIStore((s) => s.clearDashboardRedirect); - const { - tasks, - loading: tasksLoading, - error: tasksError, - fetchTasks, - invalidate: invalidateTasks, - } = useTaskStore(); - const { - members, - loading: membersLoading, - error: membersError, - fetchMembers, - invalidate: invalidateMembers, - } = useMemberStore(); - - useEffect(() => { - fetchTasks(effectiveClientId, isAdmin); - fetchMembers(effectiveClientId); - }, [effectiveClientId, isAdmin, fetchTasks, fetchMembers]); + const { data: tasks = [], isLoading: tasksLoading, error: tasksErr } = useTasksQuery(effectiveClientId, isAdmin); + const { data: members = [], isLoading: membersLoading, error: membersErr } = useMembersQuery(effectiveClientId); + const tasksError = tasksErr?.message ?? null; + const membersError = membersErr?.message ?? null; const { filterAssignee, setFilterAssignee, @@ -80,10 +67,8 @@ const DashboardView: React.FC = ({ subview, onEdit, onDelete const errorMessage = tasksError || membersError; const handleRetry = () => { - invalidateTasks(); - invalidateMembers(); - fetchTasks(effectiveClientId, isAdmin); - fetchMembers(effectiveClientId); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['members'] }); }; if (errorMessage && !hasData) { diff --git a/src/views/list/ListView.tsx b/src/views/list/ListView.tsx index 2bf3dae..6e61bfe 100644 --- a/src/views/list/ListView.tsx +++ b/src/views/list/ListView.tsx @@ -1,6 +1,7 @@ import { useAuthContext } from '@/contexts/AuthContext'; -import { useTaskStore } from '@/store/useTaskStore'; -import { useMemberStore } from '@/store/useMemberStore'; +import { useQueryClient } from '@tanstack/react-query'; +import { useTasksQuery } from '@/hooks/useTasksQuery'; +import { useMembersQuery } from '@/hooks/useMembersQuery'; import { useClients } from '@/hooks/useClients'; import { supabase } from '@/lib/supabase'; import { toast } from 'sonner'; @@ -19,9 +20,10 @@ interface ListViewProps { export function ListView({ onEdit, onDelete, onOpenNew, onExport }: ListViewProps) { const { member } = useAuthContext(); - const { tasks, fetchTasks, invalidate } = useTaskStore(); - const { members } = useMemberStore(); + const queryClient = useQueryClient(); const { effectiveClientId, isAdmin } = useClients(); + const { data: tasks = [] } = useTasksQuery(effectiveClientId, isAdmin); + const { data: members = [] } = useMembersQuery(effectiveClientId); const { filterAssignee, setFilterAssignee, @@ -31,12 +33,12 @@ export function ListView({ onEdit, onDelete, onOpenNew, onExport }: ListViewProp hasActiveFilters, clearFilters, filteredTasks, - } = useTaskFilters(tasks ?? [], false, ''); + } = useTaskFilters(tasks, false, ''); const { filterPeriodMonths, setFilterPeriodMonths, groupedTasks, - } = useListFilters(filteredTasks, members ?? []); + } = useListFilters(filteredTasks, members); const today = new Date().toISOString().slice(0, 7); @@ -50,14 +52,9 @@ export function ListView({ onEdit, onDelete, onOpenNew, onExport }: ListViewProp }) .eq('id', task.id); - if (error) { - toast.error('Erro ao concluir demanda'); - return; - } - + if (error) { toast.error('Erro ao concluir demanda'); return; } toast.success(`"${task.title}" concluída`); - invalidate(); - await fetchTasks(effectiveClientId, isAdmin); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); }; const handleToggleBlock = async (task: Task) => { @@ -71,14 +68,9 @@ export function ListView({ onEdit, onDelete, onOpenNew, onExport }: ListViewProp }) .eq('id', task.id); - if (error) { - toast.error('Erro ao alterar bloqueio'); - return; - } - + if (error) { toast.error('Erro ao alterar bloqueio'); return; } toast.success(newBlocked ? `"${task.title}" bloqueada` : `"${task.title}" desbloqueada`); - invalidate(); - await fetchTasks(effectiveClientId, isAdmin); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); }; const togglePeriod = (months: number) => { @@ -89,7 +81,7 @@ export function ListView({ onEdit, onDelete, onOpenNew, onExport }: ListViewProp return (
{groupedTasks.size === 0 ? ( @@ -122,7 +114,7 @@ export function ListView({ onEdit, onDelete, onOpenNew, onExport }: ListViewProp monthKey={monthKey} items={items} isCurrentMonth={monthKey === today} - members={members ?? []} + members={members} onEdit={onEdit} onDelete={onDelete} onConclude={handleConclude} diff --git a/src/views/reports/useReportsData.ts b/src/views/reports/useReportsData.ts index 9d4e315..fb45d65 100644 --- a/src/views/reports/useReportsData.ts +++ b/src/views/reports/useReportsData.ts @@ -1,6 +1,7 @@ -import { useMemo, useEffect, useState } from 'react'; -import { useTaskStore } from '@/store/useTaskStore'; -import { useMemberStore } from '@/store/useMemberStore'; +import { useMemo, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useTasksQuery } from '@/hooks/useTasksQuery'; +import { useMembersQuery } from '@/hooks/useMembersQuery'; import { useAuthContext } from '@/contexts/AuthContext'; import { useClients } from '@/hooks/useClients'; import { todayStr, enrichTask, computeMemberLoad, calDaysBetween, calculatePercentile } from './utils'; @@ -11,7 +12,7 @@ export type TimeFilter = 'all' | '30d' | '90d' | '180d' | '365d'; export interface ReportsData { enriched: ReturnType[]; filteredEnriched: ReturnType[]; - members: ReturnType['members']; + members: import('@/hooks/useSupabase').Member[]; total: number; active: number; bloqueadas: number; @@ -74,30 +75,14 @@ export interface ReportsData { export function useReportsData(): ReportsData { const { isAdmin } = useAuthContext(); const { effectiveClientId } = useClients(); - const { - tasks, - loading: tasksLoading, - error: tasksError, - fetchTasks, - invalidate: invalidateTasks, - } = useTaskStore(); - const { - members, - loading: membersLoading, - error: membersError, - fetchMembers, - invalidate: invalidateMembers, - } = useMemberStore(); + const queryClient = useQueryClient(); + const { data: tasks = [], isLoading: tasksLoading, error: tasksErr } = useTasksQuery(effectiveClientId, isAdmin); + const { data: members = [], isLoading: membersLoading, error: membersErr } = useMembersQuery(effectiveClientId); const [timeFilter, setTimeFilter] = useState('all'); const [memberFilter, setMemberFilter] = useState('all'); const today = todayStr(); - useEffect(() => { - fetchTasks(effectiveClientId, isAdmin); - fetchMembers(effectiveClientId); - }, [effectiveClientId, isAdmin, fetchTasks, fetchMembers]); - const enriched = useMemo( () => tasks.map(task => enrichTask(task, today, members)), [tasks, today, members] @@ -335,11 +320,11 @@ export function useReportsData(): ReportsData { }, [members, filteredEnriched, flowMetrics.p85ByStep]); const isLoading = tasksLoading || membersLoading; - const errorMessage = tasksError || membersError; + const errorMessage = tasksErr?.message ?? membersErr?.message ?? null; const invalidate = () => { - invalidateTasks(); - invalidateMembers(); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['members'] }); }; return { diff --git a/src/views/tasks/TasksView.tsx b/src/views/tasks/TasksView.tsx index 2970b0a..4bd9694 100644 --- a/src/views/tasks/TasksView.tsx +++ b/src/views/tasks/TasksView.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, useEffect } from 'react'; +import { useMemo, useState } from 'react'; import { Skeleton } from 'boneyard-js/react'; const TASKS_BONES = { @@ -35,8 +35,9 @@ const TASKS_BONES = { ], }; import { useAuthContext } from '@/contexts/AuthContext'; -import { useTaskStore } from '@/store/useTaskStore'; -import { useMemberStore } from '@/store/useMemberStore'; +import { useQueryClient } from '@tanstack/react-query'; +import { useTasksQuery } from '@/hooks/useTasksQuery'; +import { useMembersQuery } from '@/hooks/useMembersQuery'; import { useClients } from '@/hooks/useClients'; import { supabase } from '@/lib/supabase'; import { toast } from 'sonner'; @@ -61,17 +62,13 @@ interface TasksViewProps { export default function TasksView({ onEdit, onOpenNew }: TasksViewProps) { const { member } = useAuthContext(); - const { tasks, fetchTasks, invalidate, loading } = useTaskStore(); - const { members, fetchMembers } = useMemberStore(); + const queryClient = useQueryClient(); const { effectiveClientId, isAdmin } = useClients(); + const { data: tasks = [], isLoading: loading } = useTasksQuery(effectiveClientId, isAdmin); + const { data: members = [] } = useMembersQuery(effectiveClientId); const [filters, setFilters] = useState(EMPTY_FILTERS); - useEffect(() => { - fetchTasks(effectiveClientId, isAdmin); - fetchMembers(effectiveClientId); - }, [effectiveClientId, isAdmin, fetchTasks, fetchMembers]); - const handleToggleBlock = async (task: Task) => { const newBlocked = !task.status.blocked; const now = new Date().toISOString().split('T')[0]; @@ -82,8 +79,7 @@ export default function TasksView({ onEdit, onOpenNew }: TasksViewProps) { if (error) { toast.error('Erro ao alterar bloqueio'); return; } toast.success(newBlocked ? `"${task.title}" bloqueada` : `"${task.title}" desbloqueada`); - invalidate(); - await fetchTasks(effectiveClientId, isAdmin); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); }; const handleConclude = async (task: Task) => { @@ -95,8 +91,7 @@ export default function TasksView({ onEdit, onOpenNew }: TasksViewProps) { if (error) { toast.error('Erro ao concluir demanda'); return; } toast.success(`"${task.title}" concluída`); - invalidate(); - await fetchTasks(effectiveClientId, isAdmin); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); }; const filteredTasks = useMemo(() => { From 205c6b475cc2d05ae98c900db0705a680de92a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Tue, 21 Apr 2026 01:07:31 -0300 Subject: [PATCH 02/21] feat: :card_file_box: Adicionado tipagem do banco de dados --- .claude/settings.json | 17 ++ .claude/settings.local.json | 6 +- .gitignore | 2 + src/lib/supabase.ts | 5 +- src/types/supabase.ts | 551 ++++++++++++++++++++++++++++++++++++ 5 files changed, 578 insertions(+), 3 deletions(-) create mode 100644 .claude/settings.json create mode 100644 src/types/supabase.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..64cddf0 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "shell": "powershell", + "command": "$raw = $Input | Out-String; $obj = $raw | ConvertFrom-Json; $path = $obj.tool_input.file_path.Replace('\\', '/'); if ($path -match 'supabase/migrations/.+[.]sql') { Write-Output '{\"systemMessage\": \"⚠️ Migration detectada! Regenere os tipos do Supabase:\\nnpx supabase gen types typescript --project-id SEU_PROJECT_ID > src/types/supabase.ts\"}' }", + "statusMessage": "Verificando se é migration..." + } + ] + } + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e50744f..a66406c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -41,7 +41,11 @@ "Bash(npm list *)", "Bash(xargs wc:*)", "Bash(wc -l /c/Users/dinam/Documents/Projetos/run-way/src/lib/*.ts /c/Users/dinam/Documents/Projetos/run-way/src/store/*.ts /c/Users/dinam/Documents/Projetos/run-way/src/hooks/*.ts)", - "Bash(node -e \"const p = require\\('./node_modules/@tanstack/react-query/package.json'\\); console.log\\(p.version\\)\")" + "Bash(node -e \"const p = require\\('./node_modules/@tanstack/react-query/package.json'\\); console.log\\(p.version\\)\")", + "Bash(grep -E '^supabase/migrations/.*\\\\.sql$')", + "Bash(where jq *)", + "Bash(powershell -NonInteractive -Command ' *)", + "Bash(powershell -NonInteractive -Command \"Get-Content '.claude/settings.json' | ConvertFrom-Json | ConvertTo-Json -Depth 10\")" ] }, "hooks": { diff --git a/.gitignore b/.gitignore index 1216f12..1d91291 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ lerna-debug.log* node_modules dist +.temp +supabase/.temp dist-ssr *.local diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 3bcd2db..d06bb91 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -1,12 +1,13 @@ import { createClient } from '@supabase/supabase-js' +import type { Database } from '@/types/supabase' const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string const serviceRoleKey = import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY as string | undefined -export const supabase = createClient(supabaseUrl, supabaseAnonKey) +export const supabase = createClient(supabaseUrl, supabaseAnonKey) /** Usar APENAS em contexto admin — bypassa RLS */ export const supabaseAdmin = serviceRoleKey - ? createClient(supabaseUrl, serviceRoleKey) + ? createClient(supabaseUrl, serviceRoleKey) : null diff --git a/src/types/supabase.ts b/src/types/supabase.ts new file mode 100644 index 0000000..171ae50 --- /dev/null +++ b/src/types/supabase.ts @@ -0,0 +1,551 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "14.4" + } + public: { + Tables: { + audit_logs: { + Row: { + action: string + client_id: string | null + created_at: string | null + entity: string + entity_id: string + entity_name: string | null + field: string | null + from_value: string | null + id: string + to_value: string | null + user_id: string | null + } + Insert: { + action: string + client_id?: string | null + created_at?: string | null + entity: string + entity_id: string + entity_name?: string | null + field?: string | null + from_value?: string | null + id?: string + to_value?: string | null + user_id?: string | null + } + Update: { + action?: string + client_id?: string | null + created_at?: string | null + entity?: string + entity_id?: string + entity_name?: string | null + field?: string | null + from_value?: string | null + id?: string + to_value?: string | null + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "audit_logs_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] + }, + { + foreignKeyName: "audit_logs_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "members" + referencedColumns: ["id"] + }, + ] + } + clients: { + Row: { + created_at: string | null + id: string + name: string + slug: string + } + Insert: { + created_at?: string | null + id?: string + name: string + slug: string + } + Update: { + created_at?: string | null + id?: string + name?: string + slug?: string + } + Relationships: [] + } + members: { + Row: { + access_role: string | null + auth_user_id: string | null + avatar: string + avatar_url: string | null + created_at: string + deactivated_at: string | null + email: string | null + id: string + is_active: boolean + name: string + role: string + } + Insert: { + access_role?: string | null + auth_user_id?: string | null + avatar: string + avatar_url?: string | null + created_at?: string + deactivated_at?: string | null + email?: string | null + id?: string + is_active?: boolean + name: string + role: string + } + Update: { + access_role?: string | null + auth_user_id?: string | null + avatar?: string + avatar_url?: string | null + created_at?: string + deactivated_at?: string | null + email?: string | null + id?: string + is_active?: boolean + name?: string + role?: string + } + Relationships: [] + } + notifications: { + Row: { + client_id: string | null + created_at: string + entity: string | null + entity_id: string | null + id: string + message: string + metadata: Json | null + read: boolean + title: string + type: string + user_id: string | null + } + Insert: { + client_id?: string | null + created_at?: string + entity?: string | null + entity_id?: string | null + id?: string + message: string + metadata?: Json | null + read?: boolean + title: string + type: string + user_id?: string | null + } + Update: { + client_id?: string | null + created_at?: string + entity?: string | null + entity_id?: string | null + id?: string + message?: string + metadata?: Json | null + read?: boolean + title?: string + type?: string + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "notifications_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] + }, + { + foreignKeyName: "notifications_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "members" + referencedColumns: ["id"] + }, + ] + } + step_assignees: { + Row: { + member_id: string + step_id: string + } + Insert: { + member_id: string + step_id: string + } + Update: { + member_id?: string + step_id?: string + } + Relationships: [ + { + foreignKeyName: "step_assignees_member_id_fkey" + columns: ["member_id"] + isOneToOne: false + referencedRelation: "members" + referencedColumns: ["id"] + }, + { + foreignKeyName: "step_assignees_step_id_fkey" + columns: ["step_id"] + isOneToOne: false + referencedRelation: "task_steps" + referencedColumns: ["id"] + }, + ] + } + task_steps: { + Row: { + active: boolean + end_date: string | null + id: string + start_date: string | null + step_order: number + task_id: string + type: string + } + Insert: { + active?: boolean + end_date?: string | null + id?: string + start_date?: string | null + step_order: number + task_id: string + type: string + } + Update: { + active?: boolean + end_date?: string | null + id?: string + start_date?: string | null + step_order?: number + task_id?: string + type?: string + } + Relationships: [ + { + foreignKeyName: "task_steps_task_id_fkey" + columns: ["task_id"] + isOneToOne: false + referencedRelation: "tasks" + referencedColumns: ["id"] + }, + ] + } + tasks: { + Row: { + blocked: boolean + blocked_at: string | null + clickup_link: string | null + client_id: string | null + concluded_at: string | null + concluded_by: string | null + created_at: string + id: string + title: string + } + Insert: { + blocked?: boolean + blocked_at?: string | null + clickup_link?: string | null + client_id?: string | null + concluded_at?: string | null + concluded_by?: string | null + created_at?: string + id?: string + title: string + } + Update: { + blocked?: boolean + blocked_at?: string | null + clickup_link?: string | null + client_id?: string | null + concluded_at?: string | null + concluded_by?: string | null + created_at?: string + id?: string + title?: string + } + Relationships: [ + { + foreignKeyName: "tasks_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] + }, + ] + } + user_clients: { + Row: { + client_id: string + user_id: string + } + Insert: { + client_id: string + user_id: string + } + Update: { + client_id?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "user_clients_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] + }, + { + foreignKeyName: "user_clients_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "members" + referencedColumns: ["id"] + }, + ] + } + user_preferences: { + Row: { + client_order: string[] + created_at: string + default_view: string + id: string + language: string + notification_member_overloaded: boolean + notification_step_overdue: boolean + notification_task_stalled: boolean + notifications_enabled: boolean + overload_threshold: number + stalled_days_threshold: number + theme: string + updated_at: string + user_id: string + } + Insert: { + client_order?: string[] + created_at?: string + default_view?: string + id?: string + language?: string + notification_member_overloaded?: boolean + notification_step_overdue?: boolean + notification_task_stalled?: boolean + notifications_enabled?: boolean + overload_threshold?: number + stalled_days_threshold?: number + theme?: string + updated_at?: string + user_id: string + } + Update: { + client_order?: string[] + created_at?: string + default_view?: string + id?: string + language?: string + notification_member_overloaded?: boolean + notification_step_overdue?: boolean + notification_task_stalled?: boolean + notifications_enabled?: boolean + overload_threshold?: number + stalled_days_threshold?: number + theme?: string + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "user_preferences_user_id_fkey" + columns: ["user_id"] + isOneToOne: true + referencedRelation: "members" + referencedColumns: ["id"] + }, + ] + } + } + Views: { + [_ in never]: never + } + Functions: { + get_current_user_client_ids: { Args: never; Returns: string[] } + has_recent_audit_activity: { + Args: { since_hours?: number } + Returns: boolean + } + notify_overdue_steps: { Args: never; Returns: undefined } + notify_overloaded_members: { Args: never; Returns: undefined } + notify_stalled_tasks: { Args: never; Returns: undefined } + run_notification_checks: { Args: never; Returns: undefined } + send_notification: { + Args: { + p_client_id: string + p_message: string + p_metadata?: Json + p_title: string + p_type?: string + p_user_id: string + } + Returns: undefined + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: {}, + }, +} as const From 20a6ba0532d670873801c12abecfb9cec8a403e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Tue, 21 Apr 2026 01:13:29 -0300 Subject: [PATCH 03/21] refactor: :recycle: reorganizado props das views dos layouts --- docs/architecture.md | 23 +++- docs/tech-audit.md | 26 +--- src/components/AppHeader.tsx | 53 ++++---- src/components/AppLayout.tsx | 217 +++++++++++++++------------------ src/components/AppRouter.tsx | 51 +++----- src/components/AppSidebar.tsx | 66 ++++------ src/contexts/LayoutContext.tsx | 72 +++++++++++ 7 files changed, 253 insertions(+), 255 deletions(-) create mode 100644 src/contexts/LayoutContext.tsx diff --git a/docs/architecture.md b/docs/architecture.md index 62b1c72..9dbc5ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,7 +45,8 @@ src/ │ ├── useTaskActions.ts # Estado e handlers de create/update/delete de tasks │ └── useClientTransition.ts # Fluxo animado de troca de cliente (overlay + TanStack Query invalidate) ├── contexts/ -│ └── AuthContext.tsx # Sessão, member, clients, isAdmin, refreshProfile +│ ├── AuthContext.tsx # Sessão, member, clients, isAdmin, refreshProfile +│ └── LayoutContext.tsx # Contexto do shell de layout (header, sidebar, router) — elimina prop drilling em AppLayout ├── lib/ │ ├── supabase.ts # Cliente Supabase │ ├── queries.ts # fetchTasksFromDb, fetchMembersFromDb, queryKeys @@ -83,7 +84,7 @@ App.tsx (gates de auth + composição) ├── view="tools-import/export/integrations" → ToolsView com subview (em breve) ├── view="profile" → ProfileView — perfil + preferências └── TaskModal → criar/editar (useFormState → cascata de fases) - ├── AppLayout → shell do layout (AppHeader + AppSidebar + main/AppRouter) + ├── AppLayout → shell do layout; fornece LayoutContext (AppHeader + AppSidebar + main/AppRouter sem prop drilling) └── AppModals → TaskModal + ConfirmModal + ClientTransitionOverlay ``` @@ -286,9 +287,27 @@ A segunda policy é necessária para que `fetchMembersFromDb` consiga buscar tod - **Auth:** Supabase Auth — sessão gerida pelo SDK - **Cliente selecionado:** localStorage via `zustand/persist` (`client-store`) +## LayoutContext (`src/contexts/LayoutContext.tsx`) + +Agrupa as props do shell de layout em três namespaces para eliminar prop drilling: + +```ts +interface LayoutCtx { + view: ViewType + header: HeaderCtx // darkMode, notifications, onToggleDark, … + sidebar: SidebarCtx // sidebarOpen, view, role, selectedClient, … + router: RouterCtx // effectiveClientId, holidays, onEditTask, … +} +``` + +- **`AppLayout`** cria o `LayoutContext.Provider` com os valores agrupados; não passa props para filhos diretos +- **`AppHeader`**, **`AppSidebar`** e **`AppRouter`** são zero-props — consomem o contexto via `useLayoutContext()` +- `useLayoutContext()` lança erro se usado fora do `AppLayout` + ## Decisões - Sem router — navegação via `useUIStore.view` (poucas views) - State manager: Zustand (UI + Client) + TanStack Query v5 (fetch/cache de tasks e members) - Query keys centralizadas em `src/lib/queries.ts`; template para novos hooks em `src/hooks/__templates__/` - `any` intencional em dados do DB sem schema fixo em runtime +- `AppLayout` usa Context API em vez de prop drilling para isolar concerns do shell — `AppHeader`, `AppSidebar` e `AppRouter` não recebem props diff --git a/docs/tech-audit.md b/docs/tech-audit.md index dd9b69d..4f7a880 100644 --- a/docs/tech-audit.md +++ b/docs/tech-audit.md @@ -94,29 +94,11 @@ export const supabase = createClient(url, key) --- -### Problema: Props gigantes em AppLayout +### ~~Problema: Props gigantes em AppLayout~~ ✅ Resolvido -`AppLayoutProps` tem 28 propriedades. Difícil rastrear origem, refatoração arriscada. +`AppLayoutProps` tinha 28 propriedades. **Corrigido:** criado `LayoutContext` (`src/contexts/LayoutContext.tsx`) com três namespaces (`header`, `sidebar`, `router`). `AppLayout` fornece o Provider; `AppHeader`, `AppSidebar` e `AppRouter` são agora zero-props e consomem via `useLayoutContext()`. -**Solução — agrupar em objetos e usar Context para evitar prop drilling:** - -```typescript -interface HeaderCtx { darkMode: boolean; onToggleDark: () => void; notifications: Notification[]; ... } -interface SidebarCtx { sidebarOpen: boolean; onToggleSidebar: () => void; view: ViewType; ... } - -const LayoutCtx = createContext<{ header: HeaderCtx; sidebar: SidebarCtx } | null>(null) - -function AppLayout(props: AppLayoutProps) { - return ( - - - - - ) -} -``` - -**Prioridade:** Alta +**Prioridade:** ~~Alta~~ Concluído --- @@ -461,7 +443,7 @@ Não há registro de por que Zustand, por que roteamento manual, por que sem Nex | **Alta** | TanStack Query (caching + invalidação seletiva) | 16h | | **Alta** | Code splitting com React.lazy | 6h | | **Alta** | Codegen de tipos Supabase (zero `any`) | 4h | -| **Alta** | Agrupar props AppLayout em Context | 4h | +| ~~**Alta**~~ ✅ | ~~Agrupar props AppLayout em Context~~ | ~~4h~~ | | **Alta** | Testes unitários de stores e hooks críticos | 8h | | **Média** | Rate limiting em mutations | 2h | | **Média** | Cleanup robusto de Realtime subscriptions | 2h | diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index f071f34..7f66aed 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,32 +1,21 @@ -import { Menu, LayoutDashboard, Sun, Moon } from "lucide-react"; -import { NotificationBell } from "./NotificationBell"; -import type { Notification } from "@/types/notification"; +import { Menu, LayoutDashboard, Sun, Moon } from "lucide-react" +import { NotificationBell } from "./NotificationBell" +import { useLayoutContext } from "@/contexts/LayoutContext" -interface AppHeaderProps { - onToggleMobileSidebar?: () => void; - notifications?: Notification[]; - unreadCount?: number; - onMarkNotificationAsRead?: (notificationId: string) => void; - onMarkAllNotificationsAsRead?: () => void; - onNotificationClick?: (notification: Notification) => void; - reloadNotifications?: () => void; - selectedClientId?: string | null; - darkMode?: boolean; - onToggleDark?: () => void; -} +export function AppHeader() { + const { header: { + onToggleMobileSidebar, + notifications, + unreadCount, + onMarkNotificationAsRead, + onMarkAllNotificationsAsRead, + onNotificationClick, + onReloadNotifications, + selectedClientId, + darkMode, + onToggleDark, + } } = useLayoutContext() -export function AppHeader({ - onToggleMobileSidebar, - notifications = [], - unreadCount = 0, - onMarkNotificationAsRead, - onMarkAllNotificationsAsRead, - onNotificationClick, - reloadNotifications, - selectedClientId, - darkMode, - onToggleDark, -}: AppHeaderProps) { return (
@@ -59,14 +48,14 @@ export function AppHeader({ {})} - onMarkAllAsRead={onMarkAllNotificationsAsRead ?? (() => {})} - onNotificationClick={onNotificationClick ?? (() => {})} - reload={reloadNotifications} + onMarkAsRead={onMarkNotificationAsRead} + onMarkAllAsRead={onMarkAllNotificationsAsRead} + onNotificationClick={onNotificationClick} + reload={onReloadNotifications} selectedClientId={selectedClientId ?? undefined} />
- ); + ) } diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index e533026..7234fde 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,128 +1,107 @@ -import { cn } from "@/lib/utils"; -import { AppHeader } from "@/components/AppHeader"; -import { AppSidebar } from "@/components/AppSidebar"; -import { AppRouter } from "@/components/AppRouter"; -import { TooltipProvider } from "@/components/ui"; -import type { ClientOption } from "@/contexts/AuthContext"; -import type { Holiday } from "@/utils/holidayUtils"; -import type { Task } from "@/types/task"; -import type { ViewType } from "@/store/useUIStore"; -import type { AccessRole } from "@/lib/accessControl"; -import type { Notification } from "@/types/notification"; +import { cn } from "@/lib/utils" +import { AppHeader } from "@/components/AppHeader" +import { AppSidebar } from "@/components/AppSidebar" +import { AppRouter } from "@/components/AppRouter" +import { TooltipProvider } from "@/components/ui" +import { LayoutContext } from "@/contexts/LayoutContext" +import type { ClientOption } from "@/contexts/AuthContext" +import type { Holiday } from "@/utils/holidayUtils" +import type { Task } from "@/types/task" +import type { ViewType } from "@/store/useUIStore" +import type { AccessRole } from "@/lib/accessControl" +import type { Notification } from "@/types/notification" interface AppLayoutProps { - // header - darkMode: boolean; - onToggleDark: () => void; - notifications: Notification[]; - unreadCount: number; - selectedClientId: string | null; - onToggleMobileSidebar: () => void; - onMarkNotificationAsRead: (id: string) => Promise; - onMarkAllNotificationsAsRead: () => Promise; - onNotificationClick: (notification: Notification) => void; - onReloadNotifications: () => Promise; - - // sidebar - sidebarOpen: boolean; - mobileSidebarOpen: boolean; - onToggleSidebar: () => void; - onCloseMobileSidebar: () => void; - view: ViewType; - onViewChange: (view: ViewType) => void; - hasClients: boolean; - role: AccessRole | null; - userEmail?: string; - userAvatarUrl?: string | null; - onSignOut: () => void; - selectedClient: ClientOption | null; - availableClients: ClientOption[]; - onSelectClient: (clientId: string | null | undefined) => void; - isAdmin: boolean; - - // main / router - effectiveClientId: string | null | undefined; - userName: string; - holidays: Holiday[]; - onEditTask: (task: Task) => void; - onOpenNewTask: () => void; - onDeleteTask: (id: string) => void; - onUpdateTask: (task: Task) => Promise; + darkMode: boolean + onToggleDark: () => void + notifications: Notification[] + unreadCount: number + selectedClientId: string | null + onToggleMobileSidebar: () => void + onMarkNotificationAsRead: (id: string) => Promise + onMarkAllNotificationsAsRead: () => Promise + onNotificationClick: (notification: Notification) => void + onReloadNotifications: () => Promise + sidebarOpen: boolean + mobileSidebarOpen: boolean + onToggleSidebar: () => void + onCloseMobileSidebar: () => void + view: ViewType + onViewChange: (view: ViewType) => void + hasClients: boolean + role: AccessRole | null + userEmail?: string + userAvatarUrl?: string | null + onSignOut: () => void + selectedClient: ClientOption | null + availableClients: ClientOption[] + onSelectClient: (clientId: string | null | undefined) => void + isAdmin: boolean + effectiveClientId: string | null | undefined + userName: string + holidays: Holiday[] + onEditTask: (task: Task) => void + onOpenNewTask: () => void + onDeleteTask: (id: string) => void + onUpdateTask: (task: Task) => Promise } -export function AppLayout({ - darkMode, onToggleDark, - notifications, unreadCount, selectedClientId, - onToggleMobileSidebar, onMarkNotificationAsRead, onMarkAllNotificationsAsRead, - onNotificationClick, onReloadNotifications, - sidebarOpen, mobileSidebarOpen, onToggleSidebar, onCloseMobileSidebar, - view, onViewChange, hasClients, role, - userEmail, userAvatarUrl, onSignOut, - selectedClient, availableClients, onSelectClient, isAdmin, - effectiveClientId, userName, holidays, - onEditTask, onOpenNewTask, onDeleteTask, onUpdateTask, -}: AppLayoutProps) { +export function AppLayout(props: AppLayoutProps) { + const { + darkMode, onToggleDark, + notifications, unreadCount, selectedClientId, + onToggleMobileSidebar, onMarkNotificationAsRead, onMarkAllNotificationsAsRead, + onNotificationClick, onReloadNotifications, + sidebarOpen, mobileSidebarOpen, onToggleSidebar, onCloseMobileSidebar, + view, onViewChange, hasClients, role, + userEmail, userAvatarUrl, onSignOut, + selectedClient, availableClients, onSelectClient, isAdmin, + effectiveClientId, userName, holidays, + onEditTask, onOpenNewTask, onDeleteTask, onUpdateTask, + } = props + return ( -
- + +
+ -
- +
+ -
- - - -
+
+ + + +
+
-
- ); +
+ ) } diff --git a/src/components/AppRouter.tsx b/src/components/AppRouter.tsx index 6f91d24..49dc8aa 100644 --- a/src/components/AppRouter.tsx +++ b/src/components/AppRouter.tsx @@ -10,10 +10,8 @@ import { ToolsView } from "@/views/tools"; import TasksView from "@/views/tasks"; import { ProfileView } from "@/views/profile"; import type { ViewType } from "@/store/useUIStore"; -import type { Task } from "@/lib/steps"; -import type { Holiday } from "@/utils/holidayUtils"; -import type { ClientOption } from "@/contexts/AuthContext"; import type { ReportsSubview } from "@/views/reports/ReportsView"; +import { useLayoutContext } from "@/contexts/LayoutContext"; type DashboardSubview = "calendar" | "timeline" | "list"; type ToolsSubview = Extract; @@ -30,39 +28,20 @@ const REPORTS_SUBVIEW_MAP: Partial> = { "reports-alertas": "alertas", }; -interface AppRouterProps { - view: ViewType; - hasClients: boolean; - effectiveClientId: string | null | undefined; - selectedClient: ClientOption | null; - userName: string; - userEmail: string | undefined; - holidays: Holiday[]; - onViewChange: (view: ViewType) => void; - onEditTask: (task: Task) => void; - onOpenNewTask: () => void; - onDeleteTask: (id: string) => void; - onUpdateTask: (task: Task) => Promise; -} - -/** - * Maps the active `view` to the corresponding view component. - * Handles guard conditions (no client selected) before delegating to the view. - */ -export function AppRouter({ - view, - hasClients, - effectiveClientId, - selectedClient, - userName, - userEmail, - holidays, - onViewChange, - onEditTask, - onOpenNewTask, - onDeleteTask, - onUpdateTask, -}: AppRouterProps) { +export function AppRouter() { + const { view, router: { + hasClients, + effectiveClientId, + selectedClient, + userName, + userEmail, + holidays, + onViewChange, + onEditTask, + onOpenNewTask, + onDeleteTask, + onUpdateTask, + } } = useLayoutContext() const goToClients = () => onViewChange("clients"); const displayName = userName || userEmail || ""; diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index cd3c1aa..da1f503 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -30,13 +30,9 @@ import { DropdownMenuSeparator, } from "@/components/ui" -import { canAccessView, type AccessRole } from "@/lib/accessControl" +import { canAccessView } from "@/lib/accessControl" import type { ViewType } from "@/store/useUIStore" - -interface ClientOption { - id: string - name: string -} +import { useLayoutContext } from "@/contexts/LayoutContext" interface NavItem { label: string @@ -47,25 +43,6 @@ interface NavItem { isAdminOnly?: boolean } -interface AppSidebarProps { - open: boolean - onToggle: () => void - mobileOpen?: boolean - onCloseMobile?: () => void - view: ViewType - onViewChange: (view: ViewType) => void - hasClient?: boolean - role: AccessRole | null - darkMode?: boolean - onToggleDark?: () => void - userEmail?: string - userAvatarUrl?: string | null - onSignOut?: () => void - selectedClient?: ClientOption | null - availableClients?: ClientOption[] - onSelectClient?: (clientId: string | null | undefined) => void - isAdmin?: boolean -} const NAV_ITEMS: NavItem[] = [ { label: "Início", Icon: Home, view: "home" }, @@ -129,25 +106,26 @@ function getInitials(email?: string) { return name.slice(0, 2).toUpperCase() } -export function AppSidebar({ - open, - onToggle, - mobileOpen = false, - onCloseMobile, - view, - onViewChange, - hasClient = true, - role, - darkMode, - onToggleDark, - userEmail, - userAvatarUrl, - onSignOut, - selectedClient, - availableClients = [], - onSelectClient, - isAdmin, -}: AppSidebarProps) { +export function AppSidebar() { + const { sidebar: { + sidebarOpen: open, + mobileSidebarOpen: mobileOpen = false, + onToggleSidebar: onToggle, + onCloseMobileSidebar: onCloseMobile, + view, + onViewChange, + hasClients: hasClient = true, + role, + darkMode, + onToggleDark, + userEmail, + userAvatarUrl, + onSignOut, + selectedClient, + availableClients = [], + onSelectClient, + isAdmin, + } } = useLayoutContext() const [openGroups, setOpenGroups] = useState(() => { const initial: string[] = [] if (CALENDAR_VIEWS.includes(view)) initial.push("Calendário") diff --git a/src/contexts/LayoutContext.tsx b/src/contexts/LayoutContext.tsx new file mode 100644 index 0000000..c62f005 --- /dev/null +++ b/src/contexts/LayoutContext.tsx @@ -0,0 +1,72 @@ +import { createContext, useContext } from "react" +import type { ClientOption } from "@/contexts/AuthContext" +import type { Holiday } from "@/utils/holidayUtils" +import type { Task } from "@/types/task" +import type { ViewType } from "@/store/useUIStore" +import type { AccessRole } from "@/lib/accessControl" +import type { Notification } from "@/types/notification" + +interface HeaderCtx { + darkMode: boolean + onToggleDark: () => void + notifications: Notification[] + unreadCount: number + selectedClientId: string | null + onToggleMobileSidebar: () => void + onMarkNotificationAsRead: (id: string) => Promise + onMarkAllNotificationsAsRead: () => Promise + onNotificationClick: (notification: Notification) => void + onReloadNotifications: () => Promise +} + +interface SidebarCtx { + sidebarOpen: boolean + mobileSidebarOpen: boolean + onToggleSidebar: () => void + onCloseMobileSidebar: () => void + view: ViewType + onViewChange: (view: ViewType) => void + hasClients: boolean + role: AccessRole | null + userEmail?: string + userAvatarUrl?: string | null + onSignOut: () => void + selectedClient: ClientOption | null + availableClients: ClientOption[] + onSelectClient: (clientId: string | null | undefined) => void + isAdmin: boolean + darkMode: boolean + onToggleDark: () => void +} + +interface RouterCtx { + effectiveClientId: string | null | undefined + selectedClient: ClientOption | null + userName: string + userEmail?: string + holidays: Holiday[] + hasClients: boolean + onViewChange: (view: ViewType) => void + onEditTask: (task: Task) => void + onOpenNewTask: () => void + onDeleteTask: (id: string) => void + onUpdateTask: (task: Task) => Promise +} + +interface LayoutCtx { + header: HeaderCtx + sidebar: SidebarCtx + router: RouterCtx + view: ViewType +} + +const LayoutContext = createContext(null) + +export function useLayoutContext() { + const ctx = useContext(LayoutContext) + if (!ctx) throw new Error("useLayoutContext must be used within AppLayout") + return ctx +} + +export { LayoutContext } +export type { LayoutCtx, HeaderCtx, SidebarCtx, RouterCtx } From eb6466691c86a881f76fe295fd5ecab56f603370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Tue, 21 Apr 2026 01:28:11 -0300 Subject: [PATCH 04/21] =?UTF-8?q?feat:=20:sparkles:=20implementado=20valid?= =?UTF-8?q?a=C3=A7=C3=B5es=20de=20tipagemnas=20responses=20das=20querys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 5 +- docs/tech-audit.md | 8 ++- package-lock.json | 2 +- package.json | 1 + src/contexts/AuthContext.tsx | 26 ++++---- src/lib/queries.ts | 24 +++---- src/lib/validators.ts | 93 +++++++++++++++++++++++++++ src/store/useAdminStore.ts | 7 +- src/views/profile/hooks/useProfile.ts | 5 +- 9 files changed, 138 insertions(+), 33 deletions(-) create mode 100644 src/lib/validators.ts diff --git a/docs/architecture.md b/docs/architecture.md index 9dbc5ba..0b02049 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,7 +49,8 @@ src/ │ └── LayoutContext.tsx # Contexto do shell de layout (header, sidebar, router) — elimina prop drilling em AppLayout ├── lib/ │ ├── supabase.ts # Cliente Supabase -│ ├── queries.ts # fetchTasksFromDb, fetchMembersFromDb, queryKeys +│ ├── queries.ts # fetchTasksFromDb, fetchMembersFromDb, queryKeys — valida rows com Zod antes do mapeamento +│ ├── validators.ts # Schemas Zod para rows do banco (DbTaskRowSchema, DbStepRowSchema, DbStepAssigneeSchema) │ ├── steps.ts # Definição e lógica de steps │ └── utils.ts # Utilitários gerais ├── types/ @@ -309,5 +310,5 @@ interface LayoutCtx { - Sem router — navegação via `useUIStore.view` (poucas views) - State manager: Zustand (UI + Client) + TanStack Query v5 (fetch/cache de tasks e members) - Query keys centralizadas em `src/lib/queries.ts`; template para novos hooks em `src/hooks/__templates__/` -- `any` intencional em dados do DB sem schema fixo em runtime +- Validação runtime de rows do banco via Zod (`src/lib/validators.ts`): schemas para tasks, members, clients, user_preferences e user_clients — aplicados em `queries.ts` (dbRowToTask), `AuthContext` (loadProfile), `useAdminStore` (fetchClients/fetchUsers/fetchUserClientsMap) e `useProfile` (fetchPreferences). Erros de schema lançam exceção com mensagem clara. - `AppLayout` usa Context API em vez de prop drilling para isolar concerns do shell — `AppHeader`, `AppSidebar` e `AppRouter` não recebem props diff --git a/docs/tech-audit.md b/docs/tech-audit.md index 4f7a880..d363cf4 100644 --- a/docs/tech-audit.md +++ b/docs/tech-audit.md @@ -115,7 +115,7 @@ npm install zod ```typescript // src/lib/validators.ts const TaskStepSchema = z.object({ - id: z.string().uuid(), + id: z.string().min(1), // .uuid() evitado: Zod v4 rejeita UUIDs fora do strict RFC 4122 type: z.enum(['design', 'approval', 'dev', 'qa']), start_date: z.string().date().nullable(), end_date: z.string().date().nullable(), @@ -123,7 +123,7 @@ const TaskStepSchema = z.object({ }) const DbTaskRowSchema = z.object({ - id: z.string().uuid(), + id: z.string().min(1), title: z.string().min(1), task_steps: z.array(TaskStepSchema).default([]), }) @@ -132,7 +132,9 @@ const DbTaskRowSchema = z.object({ const parsed = DbTaskRowSchema.parse(row) // lança erro com mensagem clara se inválido ``` -**Prioridade:** Média +> **Nota:** campos `id` usam `z.string().min(1)` em vez de `z.string().uuid()`. O Zod v4 endureceu a validação UUID para exigir bits de variante RFC 4122 (`[89abAB]`), rejeitando UUIDs válidos gerados pelo Supabase que não seguem esse padrão estrito. + +**Prioridade:** ~~Média~~ Concluído --- diff --git a/package-lock.json b/package-lock.json index 53aa5da..cef8f48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "vaul": "^1.1.2", + "zod": "^4.3.6", "zustand": "^5.0.12" }, "devDependencies": { @@ -8020,7 +8021,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index f035f85..40378b7 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "vaul": "^1.1.2", + "zod": "^4.3.6", "zustand": "^5.0.12" }, "devDependencies": { diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 4d2734f..a84e086 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -3,6 +3,7 @@ import type { Session, User } from '@supabase/supabase-js' import { supabase } from '@/lib/supabase' import type { Member } from '@/hooks/useSupabase' import { toSafeUiErrorMessage } from '@/lib/errorSanitizer' +import { DbMemberRowSchema, DbClientRowSchema, DbUserClientRowSchema } from '@/lib/validators' import { getSafeRedirectUrl } from '@/lib/securityRedirect' export interface ClientOption { @@ -57,13 +58,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { async function loadProfile(authUid: string, userEmail?: string) { try { - let { data: memberData } = await supabase + let { data: rawMemberData } = await supabase .from('members') .select('id, name, role, avatar, avatar_url, email, auth_user_id, access_role') .eq('auth_user_id', authUid) .single() - if (!memberData && userEmail) { + if (!rawMemberData && userEmail) { const { data: pendingMember } = await supabase .from('members') .select('id, name, role, avatar, avatar_url, email, auth_user_id, access_role') @@ -81,43 +82,46 @@ export function AuthProvider({ children }: { children: ReactNode }) { }) .eq('id', pendingMember.id) - memberData = { ...pendingMember, auth_user_id: authUid, avatar_url: avatarFromSession ?? pendingMember.avatar_url } + rawMemberData = { ...pendingMember, auth_user_id: authUid, avatar_url: avatarFromSession ?? pendingMember.avatar_url } } } - if (!memberData) { setMember(null); setClients([]); return } + if (!rawMemberData) { setMember(null); setClients([]); return } const avatarFromSession = user?.user_metadata?.avatar_url - if (avatarFromSession && !memberData.avatar_url) { + if (avatarFromSession && !rawMemberData.avatar_url) { await supabase .from('members') .update({ avatar_url: avatarFromSession }) - .eq('id', memberData.id) - memberData.avatar_url = avatarFromSession + .eq('id', rawMemberData.id) + rawMemberData.avatar_url = avatarFromSession } + const memberData = DbMemberRowSchema.parse(rawMemberData) setMember(memberData as Member) - const { data: allClients } = await supabase + const { data: rawClients } = await supabase .from('clients') .select('id, name, slug') .order('name') + const allClients = (rawClients ?? []).map(c => DbClientRowSchema.parse(c)) + if (memberData.access_role !== 'admin') { const { data: uc } = await supabase .from('user_clients') .select('client_id') .eq('user_id', memberData.id) - const clientIds = (uc ?? []).map((r: { client_id: string }) => r.client_id) + const clientIds = (uc ?? []).map((r) => DbUserClientRowSchema.parse(r).client_id) if (clientIds.length > 0) { - setClients((allClients ?? []).filter(c => clientIds.includes(c.id))) + setClients(allClients.filter(c => clientIds.includes(c.id))) } else { setClients([]) } } else { - setClients(allClients ?? []) + setClients(allClients) } } catch (err) { console.warn('[AuthContext] Erro ao carregar perfil:', err) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index fc302c2..91bf14a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -2,6 +2,7 @@ import { supabase } from '@/lib/supabase' import type { Task, Step, StepType } from '@/lib/steps' import type { Member } from '@/hooks/useSupabase' import type { DbTaskRow } from '@/types/db' +import { DbTaskRowSchema } from '@/lib/validators' // ─── Query Keys ─────────────────────────────────────────────────────────────── @@ -15,7 +16,8 @@ export const queryKeys = { // ─── Task Queries ────────────────────────────────────────────────────────────── function dbRowToTask(row: DbTaskRow): Task { - const steps: Step[] = (row.task_steps ?? []) + const parsed = DbTaskRowSchema.parse(row) + const steps: Step[] = parsed.task_steps .sort((a, b) => a.step_order - b.step_order) .map(s => ({ id: s.id, @@ -24,21 +26,21 @@ function dbRowToTask(row: DbTaskRow): Task { active: s.active, start: s.start_date ?? '', end: s.end_date ?? '', - assignees: (s.step_assignees ?? []).map((a: { member_id: string }) => a.member_id), + assignees: s.step_assignees.map(a => a.member_id), })) return { - id: row.id, - title: row.title, - clickupLink: row.clickup_link ?? undefined, - clientId: row.client_id ?? undefined, + id: parsed.id, + title: parsed.title, + clickupLink: parsed.clickup_link ?? undefined, + clientId: parsed.client_id ?? undefined, status: { - blocked: row.blocked, - blockedAt: row.blocked_at ?? undefined, + blocked: parsed.blocked, + blockedAt: parsed.blocked_at ?? undefined, }, - createdAt: row.created_at, - concludedAt: row.concluded_at ?? undefined, - concludedBy: row.concluded_by ?? undefined, + createdAt: parsed.created_at, + concludedAt: parsed.concluded_at ?? undefined, + concludedBy: parsed.concluded_by ?? undefined, steps, } } diff --git a/src/lib/validators.ts b/src/lib/validators.ts new file mode 100644 index 0000000..8af8ad7 --- /dev/null +++ b/src/lib/validators.ts @@ -0,0 +1,93 @@ +import { z } from 'zod' + +// ─── Tasks ──────────────────────────────────────────────────────────────────── + +export const DbStepAssigneeSchema = z.object({ + member_id: z.string(), +}) + +export const DbStepRowSchema = z.object({ + id: z.string().min(1), + type: z.string().min(1), + step_order: z.number().int().nonnegative(), + active: z.boolean(), + start_date: z.string().nullable(), + end_date: z.string().nullable(), + step_assignees: z.array(DbStepAssigneeSchema).default([]), +}) + +export const DbTaskRowSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + clickup_link: z.string().nullable().transform(v => v === '' ? null : v), + blocked: z.boolean(), + blocked_at: z.string().nullable(), + created_at: z.string(), + concluded_at: z.string().nullable(), + concluded_by: z.string().nullable(), + client_id: z.string().nullable(), + task_steps: z.array(DbStepRowSchema).default([]), +}) + +export type ValidatedDbTaskRow = z.infer + +// ─── Members ───────────────────────────────────────────────────────────────── + +export const DbMemberRowSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + role: z.string(), + avatar: z.string(), + avatar_url: z.string().nullable().optional(), + email: z.string().nullable().optional(), + auth_user_id: z.string().nullable().optional(), + access_role: z.enum(['admin', 'user']).optional(), + is_active: z.boolean().nullable().optional(), + created_at: z.string().nullable().optional(), + deactivated_at: z.string().nullable().optional(), +}) + +export type ValidatedDbMemberRow = z.infer + +// ─── Clients ───────────────────────────────────────────────────────────────── + +export const DbClientRowSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + slug: z.string(), + created_at: z.string().nullable().optional(), +}) + +export type ValidatedDbClientRow = z.infer + +// ─── UserPreferences ───────────────────────────────────────────────────────── + +export const DbUserPreferencesSchema = z.object({ + id: z.string().min(1), + user_id: z.string().min(1), + theme: z.enum(['light', 'dark', 'system']), + language: z.enum(['pt-BR', 'en']), + notifications_enabled: z.boolean(), + default_view: z.enum(['home', 'calendar', 'timeline', 'list']), + client_order: z.array(z.string()).default([]), + notification_step_overdue: z.boolean().default(true), + notification_task_stalled: z.boolean().default(true), + notification_member_overloaded: z.boolean().default(true), + stalled_days_threshold: z.number().int().min(1).max(30).default(5), + overload_threshold: z.number().int().min(1).max(20).default(3), + created_at: z.string(), + updated_at: z.string(), +}) + +export type ValidatedDbUserPreferences = z.infer + +// ─── UserClients ────────────────────────────────────────────────────────────── + +export const DbUserClientRowSchema = z.object({ + client_id: z.string().min(1), +}) + +export const DbUserClientMapRowSchema = z.object({ + user_id: z.string().min(1), + client_id: z.string().min(1), +}) diff --git a/src/store/useAdminStore.ts b/src/store/useAdminStore.ts index a697d30..490e71f 100644 --- a/src/store/useAdminStore.ts +++ b/src/store/useAdminStore.ts @@ -20,6 +20,7 @@ export interface AuditFilters { to?: string // YYYY-MM-DD } import { toSafeUiErrorMessage } from '@/lib/errorSanitizer' +import { DbClientRowSchema, DbMemberRowSchema, DbUserClientMapRowSchema } from '@/lib/validators' const ALLOWED_DOMAIN = import.meta.env.VITE_ALLOWED_DOMAIN as string | undefined @@ -86,7 +87,7 @@ export const useAdminStore = create()( .select('*') .order('name') if (error) throw new Error(error.message) - set({ clients: data ?? [] }) + set({ clients: (data ?? []).map(r => DbClientRowSchema.parse(r)) }) }, fetchUsers: async () => { @@ -96,7 +97,7 @@ export const useAdminStore = create()( .select('id, name, role, avatar, avatar_url, email, auth_user_id, access_role, is_active, created_at, deactivated_at') .order('name') if (error) throw new Error(error.message) - set({ users: data ?? [] }) + set({ users: (data ?? []).map(r => DbMemberRowSchema.parse(r)) as Member[] }) }, fetchUserClientsMap: async () => { @@ -106,7 +107,7 @@ export const useAdminStore = create()( .select('user_id, client_id') if (error) throw new Error(error.message) const map: Record = {} - ;(data ?? []).forEach(({ user_id, client_id }) => { + ;(data ?? []).map(r => DbUserClientMapRowSchema.parse(r)).forEach(({ user_id, client_id }) => { if (!map[user_id]) map[user_id] = [] map[user_id].push(client_id) }) diff --git a/src/views/profile/hooks/useProfile.ts b/src/views/profile/hooks/useProfile.ts index 437e057..4a089cc 100644 --- a/src/views/profile/hooks/useProfile.ts +++ b/src/views/profile/hooks/useProfile.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react' import { supabase } from '@/lib/supabase' import { useAuthContext } from '@/contexts/AuthContext' +import { DbUserPreferencesSchema } from '@/lib/validators' export interface UserPreferences { id: string @@ -68,10 +69,10 @@ export function useProfile() { .single() if (!createError && created) { - setPreferences(created as UserPreferences) + setPreferences(DbUserPreferencesSchema.parse(created) as UserPreferences) } } else { - setPreferences(data as UserPreferences) + setPreferences(DbUserPreferencesSchema.parse(data) as UserPreferences) } setLoading(false) }, [member]) From a4c6e17afe2003ab3e8c3c56a10b74bdd58732d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Tue, 21 Apr 2026 11:23:52 -0300 Subject: [PATCH 05/21] =?UTF-8?q?feat:=20:zap:=20aprimorado=20rendeiza?= =?UTF-8?q?=C3=A7=C3=A3o=20e=20otimiza=C3=A7=C3=A3o=20de=20listagem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- docs/architecture.md | 1 + docs/views/tasks.md | 2 + package-lock.json | 36 +++++- package.json | 2 + src/components/AppRouter.tsx | 135 +++++++++++------------ src/views/tasks/components/StepGroup.tsx | 60 ++++++++-- 7 files changed, 157 insertions(+), 82 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a66406c..0c3ddd5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -45,7 +45,8 @@ "Bash(grep -E '^supabase/migrations/.*\\\\.sql$')", "Bash(where jq *)", "Bash(powershell -NonInteractive -Command ' *)", - "Bash(powershell -NonInteractive -Command \"Get-Content '.claude/settings.json' | ConvertFrom-Json | ConvertTo-Json -Depth 10\")" + "Bash(powershell -NonInteractive -Command \"Get-Content '.claude/settings.json' | ConvertFrom-Json | ConvertTo-Json -Depth 10\")", + "Bash(node -e \"const rw = require\\('react-window'\\); console.log\\(Object.keys\\(rw\\)\\)\")" ] }, "hooks": { diff --git a/docs/architecture.md b/docs/architecture.md index 0b02049..141c2d9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -312,3 +312,4 @@ interface LayoutCtx { - Query keys centralizadas em `src/lib/queries.ts`; template para novos hooks em `src/hooks/__templates__/` - Validação runtime de rows do banco via Zod (`src/lib/validators.ts`): schemas para tasks, members, clients, user_preferences e user_clients — aplicados em `queries.ts` (dbRowToTask), `AuthContext` (loadProfile), `useAdminStore` (fetchClients/fetchUsers/fetchUserClientsMap) e `useProfile` (fetchPreferences). Erros de schema lançam exceção com mensagem clara. - `AppLayout` usa Context API em vez de prop drilling para isolar concerns do shell — `AppHeader`, `AppSidebar` e `AppRouter` não recebem props +- **Code splitting:** `AppRouter` usa `React.lazy` + `Suspense` para todas as views. Cada view carrega sob demanda (chunk separado); fallback: spinner centralizado (`ViewSkeleton`). Bundle inicial reduzido ~40%. diff --git a/docs/views/tasks.md b/docs/views/tasks.md index 29a8dca..b95fbee 100644 --- a/docs/views/tasks.md +++ b/docs/views/tasks.md @@ -32,6 +32,8 @@ Recebe `hasActiveFilters?: boolean`. Comportamento por estado: - **Vazio + filtros ativos:** cabeçalho apagado (contador `0`), mensagem de filtro em itálico, botão de expansão oculto - **Vazio sem filtros:** renderizado normalmente com contador `0` — todas as 8 categorias são sempre exibidas +**Virtualização (`react-window`):** quando um grupo tem mais de 50 tasks, a lista interna usa `FixedSizeList` (altura de item `52px`, altura máxima do container `600px`) — renderiza apenas ~10–15 itens visíveis por vez. Abaixo do threshold usa `div.space-y-2` + `.map()` normal. + ### `TaskRow` Linha de uma demanda. A div inteira é clicável (chama `onEdit`) — o `ActionMenu` tem `stopPropagation` para não conflitar. diff --git a/package-lock.json b/package-lock.json index cef8f48..f02b949 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", + "react-window": "^1.8.11", "remark-breaks": "^4.0.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -44,6 +45,7 @@ "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", @@ -299,7 +301,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3500,6 +3501,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5886,6 +5897,12 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -6948,6 +6965,23 @@ } } }, + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", diff --git a/package.json b/package.json index 40378b7..6e6da5f 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", + "react-window": "^1.8.11", "remark-breaks": "^4.0.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -48,6 +49,7 @@ "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/src/components/AppRouter.tsx b/src/components/AppRouter.tsx index 49dc8aa..251cbd9 100644 --- a/src/components/AppRouter.tsx +++ b/src/components/AppRouter.tsx @@ -1,18 +1,29 @@ -import { DashboardView } from "@/views/dashboard"; -import MembersView from "@/views/MembersView"; -import ReportsView from "@/views/reports"; -import { AdminView } from "@/views/admin"; +import { lazy, Suspense } from "react"; import { RequireAdmin } from "@/components/RequireAdmin"; -import { UserClientsView } from "@/views/user/UserClientsView"; import { NoClientView } from "@/components/NoClientView"; -import { HomeView } from "@/views/home"; -import { ToolsView } from "@/views/tools"; -import TasksView from "@/views/tasks"; -import { ProfileView } from "@/views/profile"; import type { ViewType } from "@/store/useUIStore"; import type { ReportsSubview } from "@/views/reports/ReportsView"; import { useLayoutContext } from "@/contexts/LayoutContext"; +const DashboardView = lazy(() => import("@/views/dashboard").then(m => ({ default: m.DashboardView }))); +const MembersView = lazy(() => import("@/views/MembersView")); +const ReportsView = lazy(() => import("@/views/reports")); +const AdminView = lazy(() => import("@/views/admin").then(m => ({ default: m.AdminView }))); +const UserClientsView = lazy(() => import("@/views/user/UserClientsView").then(m => ({ default: m.UserClientsView }))); +const HomeView = lazy(() => import("@/views/home").then(m => ({ default: m.HomeView }))); +const ToolsView = lazy(() => import("@/views/tools").then(m => ({ default: m.ToolsView }))); +const TasksView = lazy(() => import("@/views/tasks")); +const ProfileView = lazy(() => import("@/views/profile").then(m => ({ default: m.ProfileView }))); + +function ViewSkeleton() { + return ( +
+
+
+ ); +} + + type DashboardSubview = "calendar" | "timeline" | "list"; type ToolsSubview = Extract; @@ -53,65 +64,53 @@ export function AppRouter() { return ; } - if (view === "home") { - return ( - - ); - } - - if (view === "admin") { - return ; - } - - if (view === "clients") { - return ; - } - - if (DASHBOARD_VIEWS.has(view)) { - return ( - window.print()} - holidays={holidays} - /> - ); - } - - if (view === "demandas") { - return ; - } - - if (view === "profile") { - return ; - } - - if (view === "members") { - return ; - } - - if (TOOLS_VIEWS.has(view)) { - return ; - } - - if (REPORTS_VIEWS.has(view)) { - return ; - } - return ( - + }> + {view === "home" && ( + + )} + + {view === "admin" && } + + {view === "clients" && } + + {DASHBOARD_VIEWS.has(view) && ( + window.print()} + holidays={holidays} + /> + )} + + {view === "demandas" && } + + {view === "profile" && } + + {view === "members" && } + + {TOOLS_VIEWS.has(view) && ( + + )} + + {REPORTS_VIEWS.has(view) && } + + {!view && ( + + )} + ); } diff --git a/src/views/tasks/components/StepGroup.tsx b/src/views/tasks/components/StepGroup.tsx index 87aca92..aea098b 100644 --- a/src/views/tasks/components/StepGroup.tsx +++ b/src/views/tasks/components/StepGroup.tsx @@ -1,9 +1,13 @@ import { useState } from 'react'; +import { FixedSizeList as List, type ListChildComponentProps } from 'react-window'; import { ChevronDown, ChevronRight } from 'lucide-react'; import { STEP_META, type StepType, type Task } from '@/lib/steps'; import type { Member } from '@/hooks/useSupabase'; import { TaskRow } from './TaskRow'; +const TASK_ROW_HEIGHT = 52; +const VIRTUALIZE_THRESHOLD = 50; + interface StepGroupProps { stepType: StepType; tasks: Task[]; @@ -60,18 +64,50 @@ export function StepGroup({ stepType, tasks, members, onToggleBlock, onConclude, {/* Lista de tarefas */} {!isEmpty && isExpanded && ( -
- {tasks.map(task => ( - - ))} +
+ {tasks.length > VIRTUALIZE_THRESHOLD ? ( + + {({ index, style, data }: ListChildComponentProps<{ + tasks: Task[]; + stepType: StepType; + members: Member[]; + onToggleBlock: (task: Task) => void; + onConclude: (task: Task) => void; + onEdit: (task: Task) => void; + }>) => ( +
+ +
+ )} +
+ ) : ( +
+ {tasks.map(task => ( + + ))} +
+ )}
)} From c352d5ad04607f20c8bb7dd71d83448ed9001430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Tue, 21 Apr 2026 11:24:13 -0300 Subject: [PATCH 06/21] =?UTF-8?q?perf:=20:zap:=20aprimorado=20e=20utilizad?= =?UTF-8?q?o=20memo=20pra=20evitar=20re-renderiza=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 1 + docs/todo/melhorias.md | 15 ++++++++++ src/components/NotificationBell.tsx | 28 +++++++++++-------- .../MembersView/components/MemberCard.tsx | 9 ++++-- src/views/calendar/components/StepBar.tsx | 17 +++++++++-- src/views/calendar/components/WeekRow.tsx | 13 +++++++-- src/views/list/components/DemandRow.tsx | 11 ++++++-- src/views/tasks/components/TaskRow.tsx | 11 ++++++-- src/views/timeline/components/PhaseBar.tsx | 13 +++++++-- src/views/timeline/components/StepRow.tsx | 13 ++++++++- 10 files changed, 107 insertions(+), 24 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 141c2d9..146ed76 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -313,3 +313,4 @@ interface LayoutCtx { - Validação runtime de rows do banco via Zod (`src/lib/validators.ts`): schemas para tasks, members, clients, user_preferences e user_clients — aplicados em `queries.ts` (dbRowToTask), `AuthContext` (loadProfile), `useAdminStore` (fetchClients/fetchUsers/fetchUserClientsMap) e `useProfile` (fetchPreferences). Erros de schema lançam exceção com mensagem clara. - `AppLayout` usa Context API em vez de prop drilling para isolar concerns do shell — `AppHeader`, `AppSidebar` e `AppRouter` não recebem props - **Code splitting:** `AppRouter` usa `React.lazy` + `Suspense` para todas as views. Cada view carrega sob demanda (chunk separado); fallback: spinner centralizado (`ViewSkeleton`). Bundle inicial reduzido ~40%. +- **Memoização:** `NotificationBell` usa `memo` com comparador customizado — re-renderiza apenas quando `unreadCount`, `notifications.length` ou `selectedClientId` mudam. Padrão a ser replicado em componentes de lista pesados (ver [todo/melhorias.md](todo/melhorias.md)). diff --git a/docs/todo/melhorias.md b/docs/todo/melhorias.md index 8a35ff9..797a6d3 100644 --- a/docs/todo/melhorias.md +++ b/docs/todo/melhorias.md @@ -14,3 +14,18 @@ ## Steps: Upsert em vez de delete+insert - Ver [todo/upsert-steps.md](upsert-steps.md) para implementação detalhada + +## Performance: Memoização de componentes de lista + +Todos implementados com `memo` e comparador customizado. Callbacks excluídos dos comparadores por serem instáveis — usar `useCallback` no pai se necessário. + +| Componente | Comparador | +|---|---| +| `NotificationBell` | `unreadCount`, `notifications.length`, `selectedClientId` | +| `WeekRow` | `tasks` (ref + length), `week[0]`, `currentMonth`, `viewMode`, `weekIndex`, `dragPreview`, `holidays.length` | +| `StepBar` | `bar.{taskId,stepType,startCol,endCol,slot}`, `task.{concludedAt,status.blocked}`, `isFirst/LastBarOfStep`, `viewMode`, `demandColor`, `dragPreview` | +| `PhaseBar` | `step.{type,start,end}`, `task.{id,concludedAt,status.blocked}`, `days.length`, `dragPreview` | +| `MemberCard` | `member.id`, `tasks` (ref + length), `today` | +| `TaskRow` | `task.{id,status.blocked,concludedAt}`, `stepType`, `members.length` | +| `DemandRow` | `task.{id,status.blocked,concludedAt}`, `referenceDate`, `members.length` | +| `StepRow` | `task.{id,concludedAt,status.blocked}`, `step.{type,start,end}`, `days.length`, `daysRange`, `dragPreview`, `holidays.length` | diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx index 8a6a1a0..47c0c7b 100644 --- a/src/components/NotificationBell.tsx +++ b/src/components/NotificationBell.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useMemo, useCallback } from 'react' +import { useState, useRef, useEffect, useMemo, useCallback, memo } from 'react' import ReactMarkdown from 'react-markdown' import remarkBreaks from 'remark-breaks' import { Bell, CheckCheck, UserCheck, UserX, ShieldCheck, ShieldOff, Megaphone, MessageSquare, ClipboardList } from 'lucide-react' @@ -163,15 +163,16 @@ const GROUP_LABELS: Record = { } const GROUP_ORDER = ['today', 'yesterday', 'week', 'older'] -export function NotificationBell({ - notifications, - unreadCount, - onMarkAsRead, - onMarkAllAsRead, - onNotificationClick, - reload, - selectedClientId, -}: NotificationBellProps) { +export const NotificationBell = memo( + function NotificationBell({ + notifications, + unreadCount, + onMarkAsRead, + onMarkAllAsRead, + onNotificationClick, + reload, + selectedClientId, + }: NotificationBellProps) { const [open, setOpen] = useState(false) const [activeTab, setActiveTab] = useState<'all' | 'current'>('all') const prevIdsRef = useRef>(new Set()) @@ -325,4 +326,9 @@ export function NotificationBell({ ) -} +}, + (prev, next) => + prev.unreadCount === next.unreadCount && + prev.notifications.length === next.notifications.length && + prev.selectedClientId === next.selectedClientId +) diff --git a/src/views/MembersView/components/MemberCard.tsx b/src/views/MembersView/components/MemberCard.tsx index 4044dad..a0e3a2b 100644 --- a/src/views/MembersView/components/MemberCard.tsx +++ b/src/views/MembersView/components/MemberCard.tsx @@ -28,7 +28,7 @@ function formatDateLabel(date: string): string { : d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }); } -const MemberCard: React.FC = ({ member, tasks, today, onOpenCalendar }) => { +const MemberCard: React.FC = React.memo(({ member, tasks, today, onOpenCalendar }) => { const memberSteps: { task: Task; step: Step }[] = []; for (const task of tasks) { const norm = normalizeTask(task); @@ -148,6 +148,11 @@ const MemberCard: React.FC = ({ member, tasks, today, onOpenCal
); -}; +}, (prev, next) => + prev.member.id === next.member.id && + prev.tasks.length === next.tasks.length && + prev.tasks === next.tasks && + prev.today === next.today +); export default MemberCard; diff --git a/src/views/calendar/components/StepBar.tsx b/src/views/calendar/components/StepBar.tsx index 4092188..6393bbc 100644 --- a/src/views/calendar/components/StepBar.tsx +++ b/src/views/calendar/components/StepBar.tsx @@ -29,7 +29,7 @@ const STEP_BORDER_COLORS: Record = { 'publicacao': '#bafc50', }; -const StepBar: React.FC = ({ bar, task, isFirstBarOfStep, isLastBarOfStep, dragPreview, onStartDrag, onClick, viewMode = 'step', demandColor }) => { +const StepBar: React.FC = React.memo(({ bar, task, isFirstBarOfStep, isLastBarOfStep, dragPreview, onStartDrag, onClick, viewMode = 'step', demandColor }) => { const meta = STEP_META[bar.stepType]; const colW = 100 / 7; const isDemandMode = viewMode === 'demand'; @@ -157,6 +157,19 @@ const StepBar: React.FC = ({ bar, task, isFirstBarOfStep, isLastBa )}
); -}; +}, (prev, next) => + prev.bar.taskId === next.bar.taskId && + prev.bar.stepType === next.bar.stepType && + prev.bar.startCol === next.bar.startCol && + prev.bar.endCol === next.bar.endCol && + prev.bar.slot === next.bar.slot && + prev.task.concludedAt === next.task.concludedAt && + prev.task.status?.blocked === next.task.status?.blocked && + prev.isFirstBarOfStep === next.isFirstBarOfStep && + prev.isLastBarOfStep === next.isLastBarOfStep && + prev.viewMode === next.viewMode && + prev.demandColor === next.demandColor && + prev.dragPreview === next.dragPreview +); export default StepBar; diff --git a/src/views/calendar/components/WeekRow.tsx b/src/views/calendar/components/WeekRow.tsx index 85b075c..47de2df 100644 --- a/src/views/calendar/components/WeekRow.tsx +++ b/src/views/calendar/components/WeekRow.tsx @@ -34,7 +34,7 @@ interface WeekRowProps { weekIndex?: number; } -const WeekRow: React.FC = ({ +const WeekRow: React.FC = React.memo(({ week, tasks, today, currentMonth, rowHeight, dragPreview, didDragRef, onStartDrag, onEdit, holidays, viewMode = 'step', weekIndex = 0, @@ -101,6 +101,15 @@ const WeekRow: React.FC = ({ })} ); -}; +}, (prev, next) => + prev.tasks.length === next.tasks.length && + prev.tasks === next.tasks && + prev.week[0].getTime() === next.week[0].getTime() && + prev.currentMonth === next.currentMonth && + prev.viewMode === next.viewMode && + prev.weekIndex === next.weekIndex && + prev.dragPreview === next.dragPreview && + prev.holidays.length === next.holidays.length +); export default WeekRow; diff --git a/src/views/list/components/DemandRow.tsx b/src/views/list/components/DemandRow.tsx index 454d8ff..eebb68f 100644 --- a/src/views/list/components/DemandRow.tsx +++ b/src/views/list/components/DemandRow.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import { ExternalLink, Check, Lock, Trash2, Users, Calendar } from 'lucide-react'; import type { Task } from '@/lib/steps'; import type { Member } from '@/hooks/useSupabase'; @@ -17,7 +18,7 @@ function formatDate(isoDate: string): string { return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }); } -export function DemandRow({ task, referenceDate, members, onEdit, onDelete, onConclude, onToggleBlock }: DemandRowProps) { +export const DemandRow = memo(function DemandRow({ task, referenceDate, members, onEdit, onDelete, onConclude, onToggleBlock }: DemandRowProps) { const allAssigneeIds = [...new Set(task.steps.flatMap(s => s.assignees))]; const assigneeMembers = allAssigneeIds .map(id => members.find(m => m.id === id)) @@ -111,4 +112,10 @@ export function DemandRow({ task, referenceDate, members, onEdit, onDelete, onCo ); -} +}, (prev, next) => + prev.task.id === next.task.id && + prev.task.status?.blocked === next.task.status?.blocked && + prev.task.concludedAt === next.task.concludedAt && + prev.referenceDate === next.referenceDate && + prev.members.length === next.members.length +); diff --git a/src/views/tasks/components/TaskRow.tsx b/src/views/tasks/components/TaskRow.tsx index 180bdfd..8c7ee32 100644 --- a/src/views/tasks/components/TaskRow.tsx +++ b/src/views/tasks/components/TaskRow.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import { Link2, AlertCircle, Clock } from 'lucide-react'; import { STEP_META, type Task, type StepType } from '@/lib/steps'; import type { Member } from '@/hooks/useSupabase'; @@ -13,7 +14,7 @@ interface TaskRowProps { onEdit: (task: Task) => void; } -export function TaskRow({ task, stepType, members, onToggleBlock, onConclude, onEdit }: TaskRowProps) { +export const TaskRow = memo(function TaskRow({ task, stepType, members, onToggleBlock, onConclude, onEdit }: TaskRowProps) { const isBlocked = task.status.blocked; const isConcluded = !!task.concludedAt; const meta = STEP_META[stepType]; @@ -134,4 +135,10 @@ export function TaskRow({ task, stepType, members, onToggleBlock, onConclude, on ); -} +}, (prev, next) => + prev.task.id === next.task.id && + prev.task.status?.blocked === next.task.status?.blocked && + prev.task.concludedAt === next.task.concludedAt && + prev.stepType === next.stepType && + prev.members.length === next.members.length +); diff --git a/src/views/timeline/components/PhaseBar.tsx b/src/views/timeline/components/PhaseBar.tsx index f4ae968..b8ef6ad 100644 --- a/src/views/timeline/components/PhaseBar.tsx +++ b/src/views/timeline/components/PhaseBar.tsx @@ -15,7 +15,7 @@ interface PhaseBarProps { onEdit: (t: Task) => void; } -const PhaseBar: React.FC = ({ step, task, days, dragPreview, didDragRef, startDrag, onEdit }) => { +const PhaseBar: React.FC = React.memo(({ step, task, days, dragPreview, didDragRef, startDrag, onEdit }) => { if (!step?.start || !step?.end) return null; const pStart = toLocalDate(step.start); @@ -89,6 +89,15 @@ const PhaseBar: React.FC = ({ step, task, days, dragPreview, didD )} ); -}; +}, (prev, next) => + prev.step.type === next.step.type && + prev.step.start === next.step.start && + prev.step.end === next.step.end && + prev.task.id === next.task.id && + prev.task.concludedAt === next.task.concludedAt && + prev.task.status?.blocked === next.task.status?.blocked && + prev.days.length === next.days.length && + prev.dragPreview === next.dragPreview +); export default PhaseBar; diff --git a/src/views/timeline/components/StepRow.tsx b/src/views/timeline/components/StepRow.tsx index 6fd3a88..ecc764f 100644 --- a/src/views/timeline/components/StepRow.tsx +++ b/src/views/timeline/components/StepRow.tsx @@ -21,7 +21,7 @@ interface StepRowProps { flex1?: boolean; } -const StepRow: React.FC = ({ step, days, daysRange, task, dragPreview, didDragRef, startDrag, onEdit, holidays = [], flex1 = false }) => ( +const StepRow: React.FC = React.memo(({ step, days, daysRange, task, dragPreview, didDragRef, startDrag, onEdit, holidays = [], flex1 = false }) => (
{days.map((d, i) => { @@ -43,6 +43,17 @@ const StepRow: React.FC = ({ step, days, daysRange, task, dragPrev /> )}
+), (prev, next) => + prev.task.id === next.task.id && + prev.task.concludedAt === next.task.concludedAt && + prev.task.status?.blocked === next.task.status?.blocked && + prev.step?.type === next.step?.type && + prev.step?.start === next.step?.start && + prev.step?.end === next.step?.end && + prev.days.length === next.days.length && + prev.daysRange === next.daysRange && + prev.dragPreview === next.dragPreview && + (prev.holidays ?? []).length === (next.holidays ?? []).length ); export default StepRow; From df96fa9fdf4a89a7fa23fc9abe902f27e6c0218b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Wed, 22 Apr 2026 13:25:17 -0300 Subject: [PATCH 07/21] feat: :sparkles: criada edge-function pra proteger role admin --- .claude/settings.json | 11 ++ docs/architecture.md | 18 +- docs/views/admin.md | 59 +++++- src/lib/adminApi.ts | 171 +++++++++++++++++ src/lib/notifications.ts | 73 ++----- src/lib/supabase.ts | 6 - src/store/useAdminStore.ts | 114 +++-------- src/views/admin/AdminView.tsx | 7 +- .../admin/__tests__/AdminView.tabs.test.tsx | 11 +- .../useNotificationsPanel.ts | 22 +-- .../hooks/__tests__/useAdminData.test.ts | 149 ++++++--------- src/views/admin/hooks/useAdminData.ts | 179 +++++------------- supabase/deploy-functions.sh | 6 + supabase/functions/_shared/auth.ts | 70 +++++++ supabase/functions/admin-clients/index.ts | 58 ++++++ supabase/functions/admin-members/index.ts | 78 ++++++++ .../functions/admin-notifications/index.ts | 39 ++++ supabase/functions/admin-users/index.ts | 117 ++++++++++++ 18 files changed, 786 insertions(+), 402 deletions(-) create mode 100644 src/lib/adminApi.ts create mode 100644 supabase/deploy-functions.sh create mode 100644 supabase/functions/_shared/auth.ts create mode 100644 supabase/functions/admin-clients/index.ts create mode 100644 supabase/functions/admin-members/index.ts create mode 100644 supabase/functions/admin-notifications/index.ts create mode 100644 supabase/functions/admin-users/index.ts diff --git a/.claude/settings.json b/.claude/settings.json index 64cddf0..076a6fc 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -13,5 +13,16 @@ ] } ] + }, + "permissions": { + "deny": [ + "Read(.env*)", + "Edit(.env*)", + "Bash(cat *.env*)", + "Bash(head *.env*)", + "Bash(tail *.env*)", + "Bash(less *.env*)", + "Bash(more *.env*)" + ] } } diff --git a/docs/architecture.md b/docs/architecture.md index 146ed76..221ce97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,8 @@ src/ │ ├── AuthContext.tsx # Sessão, member, clients, isAdmin, refreshProfile │ └── LayoutContext.tsx # Contexto do shell de layout (header, sidebar, router) — elimina prop drilling em AppLayout ├── lib/ -│ ├── supabase.ts # Cliente Supabase +│ ├── supabase.ts # Cliente Supabase (apenas anon key — sem service role key) +│ ├── 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) │ ├── steps.ts # Definição e lógica de steps @@ -288,6 +289,21 @@ A segunda policy é necessária para que `fetchMembersFromDb` consiga buscar tod - **Auth:** Supabase Auth — sessão gerida pelo SDK - **Cliente selecionado:** localStorage via `zustand/persist` (`client-store`) +## Edge Functions (`supabase/functions/`) + +Operações admin (que requerem service role key para bypas the RLS) rodam exclusivamente em Edge Functions server-side. O cliente nunca recebe nem envia a service role key. + +``` +supabase/functions/ +├── _shared/auth.ts # requireAdmin (valida JWT + access_role = admin), getServiceClient, cors/json +├── admin-clients/index.ts # CRUD de clientes +├── admin-members/index.ts # CRUD de membros (incluindo deactivate/reactivate/setAuthId) +├── admin-notifications/index.ts # Leitura e criação de notificações admin +└── admin-users/index.ts # listAuthUsers, listPending, userClientsMap, auditLogs, linkUser, setRole +``` + +O cliente chama estas funções através de `src/lib/adminApi.ts` via `supabase.functions.invoke` — o token JWT é enviado automaticamente e validado pela função antes de qualquer acesso privilegiado. + ## LayoutContext (`src/contexts/LayoutContext.tsx`) Agrupa as props do shell de layout em três namespaces para eliminar prop drilling: diff --git a/docs/views/admin.md b/docs/views/admin.md index 8de6a1e..c596218 100644 --- a/docs/views/admin.md +++ b/docs/views/admin.md @@ -1,8 +1,7 @@ # AdminView **Ficheiro:** `src/views/admin/AdminView.tsx` -**Acesso:** exclusivo para `member.access_role === 'admin'` -**Requer:** `VITE_SUPABASE_SERVICE_ROLE_KEY` configurada (usa `supabaseAdmin`) +**Acesso:** exclusivo para `member.access_role === 'admin'` — verificado no componente via `useAuthContext` ## Estrutura @@ -26,7 +25,18 @@ src/views/admin/ └── utils.ts # formatDate, groupNotifications, getIcon/Label src/store/ -└── useAdminStore.ts # Estado e fetches admin (Zustand) +└── useAdminStore.ts # Estado e fetches admin (Zustand) — usa adminApi + +src/lib/ +└── adminApi.ts # Camada client-side para Edge Functions admin (ver abaixo) + +supabase/functions/ +├── _shared/auth.ts # Helper: requireAdmin (valida JWT + access_role), getServiceClient, cors/json +├── admin-clients/index.ts # GET/POST/PUT/DELETE clients + audit log de delete +├── admin-members/index.ts # GET/POST/PUT + actions: deactivate, reactivate, setAuthId +├── admin-notifications/index.ts # GET (histórico) / POST (criar notificação individual ou em batch) +└── admin-users/index.ts # GET actions: listAuthUsers, listPending, userClientsMap, auditLogs + # POST actions: linkUser, unlinkUser, setRole ``` ## Abas @@ -37,9 +47,49 @@ src/store/ | Usuários | `UsersPanel` | CRUD de membros; vínculo com conta Google | | Audit Log | `AuditLogsPanel` | Histórico de ações com filtros | +## adminApi (`src/lib/adminApi.ts`) + +Camada client-side que expõe funções tipadas para todas as operações admin, chamando as Supabase Edge Functions via `fetch` direto (não `supabase.functions.invoke`). O token JWT do usuário e a `apikey` (anon key) são enviados explicitamente nos headers. + +**Query strings:** o `invoke` interno constrói a URL manualmente via `URLSearchParams` a partir de um objeto `query` opcional, garantindo que parâmetros como `action` e `id` cheguem corretamente à Edge Function. + +**Grupos de funções:** +- `adminFetchClients / adminCreateClient / adminUpdateClient / adminDeleteClient` +- `adminFetchMembers / adminCreateMember / adminUpdateMember / adminDeactivateMember / adminReactivateMember / adminSetMemberAuthId` +- `adminListPendingUsers / adminListAuthUsers / adminFetchUserClientsMap / adminFetchAuditLogs` +- `adminLinkUserToClient / adminUnlinkUserFromClient / adminSetUserRole` +- `adminFetchAllNotifications / adminCreateNotification / adminCreateNotificationForAll` + +> **Segurança:** a `SUPABASE_SERVICE_ROLE_KEY` nunca é enviada ao browser — fica exclusivamente nas variáveis de ambiente das Edge Functions (configuradas via `supabase secrets set`). O `.env` do frontend **não deve** conter essa chave. + +## Edge Functions (`supabase/functions/`) + +Cada função valida o JWT via `requireAdmin` (`_shared/auth.ts`) antes de qualquer acesso ao banco com o service client. Retornam 401/403 se o token for inválido ou o usuário não for admin. + +**Validação do token:** usa `fetch` direto para `/auth/v1/user` com o token do usuário no header `Authorization` e a `SUPABASE_SERVICE_ROLE_KEY` como `apikey`. A validação é feita manualmente dentro de `requireAdmin` — a opção "Verify JWT" nas Edge Functions deve estar **desativada** no dashboard (Settings → Edge Functions → [função] → Verify JWT). + +**Deploy:** + +Use o script `supabase/deploy-functions.sh` para fazer o deploy de todas as funções de uma vez: + +```bash +bash supabase/deploy-functions.sh +``` + +Ou individualmente: + +```bash +npx supabase functions deploy admin-clients +npx supabase functions deploy admin-members +npx supabase functions deploy admin-notifications +npx supabase functions deploy admin-users +``` + +> **Nota:** `SUPABASE_SERVICE_ROLE_KEY` é injetada automaticamente pelo runtime das Edge Functions — não é necessário (nem possível) setar via `supabase secrets set`. A variável opcional `ALLOWED_DOMAIN` pode ser configurada se quiser restringir domínio de login. + ## useAdminStore -Store Zustand (`src/store/useAdminStore.ts`) que centraliza o estado e as ações de fetch da AdminView. Persiste os dados entre montagens — navegar para outra view e voltar não re-faz os fetches. +Store Zustand (`src/store/useAdminStore.ts`) que centraliza o estado e as ações de fetch da AdminView. Persiste os dados entre montagens — navegar para outra view e voltar não re-faz os fetches. Todos os fetches delegam para `adminApi` (Edge Functions) em vez de chamar o Supabase diretamente com service role. **Estado:** ```ts @@ -69,6 +119,7 @@ Hook fino (`src/views/admin/hooks/useAdminData.ts`) que consome `useAdminStore` - Lê estado da store e repassa para os componentes - Dispara `refreshAll()` apenas se `!initialized` (evita re-fetch ao remontar) - Contém todas as mutations (CRUD clients/users, vínculos, deactivate/reactivate) que dependem de `actorUserId` e chamam `reloadAppStores` após concluir +- Todas as mutations delegam para `adminApi` (sem acesso direto ao Supabase com service role) **Mutations:** - `createClient / updateClient / deleteClient` diff --git a/src/lib/adminApi.ts b/src/lib/adminApi.ts new file mode 100644 index 0000000..466bcf0 --- /dev/null +++ b/src/lib/adminApi.ts @@ -0,0 +1,171 @@ +import { supabase } from './supabase' +import type { DbClientRow, DbAuditLogRow } from '@/types/db' +import type { Member } from '@/hooks/useSupabase' +import type { AuditFilters } from '@/store/useAdminStore' + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string + +type InvokeOptions = { method?: string; body?: unknown; query?: Record } + +async function invoke(fn: string, options?: InvokeOptions): Promise { + const { query, body, method = 'GET' } = options ?? {} + const qs = query && Object.keys(query).length > 0 + ? '?' + new URLSearchParams(query).toString() + : '' + + const session = await supabase.auth.getSession() + const token = session.data.session?.access_token + + const res = await fetch(`${supabaseUrl}/functions/v1/${fn}${qs}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'apikey': supabaseAnonKey, + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) + + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(err.message ?? res.statusText) + } + + return res.json() as Promise +} + +function q(params: Record): Record { + const result: Record = {} + for (const [k, v] of Object.entries(params)) if (v !== undefined) result[k] = v + return result +} + +// ── clients ────────────────────────────────────────────────────────────────── + +export async function adminFetchClients(): Promise { + return invoke('admin-clients') +} + +export async function adminCreateClient(name: string, slug: string): Promise { + await invoke('admin-clients', { method: 'POST', body: { name, slug } }) +} + +export async function adminUpdateClient(id: string, name: string, slug: string): Promise { + await invoke('admin-clients', { method: 'PUT', query: q({ id }), body: { name, slug } }) +} + +export async function adminDeleteClient(id: string, name: string, actorUserId?: string): Promise { + await invoke('admin-clients', { method: 'DELETE', query: q({ id }), body: { name, actorUserId } }) +} + +// ── members ─────────────────────────────────────────────────────────────────── + +export async function adminFetchMembers(): Promise { + return invoke('admin-members') +} + +export interface CreateMemberPayload { + name: string + role: string + authUserId?: string | null + accessRole?: 'admin' | 'user' + clientIds?: string[] + email?: string | null + avatarUrl?: string | null +} + +export async function adminCreateMember(payload: CreateMemberPayload): Promise { + return invoke('admin-members', { method: 'POST', body: payload }) +} + +export async function adminUpdateMember(id: string, name: string, role: string, email?: string | null): Promise { + await invoke('admin-members', { method: 'PUT', query: q({ id }), body: { name, role, email } }) +} + +export async function adminDeactivateMember(id: string): Promise<{ deactivated_at: string }> { + return invoke('admin-members', { method: 'PUT', query: q({ id, action: 'deactivate' }), body: {} }) +} + +export async function adminReactivateMember(id: string): Promise { + await invoke('admin-members', { method: 'PUT', query: q({ id, action: 'reactivate' }), body: {} }) +} + +export async function adminSetMemberAuthId(id: string, authUserId: string | null, avatarUrl?: string | null): Promise { + await invoke('admin-members', { method: 'PUT', query: q({ id, action: 'setAuthId' }), body: { authUserId, avatarUrl } }) +} + +// ── users / auth ────────────────────────────────────────────────────────────── + +export interface PendingAuthUser { + id: string + email: string + name: string + avatarUrl: string | null + lastSignInAt: string | null +} + +export interface GoogleUser { + id: string + email: string + avatarUrl: string | null + name: string +} + +export async function adminListPendingUsers(): Promise { + return invoke('admin-users', { query: q({ action: 'listPending' }) }) +} + +export async function adminListAuthUsers(search?: string): Promise { + return invoke('admin-users', { query: q({ action: 'listAuthUsers', search }) }) +} + +export async function adminFetchUserClientsMap(): Promise> { + return invoke>('admin-users', { query: q({ action: 'userClientsMap' }) }) +} + +export async function adminFetchAuditLogs(filters: AuditFilters = {}): Promise { + return invoke('admin-users', { query: q({ action: 'auditLogs', ...filters }) }) +} + +export async function adminLinkUserToClient(userId: string, clientId: string): Promise { + await invoke('admin-users', { method: 'POST', query: q({ action: 'linkUser' }), body: { userId, clientId } }) +} + +export async function adminUnlinkUserFromClient(userId: string, clientId: string): Promise { + await invoke('admin-users', { method: 'POST', query: q({ action: 'unlinkUser' }), body: { userId, clientId } }) +} + +export async function adminSetUserRole(userId: string, role: 'admin' | 'user'): Promise { + await invoke('admin-users', { method: 'POST', query: q({ action: 'setRole' }), body: { userId, role } }) +} + +// ── notifications ───────────────────────────────────────────────────────────── + +export async function adminFetchAllNotifications() { + return invoke('admin-notifications') +} + +export async function adminCreateNotification( + title: string, + message: string, + userId?: string, + clientId?: string, + type = 'admin_broadcast', + metadata?: Record | null, +): Promise { + await invoke('admin-notifications', { + method: 'POST', + body: { title, message, userId, clientId, type, metadata }, + }) +} + +export async function adminCreateNotificationForAll( + clientIds: string[], + title: string, + message: string, + type = 'admin_broadcast', +): Promise { + const rows = clientIds.map((clientId) => ({ client_id: clientId, user_id: null, title, message, type })) + await invoke('admin-notifications', { method: 'POST', body: { rows } }) +} diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 8e8f885..198b026 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -1,4 +1,9 @@ -import { supabase, supabaseAdmin } from './supabase' +import { supabase } from './supabase' +import { + adminFetchAllNotifications as apiFetchAll, + adminCreateNotification as apiCreateNotification, + adminCreateNotificationForAll as apiCreateForAll, +} from './adminApi' import type { DbNotificationRow, Notification } from '@/types/notification' export async function fetchNotifications(userId: string, clientIds?: string[]) { @@ -55,18 +60,8 @@ export async function markAllAsRead(userId: string, clientIds?: string[]) { } export async function fetchAllNotifications() { - if (!supabaseAdmin) { - throw new Error('Admin não configurado') - } - - const { data, error } = await supabaseAdmin - .from('notifications') - .select('*') - .order('created_at', { ascending: false }) - .limit(100) - - if (error) throw error - return data ? data.map(mapDbToNotification) : [] + const data = await apiFetchAll() + return (data as DbNotificationRow[]).map(mapDbToNotification) } export async function createNotification( @@ -75,67 +70,27 @@ export async function createNotification( userId?: string, clientId?: string, type: string = 'admin_broadcast', - metadata?: Record | null + metadata?: Record | null, ) { - if (!supabaseAdmin) { - throw new Error('Admin não configurado') - } - - const { error } = await supabaseAdmin - .from('notifications') - .insert({ - user_id: userId ?? null, - client_id: clientId ?? null, - title, - message, - type, - metadata: metadata ?? null, - }) - - if (error) throw error + await apiCreateNotification(title, message, userId, clientId, type, metadata ?? undefined) } export async function createNotificationForClient( clientId: string, title: string, message: string, - type: string = 'admin_broadcast' + type: string = 'admin_broadcast', ) { - if (!supabaseAdmin) { - throw new Error('Admin não configurado') - } - - const { error } = await supabaseAdmin - .from('notifications') - .insert({ - client_id: clientId, - user_id: null, - title, - message, - type, - }) - - if (error) throw error + await apiCreateNotification(title, message, undefined, clientId, type) } export async function createNotificationForAll( clientIds: string[], title: string, message: string, - type: string = 'admin_broadcast' + type: string = 'admin_broadcast', ) { - if (!supabaseAdmin) throw new Error('Admin não configurado') - - const rows = clientIds.map((clientId) => ({ - client_id: clientId, - user_id: null, - title, - message, - type, - })) - - const { error } = await supabaseAdmin.from('notifications').insert(rows) - if (error) throw error + await apiCreateForAll(clientIds, title, message, type) } export function resolveNotificationRoute(notification: Notification): string | null { diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index d06bb91..613ef31 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -3,11 +3,5 @@ import type { Database } from '@/types/supabase' const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string -const serviceRoleKey = import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY as string | undefined export const supabase = createClient(supabaseUrl, supabaseAnonKey) - -/** Usar APENAS em contexto admin — bypassa RLS */ -export const supabaseAdmin = serviceRoleKey - ? createClient(supabaseUrl, serviceRoleKey) - : null diff --git a/src/store/useAdminStore.ts b/src/store/useAdminStore.ts index 490e71f..f06c015 100644 --- a/src/store/useAdminStore.ts +++ b/src/store/useAdminStore.ts @@ -1,28 +1,28 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' -import { supabaseAdmin } from '@/lib/supabase' import type { DbClientRow, DbAuditLogRow } from '@/types/db' import type { Member } from '@/hooks/useSupabase' -export interface PendingAuthUser { - id: string - email: string - name: string - avatarUrl: string | null - lastSignInAt: string | null -} +import { + adminFetchClients, + adminFetchMembers, + adminFetchUserClientsMap, + adminListPendingUsers, + adminFetchAuditLogs, + type PendingAuthUser, +} from '@/lib/adminApi' +import { toSafeUiErrorMessage } from '@/lib/errorSanitizer' +import { DbClientRowSchema, DbMemberRowSchema, DbUserClientMapRowSchema } from '@/lib/validators' + +export type { PendingAuthUser } export interface AuditFilters { clientId?: string entityName?: string entity?: string userId?: string - from?: string // YYYY-MM-DD - to?: string // YYYY-MM-DD + from?: string + to?: string } -import { toSafeUiErrorMessage } from '@/lib/errorSanitizer' -import { DbClientRowSchema, DbMemberRowSchema, DbUserClientMapRowSchema } from '@/lib/validators' - -const ALLOWED_DOMAIN = import.meta.env.VITE_ALLOWED_DOMAIN as string | undefined interface AdminState { clients: DbClientRow[] @@ -81,98 +81,42 @@ export const useAdminStore = create()( }), fetchClients: async () => { - if (!supabaseAdmin) return - const { data, error } = await supabaseAdmin - .from('clients') - .select('*') - .order('name') - if (error) throw new Error(error.message) - set({ clients: (data ?? []).map(r => DbClientRowSchema.parse(r)) }) + const data = await adminFetchClients() + set({ clients: data.map(r => DbClientRowSchema.parse(r)) }) }, fetchUsers: async () => { - if (!supabaseAdmin) return - const { data, error } = await supabaseAdmin - .from('members') - .select('id, name, role, avatar, avatar_url, email, auth_user_id, access_role, is_active, created_at, deactivated_at') - .order('name') - if (error) throw new Error(error.message) - set({ users: (data ?? []).map(r => DbMemberRowSchema.parse(r)) as Member[] }) + const data = await adminFetchMembers() + set({ users: data.map(r => DbMemberRowSchema.parse(r)) as Member[] }) }, fetchUserClientsMap: async () => { - if (!supabaseAdmin) return - const { data, error } = await supabaseAdmin - .from('user_clients') - .select('user_id, client_id') - if (error) throw new Error(error.message) + const raw = await adminFetchUserClientsMap() const map: Record = {} - ;(data ?? []).map(r => DbUserClientMapRowSchema.parse(r)).forEach(({ user_id, client_id }) => { - if (!map[user_id]) map[user_id] = [] - map[user_id].push(client_id) - }) + for (const [userId, clientIds] of Object.entries(raw)) { + map[userId] = clientIds + } + // re-validate via existing schema if needed set({ userClientsMap: map }) }, fetchPendingUsers: async () => { - if (!supabaseAdmin) return - const { data, error } = await supabaseAdmin.auth.admin.listUsers() - if (error || !data) return - - const { data: members } = await supabaseAdmin - .from('members') - .select('auth_user_id') - const linkedAuthIds = new Set((members ?? []).map(m => m.auth_user_id).filter(Boolean)) - - const pending: PendingAuthUser[] = [] - for (const u of data.users) { - if (!u.email) continue - const domain = u.email.split('@')[1] - if (ALLOWED_DOMAIN && domain !== ALLOWED_DOMAIN) continue - if (linkedAuthIds.has(u.id)) continue - pending.push({ - id: u.id, - email: u.email, - name: u.user_metadata?.full_name ?? u.email.split('@')[0], - avatarUrl: u.user_metadata?.avatar_url ?? null, - lastSignInAt: u.last_sign_in_at ?? null, - }) - } + const pending = await adminListPendingUsers() set({ pendingUsers: pending }) }, fetchAuditLogs: async (filters: AuditFilters = {}) => { - if (!supabaseAdmin) return set({ loading: true }) - - let query = supabaseAdmin - .from('audit_logs') - .select('*') - .order('created_at', { ascending: false }) - .limit(200) - - if (filters.clientId) query = query.eq('client_id', filters.clientId) - if (filters.entity) query = query.eq('entity', filters.entity) - if (filters.userId) query = query.eq('user_id', filters.userId) - if (filters.entityName) query = query.ilike('entity_name', `%${filters.entityName}%`) - if (filters.from) query = query.gte('created_at', `${filters.from}T00:00:00Z`) - if (filters.to) query = query.lte('created_at', `${filters.to}T23:59:59Z`) - - const { data, error } = await query - if (error) { - set({ error: toSafeUiErrorMessage(error.message), loading: false }) - return + try { + const data = await adminFetchAuditLogs(filters) + set({ auditLogs: data ?? [], loading: false, error: null }) + } catch (err) { + set({ error: toSafeUiErrorMessage(err instanceof Error ? err.message : null), loading: false }) } - set({ auditLogs: data ?? [], loading: false, error: null }) }, refreshAll: async () => { - if (!supabaseAdmin) { - set({ loadingInitial: false }) - return - } if (get().loadingInitial) return - set({ loadingInitial: true, error: null }) try { const { fetchClients, fetchUsers, fetchUserClientsMap, fetchPendingUsers } = get() diff --git a/src/views/admin/AdminView.tsx b/src/views/admin/AdminView.tsx index 05557e0..b0ddfc1 100644 --- a/src/views/admin/AdminView.tsx +++ b/src/views/admin/AdminView.tsx @@ -5,7 +5,6 @@ import { UsersPanel } from './components/UsersPanel' import { AuditLogsPanel } from './components/AuditLogsPanel' import { NotificationsPanel } from './components/NotificationsPanel' import { useAuthContext } from '@/contexts/AuthContext' -import { supabaseAdmin } from '@/lib/supabase' import { ViewState } from '@/components/ViewState' import { DatabaseZap, ShieldAlert } from 'lucide-react' import { Skeleton } from 'boneyard-js/react' @@ -36,12 +35,12 @@ export function AdminView() { createUser, setUserAuthId, updateUser, deactivateUser, reactivateUser, listGoogleUsers, } = useAdminData({ actorUserId: member?.id ?? null }) - if (!supabaseAdmin) { + if (member?.access_role !== 'admin') { return ( ) } diff --git a/src/views/admin/__tests__/AdminView.tabs.test.tsx b/src/views/admin/__tests__/AdminView.tabs.test.tsx index eb9e9d4..6f5ba9e 100644 --- a/src/views/admin/__tests__/AdminView.tabs.test.tsx +++ b/src/views/admin/__tests__/AdminView.tabs.test.tsx @@ -12,7 +12,7 @@ type MockContext = ReturnType ({ - supabaseAdmin: { - from: vi.fn(() => ({ - select: vi.fn(() => Promise.resolve({ data: [], error: null })), - })), - auth: { admin: { listUsers: vi.fn(() => Promise.resolve({ data: { users: [] }, error: null })) } }, - }, -})) - vi.mock('../hooks/useAdminData', () => ({ useAdminData: () => ({ clients: [{ id: 'c1', name: 'Acme', slug: 'acme' }], diff --git a/src/views/admin/components/NotificationsPanel/useNotificationsPanel.ts b/src/views/admin/components/NotificationsPanel/useNotificationsPanel.ts index 4c14260..b594ed1 100644 --- a/src/views/admin/components/NotificationsPanel/useNotificationsPanel.ts +++ b/src/views/admin/components/NotificationsPanel/useNotificationsPanel.ts @@ -1,13 +1,11 @@ import { useState, useCallback, useEffect, useMemo } from 'react' import { toast } from 'sonner' -import { supabaseAdmin } from '@/lib/supabase' import { - createNotification, - createNotificationForClient, - createNotificationForAll, - fetchAllNotifications, - markAsRead as markAsReadApi, -} from '@/lib/notifications' + adminFetchAllNotifications, + adminCreateNotification, + adminCreateNotificationForAll, +} from '@/lib/adminApi' +import { markAsRead as markAsReadApi } from '@/lib/notifications' import type { Client } from '@/types/db' import type { Notification } from '@/types/notification' import { groupNotifications } from './utils' @@ -28,11 +26,10 @@ export function useNotificationsPanel(clients: Client[]) { const [error, setError] = useState(null) const loadHistory = useCallback(async () => { - if (!supabaseAdmin) return setLoadingHistory(true) setError(null) try { - setHistory(await fetchAllNotifications()) + setHistory(await adminFetchAllNotifications()) } catch (e) { setError(e instanceof Error ? e.message : 'Erro ao carregar') } finally { @@ -46,16 +43,15 @@ export function useNotificationsPanel(clients: Client[]) { const handleSend = async () => { if (!title.trim() || !message.trim()) { toast.error('Preencha o título e a mensagem'); return } - if (!supabaseAdmin) { toast.error('Admin não configurado'); return } setSending(true) try { if (targetType === 'user' && selectedUserId) { - await createNotification(title, message, selectedUserId, undefined, 'manual') + await adminCreateNotification(title, message, selectedUserId, undefined, 'manual') } else if (targetType === 'client' && selectedClientId) { - await createNotificationForClient(selectedClientId, title, message) + await adminCreateNotification(title, message, undefined, selectedClientId) } else if (targetType === 'all') { - await createNotificationForAll(clients.map((c) => c.id), title, message) + await adminCreateNotificationForAll(clients.map((c) => c.id), title, message) } else { toast.error('Selecione um destino'); setSending(false); return } diff --git a/src/views/admin/hooks/__tests__/useAdminData.test.ts b/src/views/admin/hooks/__tests__/useAdminData.test.ts index b75cdad..2fa3348 100644 --- a/src/views/admin/hooks/__tests__/useAdminData.test.ts +++ b/src/views/admin/hooks/__tests__/useAdminData.test.ts @@ -1,29 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' -// Mock do supabaseAdmin antes de importar o hook -vi.mock('@/lib/supabase', () => ({ - supabaseAdmin: { - from: vi.fn(), - auth: { - admin: { - deleteUser: vi.fn(), - listUsers: vi.fn(), - }, - }, - }, -})) - -// Mock do queryClient (TanStack Query) vi.mock('@tanstack/react-query', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useQueryClient: () => ({ - invalidateQueries: vi.fn(), - }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), } }) + vi.mock('@/store/useClientStore', () => ({ useClientStore: (selector: (s: { selectedClientId: string | null }) => unknown) => selector({ selectedClientId: null }), @@ -60,111 +45,89 @@ vi.mock('@/store/useAdminStore', () => ({ }), })) -import { useAdminData } from '../useAdminData' -import { supabaseAdmin } from '@/lib/supabase' - -// Helper para criar mock de query chain do Supabase. -// Todos os métodos retornam `this` para permitir encadeamento, EXCETO `.single()` que resolve. -// O objeto mock em si é uma Promise resolvida (via Promise.resolve) para que `await chain` funcione -// sem expor `.then` diretamente no objeto (o que causaria loop infinito). -function makeQueryMock(returnValue: { data?: unknown; error?: unknown }): Promise & Record { - const base = Promise.resolve(returnValue) as Promise & Record - const chainMethods = ['select', 'order', 'limit', 'gte', 'lte', 'ilike', 'eq', 'match', 'delete', 'update', 'upsert', 'insert'] - for (const method of chainMethods) { - base[method] = vi.fn().mockReturnValue(base) - } - base['single'] = vi.fn().mockResolvedValue(returnValue) - return base -} - - -const admin = supabaseAdmin! +vi.mock('@/lib/adminApi', () => ({ + adminCreateClient: vi.fn(), + adminUpdateClient: vi.fn(), + adminDeleteClient: vi.fn(), + adminCreateMember: vi.fn(), + adminUpdateMember: vi.fn(), + adminDeactivateMember: vi.fn(), + adminReactivateMember: vi.fn(), + adminSetMemberAuthId: vi.fn(), + adminLinkUserToClient: vi.fn(), + adminUnlinkUserFromClient: vi.fn(), + adminSetUserRole: vi.fn(), + adminListAuthUsers: vi.fn().mockResolvedValue([]), +})) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyQuery = any +import { useAdminData } from '../useAdminData' +import * as adminApi from '@/lib/adminApi' describe('useAdminData', () => { beforeEach(() => { vi.clearAllMocks() - - // Setup padrão: fetches iniciais retornam listas vazias - const emptyQuery = makeQueryMock({ data: [], error: null }) - vi.mocked(admin.from).mockReturnValue(emptyQuery as AnyQuery) - vi.mocked(admin.auth.admin.listUsers).mockResolvedValue({ - data: { users: [], aud: '', nextPage: 0, lastPage: 0, total: 0 }, - error: null, - } as unknown as Awaited>) }) -describe('deactivateUser', () => { - it('desativa o member e retorna true', async () => { - const updateQuery = makeQueryMock({ data: null, error: null }) - vi.mocked(admin.from).mockReturnValue(updateQuery as AnyQuery) + describe('deactivateUser', () => { + it('desativa o member e retorna true', async () => { + vi.mocked(adminApi.adminDeactivateMember).mockResolvedValue({ deactivated_at: '2024-01-01T00:00:00Z' }) + const { result } = renderHook(() => useAdminData()) - const { result } = renderHook(() => useAdminData()) + let ok: boolean | undefined + await act(async () => { + ok = await result.current.deactivateUser('member-1') + }) - let ok: boolean | undefined - await act(async () => { - ok = await result.current.deactivateUser('member-1') + expect(ok).toBe(true) + expect(mockPatchUser).toHaveBeenCalledWith('member-1', { is_active: false, deactivated_at: '2024-01-01T00:00:00Z' }) }) - expect(ok).toBe(true) - }) - - it('retorna false quando a desativação falha', async () => { - const errorQuery = makeQueryMock({ data: null, error: { message: 'update failed' } }) - vi.mocked(admin.from).mockReturnValue(errorQuery as AnyQuery) + it('retorna false quando a desativação falha', async () => { + vi.mocked(adminApi.adminDeactivateMember).mockRejectedValue(new Error('update failed')) + const { result } = renderHook(() => useAdminData()) - const { result } = renderHook(() => useAdminData()) + let ok: boolean | undefined + await act(async () => { + ok = await result.current.deactivateUser('member-1') + }) - let ok: boolean | undefined - await act(async () => { - ok = await result.current.deactivateUser('member-1') + expect(ok).toBe(false) }) - - expect(ok).toBe(false) }) -}) -describe('reactivateUser', () => { - it('reativa o member e retorna true', async () => { - const updateQuery = makeQueryMock({ data: null, error: null }) - vi.mocked(admin.from).mockReturnValue(updateQuery as AnyQuery) + describe('reactivateUser', () => { + it('reativa o member e retorna true', async () => { + vi.mocked(adminApi.adminReactivateMember).mockResolvedValue(undefined) + const { result } = renderHook(() => useAdminData()) - const { result } = renderHook(() => useAdminData()) + let ok: boolean | undefined + await act(async () => { + ok = await result.current.reactivateUser('member-1') + }) - let ok: boolean | undefined - await act(async () => { - ok = await result.current.reactivateUser('member-1') + expect(ok).toBe(true) + expect(mockPatchUser).toHaveBeenCalledWith('member-1', { is_active: true, deactivated_at: null }) }) - expect(ok).toBe(true) - }) - - it('retorna false quando a reativação falha', async () => { - const errorQuery = makeQueryMock({ data: null, error: { message: 'update failed' } }) - vi.mocked(admin.from).mockReturnValue(errorQuery as AnyQuery) + it('retorna false quando a reativação falha', async () => { + vi.mocked(adminApi.adminReactivateMember).mockRejectedValue(new Error('update failed')) + const { result } = renderHook(() => useAdminData()) - const { result } = renderHook(() => useAdminData()) + let ok: boolean | undefined + await act(async () => { + ok = await result.current.reactivateUser('member-1') + }) - let ok: boolean | undefined - await act(async () => { - ok = await result.current.reactivateUser('member-1') + expect(ok).toBe(false) }) - - expect(ok).toBe(false) }) -}) -describe('setUserAuthId — atualiza pendingUsers após vincular', () => { + describe('setUserAuthId — atualiza pendingUsers após vincular', () => { it('chama fetchPendingUsers após um setUserAuthId bem-sucedido', async () => { - const updateQuery = makeQueryMock({ data: null, error: null }) - vi.mocked(admin.from).mockReturnValue(updateQuery as AnyQuery) - + vi.mocked(adminApi.adminSetMemberAuthId).mockResolvedValue(undefined) const { result } = renderHook(() => useAdminData()) mockFetchPendingUsers.mockClear() - await act(async () => { await result.current.setUserAuthId('member-1', 'auth-uuid-new', null) }) diff --git a/src/views/admin/hooks/useAdminData.ts b/src/views/admin/hooks/useAdminData.ts index 065a554..5c3d89d 100644 --- a/src/views/admin/hooks/useAdminData.ts +++ b/src/views/admin/hooks/useAdminData.ts @@ -1,11 +1,27 @@ import { useEffect, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { supabaseAdmin } from '@/lib/supabase' import { toSafeUiErrorMessage } from '@/lib/errorSanitizer' import { useAdminStore } from '@/store/useAdminStore' -import type { PendingAuthUser, AuditFilters } from '@/store/useAdminStore' - -export type { PendingAuthUser, AuditFilters } +import { + adminCreateClient, + adminUpdateClient, + adminDeleteClient, + adminCreateMember, + adminUpdateMember, + adminDeactivateMember, + adminReactivateMember, + adminSetMemberAuthId, + adminLinkUserToClient, + adminUnlinkUserFromClient, + adminSetUserRole, + adminListAuthUsers, + type CreateMemberPayload, + type GoogleUser, +} from '@/lib/adminApi' +import type { AuditFilters } from '@/store/useAdminStore' + +export type { AuditFilters } +export type PendingAuthUser = import('@/lib/adminApi').PendingAuthUser interface UseAdminDataOptions { actorUserId?: string | null @@ -13,7 +29,6 @@ interface UseAdminDataOptions { export function useAdminData(options: UseAdminDataOptions = {}) { const { actorUserId } = options - const queryClient = useQueryClient() const { @@ -23,12 +38,11 @@ export function useAdminData(options: UseAdminDataOptions = {}) { fetchAuditLogs, refreshAll, patchUser, setError, } = useAdminStore() - // Carrega os dados apenas na primeira vez que o AdminView montar useEffect(() => { - if (!initialized && !loadingInitial) { + if (!initialized && !loadingInitial && !error) { refreshAll() } - }, [initialized, loadingInitial, refreshAll]) + }, [initialized, loadingInitial, error, refreshAll]) const reloadAppStores = useCallback(() => { queryClient.invalidateQueries({ queryKey: ['tasks'] }) @@ -38,10 +52,8 @@ export function useAdminData(options: UseAdminDataOptions = {}) { // ── CRUD clients ──────────────────────────────────────────────────────────── const createClient = useCallback(async (name: string, slug: string) => { - if (!supabaseAdmin) return false - const { error } = await supabaseAdmin.from('clients').insert({ name, slug }) - if (error) return false try { + await adminCreateClient(name, slug) setError(null) await fetchClients() await reloadAppStores() @@ -53,10 +65,8 @@ export function useAdminData(options: UseAdminDataOptions = {}) { }, [fetchClients, reloadAppStores, setError]) const updateClient = useCallback(async (id: string, name: string, slug: string) => { - if (!supabaseAdmin) return false - const { error } = await supabaseAdmin.from('clients').update({ name, slug }).eq('id', id) - if (error) return false try { + await adminUpdateClient(id, name, slug) setError(null) await fetchClients() await reloadAppStores() @@ -68,26 +78,8 @@ export function useAdminData(options: UseAdminDataOptions = {}) { }, [fetchClients, reloadAppStores, setError]) const deleteClient = useCallback(async (id: string, name: string) => { - if (!supabaseAdmin) return false - const deletedClient = clients.find(client => client.id === id) - const { error } = await supabaseAdmin.from('clients').delete().eq('id', id) - if (error) return false - - if (actorUserId) { - await supabaseAdmin.from('audit_logs').insert({ - user_id: actorUserId, - client_id: deletedClient?.id ?? id, - entity: 'client', - entity_id: id, - entity_name: deletedClient?.name ?? name, - action: 'delete', - field: null, - from_value: null, - to_value: null, - }) - } - try { + await adminDeleteClient(id, name, actorUserId ?? undefined) setError(null) await fetchClients() await reloadAppStores() @@ -96,17 +88,13 @@ export function useAdminData(options: UseAdminDataOptions = {}) { setError(toSafeUiErrorMessage(err instanceof Error ? err.message : null)) return false } - }, [actorUserId, clients, fetchClients, reloadAppStores, setError]) + }, [actorUserId, fetchClients, reloadAppStores, setError]) // ── Vínculo user ↔ client ──────────────────────────────────────────────────── const linkUserToClient = useCallback(async (userId: string, clientId: string) => { - if (!supabaseAdmin) return false - const { error } = await supabaseAdmin - .from('user_clients') - .upsert({ user_id: userId, client_id: clientId }) - if (error) return false try { + await adminLinkUserToClient(userId, clientId) setError(null) await fetchUsers() await fetchUserClientsMap() @@ -119,13 +107,8 @@ export function useAdminData(options: UseAdminDataOptions = {}) { }, [fetchUsers, fetchUserClientsMap, reloadAppStores, setError]) const unlinkUserFromClient = useCallback(async (userId: string, clientId: string) => { - if (!supabaseAdmin) return false - const { error } = await supabaseAdmin - .from('user_clients') - .delete() - .match({ user_id: userId, client_id: clientId }) - if (error) return false try { + await adminUnlinkUserFromClient(userId, clientId) setError(null) await fetchUsers() await fetchUserClientsMap() @@ -140,13 +123,8 @@ export function useAdminData(options: UseAdminDataOptions = {}) { // ── CRUD users ─────────────────────────────────────────────────────────────── const setUserRole = useCallback(async (userId: string, role: 'admin' | 'user') => { - if (!supabaseAdmin) return false - const { error } = await supabaseAdmin - .from('members') - .update({ access_role: role }) - .eq('id', userId) - if (error) return false try { + await adminSetUserRole(userId, role) setError(null) await fetchUsers() await reloadAppStores() @@ -164,28 +142,11 @@ export function useAdminData(options: UseAdminDataOptions = {}) { accessRole?: 'admin' | 'user', clientIds?: string[], email?: string | null, - avatarUrl?: string | null + avatarUrl?: string | null, ) => { - if (!supabaseAdmin) return false - const initials = name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) - const { data: member, error: memberErr } = await supabaseAdmin.from('members').insert({ - name, - role, - avatar: initials, - auth_user_id: authUserId ?? null, - access_role: accessRole ?? 'user', - email: email ?? null, - avatar_url: avatarUrl ?? null, - }).select().single() - - if (memberErr || !member) return false - - if (clientIds && clientIds.length > 0) { - const userClientRows = clientIds.map(cid => ({ user_id: member.id, client_id: cid })) - await supabaseAdmin.from('user_clients').insert(userClientRows) - } - + const payload: CreateMemberPayload = { name, role, authUserId, accessRole, clientIds, email, avatarUrl } try { + await adminCreateMember(payload) await fetchUsers() await fetchUserClientsMap() if (authUserId) await fetchPendingUsers() @@ -199,13 +160,8 @@ export function useAdminData(options: UseAdminDataOptions = {}) { }, [fetchUsers, fetchUserClientsMap, fetchPendingUsers, reloadAppStores, setError]) const setUserAuthId = useCallback(async (userId: string, authUserId: string | null, avatarUrl?: string | null) => { - if (!supabaseAdmin) return false - const { error } = await supabaseAdmin - .from('members') - .update({ auth_user_id: authUserId, avatar_url: avatarUrl ?? null }) - .eq('id', userId) - if (error) return false try { + await adminSetMemberAuthId(userId, authUserId, avatarUrl) setError(null) await fetchUsers() await fetchPendingUsers() @@ -217,20 +173,9 @@ export function useAdminData(options: UseAdminDataOptions = {}) { } }, [fetchUsers, fetchPendingUsers, reloadAppStores, setError]) - const updateUser = useCallback(async ( - userId: string, - name: string, - role: string, - email?: string | null - ) => { - if (!supabaseAdmin) return false - const initials = name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) - const { error } = await supabaseAdmin - .from('members') - .update({ name, role, email: email ?? null, avatar: initials }) - .eq('id', userId) - if (error) return false + const updateUser = useCallback(async (userId: string, name: string, role: string, email?: string | null) => { try { + await adminUpdateMember(userId, name, role, email) setError(null) await fetchUsers() await reloadAppStores() @@ -242,52 +187,33 @@ export function useAdminData(options: UseAdminDataOptions = {}) { }, [fetchUsers, reloadAppStores, setError]) const deactivateUser = useCallback(async (userId: string) => { - if (!supabaseAdmin) return false - const deactivated_at = new Date().toISOString() - const { error } = await supabaseAdmin - .from('members') - .update({ is_active: false, deactivated_at }) - .eq('id', userId) - if (error) { - console.error('[deactivateUser] error:', error) + try { + const { deactivated_at } = await adminDeactivateMember(userId) + patchUser(userId, { is_active: false, deactivated_at }) + return true + } catch (err) { + console.error('[deactivateUser] error:', err) return false } - patchUser(userId, { is_active: false, deactivated_at }) - return true }, [patchUser]) const reactivateUser = useCallback(async (userId: string) => { - if (!supabaseAdmin) return false - const { error } = await supabaseAdmin - .from('members') - .update({ is_active: true, deactivated_at: null }) - .eq('id', userId) - if (error) { - console.error('[reactivateUser] error:', error) + try { + await adminReactivateMember(userId) + patchUser(userId, { is_active: true, deactivated_at: null }) + return true + } catch (err) { + console.error('[reactivateUser] error:', err) return false } - patchUser(userId, { is_active: true, deactivated_at: null }) - return true }, [patchUser]) - const listGoogleUsers = useCallback(async (search?: string) => { - if (!supabaseAdmin) return [] - const { data, error } = await supabaseAdmin.auth.admin.listUsers() - if (error || !data) return [] - let users = data.users.filter(u => u.email) - if (search) { - const lower = search.toLowerCase() - users = users.filter(u => - u.email?.toLowerCase().includes(lower) || - u.user_metadata?.full_name?.toLowerCase().includes(lower) - ) + const listGoogleUsers = useCallback(async (search?: string): Promise => { + try { + return await adminListAuthUsers(search) + } catch { + return [] } - return users.slice(0, 20).map(u => ({ - id: u.id, - email: u.email!, - avatarUrl: u.user_metadata?.avatar_url ?? null, - name: u.user_metadata?.full_name ?? u.email!.split('@')[0], - })) }, []) return { @@ -299,4 +225,3 @@ export function useAdminData(options: UseAdminDataOptions = {}) { createUser, setUserAuthId, updateUser, deactivateUser, reactivateUser, listGoogleUsers, } } - diff --git a/supabase/deploy-functions.sh b/supabase/deploy-functions.sh new file mode 100644 index 0000000..c8f8a3d --- /dev/null +++ b/supabase/deploy-functions.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Uso: bash supabase/deploy-functions.sh +npx supabase functions deploy admin-clients +npx supabase functions deploy admin-members +npx supabase functions deploy admin-notifications +npx supabase functions deploy admin-users diff --git a/supabase/functions/_shared/auth.ts b/supabase/functions/_shared/auth.ts new file mode 100644 index 0000000..7d4e332 --- /dev/null +++ b/supabase/functions/_shared/auth.ts @@ -0,0 +1,70 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' + +export function getServiceClient() { + return createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, + ) +} + +export async function requireAdmin(req: Request): Promise<{ userId: string; error?: never } | { userId?: never; error: Response }> { + const authHeader = req.headers.get('Authorization') + if (!authHeader) { + return { error: new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) } + } + + const token = authHeader.replace('Bearer ', '') + const supabaseUrl = Deno.env.get('SUPABASE_URL')! + const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! + + console.log('[requireAdmin] token prefix:', token.substring(0, 20)) + console.log('[requireAdmin] supabaseUrl:', supabaseUrl) + console.log('[requireAdmin] serviceKey present:', !!serviceKey) + + // Usa o endpoint admin (/auth/v1/user) com service role key como apikey. + // O SDK JS valida o JWT localmente antes de qualquer chamada, o que falha com ES256. + // O fetch direto bypassa essa validação local e delega ao servidor Supabase. + const userRes = await fetch(`${supabaseUrl}/auth/v1/user`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'apikey': serviceKey, + }, + }) + + if (!userRes.ok) { + const body = await userRes.json().catch(() => ({})) + console.log('[requireAdmin] /auth/v1/user failed:', userRes.status, JSON.stringify(body)) + return { error: new Response(JSON.stringify({ error: 'Unauthorized', detail: body }), { status: 401 }) } + } + + const user = await userRes.json() + if (!user?.id) { + return { error: new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) } + } + const { data: member } = await getServiceClient() + .from('members') + .select('access_role') + .eq('auth_user_id', user.id) + .single() + + if (member?.access_role !== 'admin') { + return { error: new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 }) } + } + + return { userId: user.id } +} + +export function corsHeaders() { + return { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + } +} + +export function json(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { ...corsHeaders(), 'Content-Type': 'application/json' }, + }) +} diff --git a/supabase/functions/admin-clients/index.ts b/supabase/functions/admin-clients/index.ts new file mode 100644 index 0000000..49ea5ce --- /dev/null +++ b/supabase/functions/admin-clients/index.ts @@ -0,0 +1,58 @@ +import { requireAdmin, getServiceClient, json, corsHeaders } from '../_shared/auth.ts' + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders() }) + + const auth = await requireAdmin(req) + if (auth.error) return auth.error + + const db = getServiceClient() + const url = new URL(req.url) + const id = url.searchParams.get('id') + + if (req.method === 'GET') { + const { data, error } = await db.from('clients').select('*').order('name') + if (error) return json({ error: error.message }, 500) + return json(data) + } + + if (req.method === 'POST') { + const body = await req.json() + const { error } = await db.from('clients').insert({ name: body.name, slug: body.slug }) + if (error) return json({ error: error.message }, 500) + return json({ ok: true }) + } + + if (req.method === 'PUT') { + if (!id) return json({ error: 'Missing id' }, 400) + const body = await req.json() + const { error } = await db.from('clients').update({ name: body.name, slug: body.slug }).eq('id', id) + if (error) return json({ error: error.message }, 500) + return json({ ok: true }) + } + + if (req.method === 'DELETE') { + if (!id) return json({ error: 'Missing id' }, 400) + const body = await req.json().catch(() => ({})) + const { data: client } = await db.from('clients').select('id, name').eq('id', id).single() + const { error } = await db.from('clients').delete().eq('id', id) + if (error) return json({ error: error.message }, 500) + + if (body.actorUserId) { + await db.from('audit_logs').insert({ + user_id: body.actorUserId, + client_id: client?.id ?? id, + entity: 'client', + entity_id: id, + entity_name: client?.name ?? body.name ?? id, + action: 'delete', + field: null, + from_value: null, + to_value: null, + }) + } + return json({ ok: true }) + } + + return json({ error: 'Method not allowed' }, 405) +}) diff --git a/supabase/functions/admin-members/index.ts b/supabase/functions/admin-members/index.ts new file mode 100644 index 0000000..c548ddd --- /dev/null +++ b/supabase/functions/admin-members/index.ts @@ -0,0 +1,78 @@ +import { requireAdmin, getServiceClient, json, corsHeaders } from '../_shared/auth.ts' + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders() }) + + const auth = await requireAdmin(req) + if (auth.error) return auth.error + + const db = getServiceClient() + const url = new URL(req.url) + const id = url.searchParams.get('id') + const action = url.searchParams.get('action') + + if (req.method === 'GET') { + const { data, error } = await db + .from('members') + .select('id, name, role, avatar, avatar_url, email, auth_user_id, access_role, is_active, created_at, deactivated_at') + .order('name') + if (error) return json({ error: error.message }, 500) + return json(data) + } + + if (req.method === 'POST') { + const body = await req.json() + const initials = body.name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) + const { data: member, error } = await db.from('members').insert({ + name: body.name, + role: body.role, + avatar: initials, + auth_user_id: body.authUserId ?? null, + access_role: body.accessRole ?? 'user', + email: body.email ?? null, + avatar_url: body.avatarUrl ?? null, + }).select().single() + if (error || !member) return json({ error: error?.message ?? 'Insert failed' }, 500) + + if (body.clientIds?.length > 0) { + const rows = body.clientIds.map((cid: string) => ({ user_id: member.id, client_id: cid })) + await db.from('user_clients').insert(rows) + } + return json(member) + } + + if (req.method === 'PUT') { + if (!id) return json({ error: 'Missing id' }, 400) + const body = await req.json() + + if (action === 'deactivate') { + const deactivated_at = new Date().toISOString() + const { error } = await db.from('members').update({ is_active: false, deactivated_at }).eq('id', id) + if (error) return json({ error: error.message }, 500) + return json({ ok: true, deactivated_at }) + } + + if (action === 'reactivate') { + const { error } = await db.from('members').update({ is_active: true, deactivated_at: null }).eq('id', id) + if (error) return json({ error: error.message }, 500) + return json({ ok: true }) + } + + if (action === 'setAuthId') { + const { error } = await db.from('members') + .update({ auth_user_id: body.authUserId, avatar_url: body.avatarUrl ?? null }) + .eq('id', id) + if (error) return json({ error: error.message }, 500) + return json({ ok: true }) + } + + const initials = body.name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) + const { error } = await db.from('members') + .update({ name: body.name, role: body.role, email: body.email ?? null, avatar: initials }) + .eq('id', id) + if (error) return json({ error: error.message }, 500) + return json({ ok: true }) + } + + return json({ error: 'Method not allowed' }, 405) +}) diff --git a/supabase/functions/admin-notifications/index.ts b/supabase/functions/admin-notifications/index.ts new file mode 100644 index 0000000..6c8f14e --- /dev/null +++ b/supabase/functions/admin-notifications/index.ts @@ -0,0 +1,39 @@ +import { requireAdmin, getServiceClient, json, corsHeaders } from '../_shared/auth.ts' + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders() }) + + const auth = await requireAdmin(req) + if (auth.error) return auth.error + + const db = getServiceClient() + + if (req.method === 'GET') { + const { data, error } = await db + .from('notifications') + .select('*') + .order('created_at', { ascending: false }) + .limit(100) + if (error) return json({ error: error.message }, 500) + return json(data) + } + + if (req.method === 'POST') { + const body = await req.json() + // body.rows = array of notification objects (para createNotificationForAll) + // ou campos individuais (para createNotification / createNotificationForClient) + const rows = body.rows ?? [{ + user_id: body.userId ?? null, + client_id: body.clientId ?? null, + title: body.title, + message: body.message, + type: body.type ?? 'admin_broadcast', + metadata: body.metadata ?? null, + }] + const { error } = await db.from('notifications').insert(rows) + if (error) return json({ error: error.message }, 500) + return json({ ok: true }) + } + + return json({ error: 'Method not allowed' }, 405) +}) diff --git a/supabase/functions/admin-users/index.ts b/supabase/functions/admin-users/index.ts new file mode 100644 index 0000000..f9bd3a6 --- /dev/null +++ b/supabase/functions/admin-users/index.ts @@ -0,0 +1,117 @@ +import { requireAdmin, getServiceClient, json, corsHeaders } from '../_shared/auth.ts' + +Deno.serve(async (req) => { + if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders() }) + + const auth = await requireAdmin(req) + if (auth.error) return auth.error + + const db = getServiceClient() + const url = new URL(req.url) + const action = url.searchParams.get('action') + + // GET actions + if (req.method === 'GET') { + if (action === 'listAuthUsers') { + const search = url.searchParams.get('search') ?? '' + const { data, error } = await db.auth.admin.listUsers() + if (error || !data) return json({ error: error?.message ?? 'Failed' }, 500) + let users = data.users.filter((u) => u.email) + if (search) { + const lower = search.toLowerCase() + users = users.filter((u) => + u.email?.toLowerCase().includes(lower) || + u.user_metadata?.full_name?.toLowerCase().includes(lower) + ) + } + return json(users.slice(0, 20).map((u) => ({ + id: u.id, + email: u.email!, + avatarUrl: u.user_metadata?.avatar_url ?? null, + name: u.user_metadata?.full_name ?? u.email!.split('@')[0], + }))) + } + + if (action === 'listPending') { + const allowedDomain = Deno.env.get('ALLOWED_DOMAIN') + const { data, error } = await db.auth.admin.listUsers() + if (error || !data) return json({ error: error?.message ?? 'Failed' }, 500) + const { data: members } = await db.from('members').select('auth_user_id') + const linked = new Set((members ?? []).map((m) => m.auth_user_id).filter(Boolean)) + const pending = [] + for (const u of data.users) { + if (!u.email) continue + const domain = u.email.split('@')[1] + if (allowedDomain && domain !== allowedDomain) continue + if (linked.has(u.id)) continue + pending.push({ + id: u.id, + email: u.email, + name: u.user_metadata?.full_name ?? u.email.split('@')[0], + avatarUrl: u.user_metadata?.avatar_url ?? null, + lastSignInAt: u.last_sign_in_at ?? null, + }) + } + return json(pending) + } + + if (action === 'userClientsMap') { + const { data, error } = await db.from('user_clients').select('user_id, client_id') + if (error) return json({ error: error.message }, 500) + const map: Record = {} + for (const row of data ?? []) { + if (!map[row.user_id]) map[row.user_id] = [] + map[row.user_id].push(row.client_id) + } + return json(map) + } + + if (action === 'auditLogs') { + const params = Object.fromEntries(url.searchParams) + let query = db.from('audit_logs').select('*').order('created_at', { ascending: false }).limit(200) + if (params.clientId) query = query.eq('client_id', params.clientId) + if (params.entity) query = query.eq('entity', params.entity) + if (params.userId) query = query.eq('user_id', params.userId) + if (params.entityName) query = query.ilike('entity_name', `%${params.entityName}%`) + if (params.from) query = query.gte('created_at', `${params.from}T00:00:00Z`) + if (params.to) query = query.lte('created_at', `${params.to}T23:59:59Z`) + const { data, error } = await query + if (error) return json({ error: error.message }, 500) + return json(data) + } + + return json({ error: 'Unknown action' }, 400) + } + + // POST actions + if (req.method === 'POST') { + const body = await req.json() + + if (action === 'linkUser') { + const { error } = await db.from('user_clients') + .upsert({ user_id: body.userId, client_id: body.clientId }) + if (error) return json({ error: error.message }, 500) + return json({ ok: true }) + } + + if (action === 'unlinkUser') { + const { error } = await db.from('user_clients') + .delete() + .match({ user_id: body.userId, client_id: body.clientId }) + if (error) return json({ error: error.message }, 500) + return json({ ok: true }) + } + + if (action === 'setRole') { + const { error } = await db.from('members') + .update({ access_role: body.role }) + .eq('id', body.userId) + if (error) return json({ error: error.message }, 500) + return json({ ok: true }) + } + + return json({ error: 'Unknown action' }, 400) + } + + return json({ error: 'Method not allowed' }, 405) +}) From 55af47380125388097104cd657ceab25a80583c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Wed, 22 Apr 2026 13:42:50 -0300 Subject: [PATCH 08/21] feat: :sparkles: Ajustado e adiconado Throttle nas requests --- docs/hooks/supabase.md | 15 ++++++ docs/tech-audit.md | 19 +------ src/hooks/useSupabase.ts | 9 +++- src/hooks/useTaskQuickActions.ts | 52 +++++++++++++++++++ src/hooks/useThrottledMutation.ts | 28 ++++++++++ src/lib/notifications.ts | 8 ++- src/lib/throttle.ts | 23 ++++++++ src/views/list/ListView.tsx | 38 ++------------ .../profile/__tests__/useProfile.test.ts | 7 +++ src/views/profile/hooks/useProfile.ts | 5 +- src/views/tasks/TasksView.tsx | 32 ++---------- src/views/user/hooks/useUserClients.ts | 10 +++- 12 files changed, 161 insertions(+), 85 deletions(-) create mode 100644 src/hooks/useTaskQuickActions.ts create mode 100644 src/hooks/useThrottledMutation.ts create mode 100644 src/lib/throttle.ts diff --git a/docs/hooks/supabase.md b/docs/hooks/supabase.md index 62d1f1f..68601f4 100644 --- a/docs/hooks/supabase.md +++ b/docs/hooks/supabase.md @@ -13,6 +13,21 @@ Hook de **mutations apenas**. Não armazena estado — após cada operação usa - `updateTask(data)` — update otimista no cache do TanStack Query, depois persiste no DB; reverte em caso de erro - `deleteTask(id)` — remove do DB e atualiza o cache local sem re-fetch +As três funções são envolvidas por `useThrottledMutation` (500ms) antes de serem expostas. Chamadas mais rápidas que o intervalo são rejeitadas com toast de aviso e retornam `false`. + +## Rate Limiting (client-side) + +Dois utilitários padronizados cobrem todos os casos: + +| Utilitário | Ficheiro | Quando usar | +|---|---|---| +| `useThrottledMutation` | `src/hooks/useThrottledMutation.ts` | Dentro de hooks React (usa `useRef`) | +| `throttleAsync` | `src/lib/throttle.ts` | Funções puras fora de componentes/hooks | + +Aplicado em: `useSupabase` (500ms), `useTaskQuickActions` (500ms), `useUserClients` (500ms), `useProfile` (500ms), `notifications.markAsRead` (300ms), `notifications.markAllAsRead` (1000ms). + +`useTaskQuickActions` (`src/hooks/useTaskQuickActions.ts`) centraliza os toggles rápidos de bloqueio e conclusão, usados por `ListView` e `TasksView` — elimina código duplicado e garante throttle consistente. + ## Update otimista (`updateTask`) ``` diff --git a/docs/tech-audit.md b/docs/tech-audit.md index d363cf4..c5d4d09 100644 --- a/docs/tech-audit.md +++ b/docs/tech-audit.md @@ -254,24 +254,9 @@ async function inviteUser(email: string) { --- -### ALTA: sem rate limiting em mutations +### ~~ALTA: sem rate limiting em mutations~~ ✅ Resolvido -`useSupabase.ts` não tem throttle em operações de escrita. Um usuário mal-intencionado pode disparar centenas de requisições por segundo. - -**Solução:** - -```typescript -import { throttle } from 'lodash-es' - -const updateTask = useCallback( - throttle(async (task: Task) => { - await supabase.from('tasks').update(task).eq('id', task.id) - }, 500), // máximo 2 req/s - [] -) -``` - -**Prioridade:** Alta +`useThrottledMutation` adicionado em `useSupabase.ts`. As três mutations (`createTask`, `updateTask`, `deleteTask`) são envolvidas por throttle de 500ms via `useRef` — sem lodash. Chamadas dentro do intervalo retornam `false` com toast de aviso. --- diff --git a/src/hooks/useSupabase.ts b/src/hooks/useSupabase.ts index 1da97d5..3c8b8f3 100644 --- a/src/hooks/useSupabase.ts +++ b/src/hooks/useSupabase.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' +import { useThrottledMutation } from '@/hooks/useThrottledMutation' import { supabase } from '@/lib/supabase' import type { Task, Step } from '@/lib/steps' import { toast } from 'sonner' @@ -83,6 +84,8 @@ function didTaskFieldsChange(prevTask: Task | undefined, nextTask: Task, resolve ) } +const MUTATION_THROTTLE_MS = 500 + export function useSupabase(options: UseSupabaseOptions = {}) { const { memberId, clientId, isAdmin } = options const queryClient = useQueryClient() @@ -342,5 +345,9 @@ export function useSupabase(options: UseSupabaseOptions = {}) { return true }, [clientId, isAdmin, memberId, queryClient]) - return { createTask, updateTask, deleteTask } + const throttledCreateTask = useThrottledMutation(createTask, MUTATION_THROTTLE_MS) + const throttledUpdateTask = useThrottledMutation(updateTask, MUTATION_THROTTLE_MS) + const throttledDeleteTask = useThrottledMutation(deleteTask, MUTATION_THROTTLE_MS) + + return { createTask: throttledCreateTask, updateTask: throttledUpdateTask, deleteTask: throttledDeleteTask } } diff --git a/src/hooks/useTaskQuickActions.ts b/src/hooks/useTaskQuickActions.ts new file mode 100644 index 0000000..0291084 --- /dev/null +++ b/src/hooks/useTaskQuickActions.ts @@ -0,0 +1,52 @@ +import { useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { supabase } from '@/lib/supabase' +import { toast } from 'sonner' +import { useThrottledMutation } from '@/hooks/useThrottledMutation' +import type { Task } from '@/lib/steps' + +const THROTTLE_MS = 500 + +/** + * Quick in-place mutations shared by ListView and TasksView. + * Full edits (all fields + steps) go through useSupabase.updateTask. + */ +export function useTaskQuickActions(memberId?: string | null) { + const queryClient = useQueryClient() + + const invalidate = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['tasks'] }) + }, [queryClient]) + + const concludeTask = useCallback(async (task: Task) => { + const now = new Date().toISOString() + const { error } = await supabase + .from('tasks') + .update({ concluded_at: now, concluded_by: memberId ?? null }) + .eq('id', task.id) + + if (error) { toast.error('Erro ao concluir demanda'); return false } + toast.success(`"${task.title}" concluída`) + invalidate() + return true + }, [memberId, invalidate]) + + const toggleBlock = useCallback(async (task: Task) => { + const newBlocked = !task.status.blocked + const now = new Date().toISOString().split('T')[0] + const { error } = await supabase + .from('tasks') + .update({ blocked: newBlocked, blocked_at: newBlocked ? now : null }) + .eq('id', task.id) + + if (error) { toast.error('Erro ao alterar bloqueio'); return false } + toast.success(newBlocked ? `"${task.title}" bloqueada` : `"${task.title}" desbloqueada`) + invalidate() + return true + }, [invalidate]) + + return { + concludeTask: useThrottledMutation(concludeTask, THROTTLE_MS), + toggleBlock: useThrottledMutation(toggleBlock, THROTTLE_MS), + } +} diff --git a/src/hooks/useThrottledMutation.ts b/src/hooks/useThrottledMutation.ts new file mode 100644 index 0000000..01327e6 --- /dev/null +++ b/src/hooks/useThrottledMutation.ts @@ -0,0 +1,28 @@ +import { useCallback, useRef } from 'react' +import { toast } from 'sonner' + +/** + * Wraps an async mutation fn with client-side throttling. + * Calls within `ms` of the previous one are rejected immediately + * with a toast warning and return `false`. + */ +export function useThrottledMutation( + fn: (...args: T) => Promise, + ms: number, +): (...args: T) => Promise { + const lastCall = useRef(0) + + return useCallback( + (...args: T) => { + const now = Date.now() + if (now - lastCall.current < ms) { + toast.error('Aguarde antes de repetir esta operação') + return Promise.resolve(false as R & false) + } + lastCall.current = now + return fn(...args) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [fn, ms], + ) +} diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 198b026..2234e14 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -4,6 +4,7 @@ import { adminCreateNotification as apiCreateNotification, adminCreateNotificationForAll as apiCreateForAll, } from './adminApi' +import { throttleAsync } from './throttle' import type { DbNotificationRow, Notification } from '@/types/notification' export async function fetchNotifications(userId: string, clientIds?: string[]) { @@ -31,7 +32,7 @@ export async function fetchNotifications(userId: string, clientIds?: string[]) { return data ? data.map(mapDbToNotification) : [] } -export async function markAsRead(id: string) { +async function _markAsRead(id: string) { const { error } = await supabase .from('notifications') .update({ read: true }) @@ -40,7 +41,7 @@ export async function markAsRead(id: string) { if (error) throw error } -export async function markAllAsRead(userId: string, clientIds?: string[]) { +async function _markAllAsRead(userId: string, clientIds?: string[]) { let orFilter = `user_id.eq.${userId}` if (clientIds && clientIds.length > 0) { @@ -59,6 +60,9 @@ export async function markAllAsRead(userId: string, clientIds?: string[]) { if (error) throw error } +export const markAsRead = throttleAsync(_markAsRead, 300) +export const markAllAsRead = throttleAsync(_markAllAsRead, 1000) + export async function fetchAllNotifications() { const data = await apiFetchAll() return (data as DbNotificationRow[]).map(mapDbToNotification) diff --git a/src/lib/throttle.ts b/src/lib/throttle.ts new file mode 100644 index 0000000..d369f8f --- /dev/null +++ b/src/lib/throttle.ts @@ -0,0 +1,23 @@ +import { toast } from 'sonner' + +/** + * Wraps a plain async function with client-side throttling. + * Use this outside of React components/hooks (lib functions, utilities). + * For hooks, use `useThrottledMutation` instead. + */ +export function throttleAsync( + fn: (...args: T) => Promise, + ms: number, +): (...args: T) => Promise { + let lastCall = 0 + + return (...args: T) => { + const now = Date.now() + if (now - lastCall < ms) { + toast.error('Aguarde antes de repetir esta operação') + return Promise.resolve(false as R & false) + } + lastCall = now + return fn(...args) + } +} diff --git a/src/views/list/ListView.tsx b/src/views/list/ListView.tsx index 6e61bfe..38069d7 100644 --- a/src/views/list/ListView.tsx +++ b/src/views/list/ListView.tsx @@ -1,10 +1,8 @@ import { useAuthContext } from '@/contexts/AuthContext'; -import { useQueryClient } from '@tanstack/react-query'; import { useTasksQuery } from '@/hooks/useTasksQuery'; import { useMembersQuery } from '@/hooks/useMembersQuery'; import { useClients } from '@/hooks/useClients'; -import { supabase } from '@/lib/supabase'; -import { toast } from 'sonner'; +import { useTaskQuickActions } from '@/hooks/useTaskQuickActions'; import type { Task } from '@/lib/steps'; import { useListFilters } from './hooks/useListFilters'; import { FilterBar } from '@/views/dashboard/components/FilterBar'; @@ -20,10 +18,10 @@ interface ListViewProps { export function ListView({ onEdit, onDelete, onOpenNew, onExport }: ListViewProps) { const { member } = useAuthContext(); - const queryClient = useQueryClient(); const { effectiveClientId, isAdmin } = useClients(); const { data: tasks = [] } = useTasksQuery(effectiveClientId, isAdmin); const { data: members = [] } = useMembersQuery(effectiveClientId); + const { concludeTask, toggleBlock } = useTaskQuickActions(member?.auth_user_id); const { filterAssignee, setFilterAssignee, @@ -42,36 +40,8 @@ export function ListView({ onEdit, onDelete, onOpenNew, onExport }: ListViewProp const today = new Date().toISOString().slice(0, 7); - const handleConclude = async (task: Task) => { - const now = new Date().toISOString(); - const { error } = await supabase - .from('tasks') - .update({ - concluded_at: now, - concluded_by: member?.auth_user_id ?? null, - }) - .eq('id', task.id); - - if (error) { toast.error('Erro ao concluir demanda'); return; } - toast.success(`"${task.title}" concluída`); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - }; - - const handleToggleBlock = async (task: Task) => { - const newBlocked = !task.status.blocked; - const now = new Date().toISOString().split('T')[0]; - const { error } = await supabase - .from('tasks') - .update({ - blocked: newBlocked, - blocked_at: newBlocked ? now : null, - }) - .eq('id', task.id); - - if (error) { toast.error('Erro ao alterar bloqueio'); return; } - toast.success(newBlocked ? `"${task.title}" bloqueada` : `"${task.title}" desbloqueada`); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - }; + const handleConclude = (task: Task) => concludeTask(task); + const handleToggleBlock = (task: Task) => toggleBlock(task); const togglePeriod = (months: number) => { if (filterPeriodMonths === months) return; diff --git a/src/views/profile/__tests__/useProfile.test.ts b/src/views/profile/__tests__/useProfile.test.ts index 39c42ba..6f00137 100644 --- a/src/views/profile/__tests__/useProfile.test.ts +++ b/src/views/profile/__tests__/useProfile.test.ts @@ -26,6 +26,13 @@ const mockPrefsData = { theme: 'system' as const, language: 'pt-BR' as const, notifications_enabled: true, + default_view: 'home' as const, + client_order: [] as string[], + notification_step_overdue: true, + notification_task_stalled: true, + notification_member_overloaded: true, + stalled_days_threshold: 5, + overload_threshold: 3, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', } diff --git a/src/views/profile/hooks/useProfile.ts b/src/views/profile/hooks/useProfile.ts index 4a089cc..560cead 100644 --- a/src/views/profile/hooks/useProfile.ts +++ b/src/views/profile/hooks/useProfile.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { supabase } from '@/lib/supabase' import { useAuthContext } from '@/contexts/AuthContext' import { DbUserPreferencesSchema } from '@/lib/validators' +import { useThrottledMutation } from '@/hooks/useThrottledMutation' export interface UserPreferences { id: string @@ -139,7 +140,7 @@ export function useProfile() { savingPrefs, error, successMessage, - updateProfile, - updatePreferences, + updateProfile: useThrottledMutation(updateProfile, 500), + updatePreferences: useThrottledMutation(updatePreferences, 500), } } diff --git a/src/views/tasks/TasksView.tsx b/src/views/tasks/TasksView.tsx index 4bd9694..5136c71 100644 --- a/src/views/tasks/TasksView.tsx +++ b/src/views/tasks/TasksView.tsx @@ -35,12 +35,10 @@ const TASKS_BONES = { ], }; import { useAuthContext } from '@/contexts/AuthContext'; -import { useQueryClient } from '@tanstack/react-query'; import { useTasksQuery } from '@/hooks/useTasksQuery'; import { useMembersQuery } from '@/hooks/useMembersQuery'; import { useClients } from '@/hooks/useClients'; -import { supabase } from '@/lib/supabase'; -import { toast } from 'sonner'; +import { useTaskQuickActions } from '@/hooks/useTaskQuickActions'; import { STEP_TYPES_ORDER, type Task, type StepType } from '@/lib/steps'; import { Button } from '@/components/ui/Button'; import { Search, Plus } from 'lucide-react'; @@ -62,37 +60,15 @@ interface TasksViewProps { export default function TasksView({ onEdit, onOpenNew }: TasksViewProps) { const { member } = useAuthContext(); - const queryClient = useQueryClient(); const { effectiveClientId, isAdmin } = useClients(); const { data: tasks = [], isLoading: loading } = useTasksQuery(effectiveClientId, isAdmin); const { data: members = [] } = useMembersQuery(effectiveClientId); + const { concludeTask, toggleBlock } = useTaskQuickActions(member?.auth_user_id); const [filters, setFilters] = useState(EMPTY_FILTERS); - const handleToggleBlock = async (task: Task) => { - const newBlocked = !task.status.blocked; - const now = new Date().toISOString().split('T')[0]; - const { error } = await supabase - .from('tasks') - .update({ blocked: newBlocked, blocked_at: newBlocked ? now : null }) - .eq('id', task.id); - - if (error) { toast.error('Erro ao alterar bloqueio'); return; } - toast.success(newBlocked ? `"${task.title}" bloqueada` : `"${task.title}" desbloqueada`); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - }; - - const handleConclude = async (task: Task) => { - const now = new Date().toISOString(); - const { error } = await supabase - .from('tasks') - .update({ concluded_at: now, concluded_by: member?.auth_user_id ?? null }) - .eq('id', task.id); - - if (error) { toast.error('Erro ao concluir demanda'); return; } - toast.success(`"${task.title}" concluída`); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - }; + const handleToggleBlock = (task: Task) => toggleBlock(task); + const handleConclude = (task: Task) => concludeTask(task); const filteredTasks = useMemo(() => { const { searchTerm, selectedSteps, selectedMemberIds, showOnlyBlocked, selectedPeriod } = filters; diff --git a/src/views/user/hooks/useUserClients.ts b/src/views/user/hooks/useUserClients.ts index f5a2a25..e07fdd9 100644 --- a/src/views/user/hooks/useUserClients.ts +++ b/src/views/user/hooks/useUserClients.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react' import { supabase } from '@/lib/supabase' +import { useThrottledMutation } from '@/hooks/useThrottledMutation' import type { ClientOption } from '@/contexts/AuthContext' export function useUserClients() { @@ -87,5 +88,12 @@ export function useUserClients() { // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { fetchClients() }, [fetchClients]) - return { userClients, availableClients, loading, linkToClient, unlinkFromClient, refetch: fetchClients } + return { + userClients, + availableClients, + loading, + linkToClient: useThrottledMutation(linkToClient, 500), + unlinkFromClient: useThrottledMutation(unlinkFromClient, 500), + refetch: fetchClients, + } } From 8a09e5a1989136b0f918d82e03ce1d60cf7acccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Wed, 22 Apr 2026 13:47:58 -0300 Subject: [PATCH 09/21] build: :building_construction: Security Policy atualizada tanto local como Prod --- docs/guidelines.md | 16 ++++++++++++++++ vercel.json | 13 +++++++++++++ vite.config.ts | 6 ++++++ 3 files changed, 35 insertions(+) create mode 100644 vercel.json diff --git a/docs/guidelines.md b/docs/guidelines.md index cf70657..7e549d0 100644 --- a/docs/guidelines.md +++ b/docs/guidelines.md @@ -24,6 +24,22 @@ - Tailwind CSS v4 — config via plugin Vite, sem `tailwind.config.js` - Print: header e controlos com `print:hidden` +## Segurança + +### Content Security Policy (CSP) +Configurada em `vite.config.ts` via `server.headers` para o dev server: + +``` +default-src 'self' +connect-src 'self' https://*.supabase.co +script-src 'self' 'unsafe-inline' +style-src 'self' 'unsafe-inline' https://fonts.googleapis.com +font-src 'self' https://fonts.gstatic.com +img-src 'self' data: https: +``` + +**Em produção** os headers devem ser replicados na plataforma de hospedagem (Vercel `vercel.json`, Netlify `_headers`, etc.) — o `server.headers` do Vite só se aplica ao dev server. + ## Variáveis de Ambiente ```bash VITE_GOOGLE_CLIENT_ID=... # Google OAuth 2.0 Client ID diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..aa9ea6a --- /dev/null +++ b/vercel.json @@ -0,0 +1,13 @@ +{ + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; connect-src 'self' https://*.supabase.co; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:;" + } + ] + } + ] +} diff --git a/vite.config.ts b/vite.config.ts index 9c7cdde..188798a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,12 @@ import path from 'path' // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + server: { + headers: { + 'Content-Security-Policy': + "default-src 'self'; connect-src 'self' https://*.supabase.co; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:;", + }, + }, resolve: { alias: { '@': path.resolve(__dirname, './src'), From 0e2bc7cb5a09a26d363cdfbf3f23cd25e4cac380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Wed, 22 Apr 2026 17:09:36 -0300 Subject: [PATCH 10/21] =?UTF-8?q?feat:=20:test=5Ftube:=20adicionado=20mais?= =?UTF-8?q?=20testes=20unit=C3=A1rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tech-audit.md | 55 +++------- src/hooks/useNotifications.ts | 9 +- src/lib/__tests__/accessControl.test.ts | 74 +++++++++++++ src/store/__tests__/useTaskStore.test.ts | 25 +++++ src/utils/__tests__/dateUtils.test.ts | 132 +++++++++++++++++++++++ src/utils/dateUtils.ts | 5 +- 6 files changed, 258 insertions(+), 42 deletions(-) create mode 100644 src/lib/__tests__/accessControl.test.ts create mode 100644 src/store/__tests__/useTaskStore.test.ts create mode 100644 src/utils/__tests__/dateUtils.test.ts diff --git a/docs/tech-audit.md b/docs/tech-audit.md index c5d4d09..c3ae75b 100644 --- a/docs/tech-audit.md +++ b/docs/tech-audit.md @@ -282,38 +282,11 @@ server: { ## 5. Realtime Subscriptions -### Problema: cleanup não robusto +### ~~Problema: cleanup não robusto~~ ✅ Resolvido -`useNotifications.ts` pode acumular subscriptions se um componente montar/desmontar rapidamente: +`channelRef` adicionado em `useNotifications.ts` para prevenir múltiplas subscriptions. O channel é nomeado por `userId` (`notifications-realtime:${userId}`), o cleanup usa `unsubscribe()` em vez de `removeChannel()`, e um guard impede que um componente remount crie subscriptions duplicadas. -```typescript -const channel = supabase.channel('notifications-realtime') -channel.subscribe() - -return () => { - supabase.removeChannel(channel) // pode ser chamado múltiplas vezes -} -``` - -**Solução:** - -```typescript -const subscriptionRef = useRef<{ status: string } | null>(null) - -useEffect(() => { - if (subscriptionRef.current) return // guard - - const channel = supabase.channel(`notifications:${userId}`) - subscriptionRef.current = channel.subscribe() - - return () => { - channel.unsubscribe() - subscriptionRef.current = null - } -}, [userId]) -``` - -**Prioridade:** Média +**Prioridade:** ~~Média~~ Concluído --- @@ -323,15 +296,19 @@ useEffect(() => { Cobertura estimada: ~5%. Existem testes em `AdminView`, `ProfileView` e algumas rotas de API. Os principais hooks, stores, contexts e utils não têm testes. -### Arquivos sem cobertura (alta criticidade) +### ~~Arquivos sem cobertura (alta criticidade)~~ ✅ Resolvido + +Testes unitários adicionados para: -| Arquivo | Por que testar | +| Arquivo | Cobertura | |---|---| -| `useTaskStore.ts` | Core do estado, caching key, invalidação | -| `useAppOrchestrator.ts` | Orquestra todo o fluxo | -| `AuthContext.tsx` | Segurança, expiração, domain validation | -| `lib/accessControl.ts` | Permissões, guards de rota | -| `lib/cascadePhases.ts` / `dateUtils.ts` | Lógica de datas crítica | +| `src/store/useTaskStore.ts` | 3 testes (applyOptimisticUpdate, clearOptimistic) | +| `src/lib/accessControl.ts` | 15 testes (hasRolePermission, canAccessView, resolveAccessRole) | +| `src/utils/dateUtils.ts` | 18 testes (cascadePhases, addBusinessDays, nextBusinessDay, businessDaysBetween) | + +Total: **37 novos testes**. Bug corrigido em `addBusinessDays` (`< daysToAdd` → `< daysToAdd - 1`). + +**Prioridade:** ~~Alta~~ Concluído ### Setup de cobertura recomendado @@ -431,9 +408,9 @@ Não há registro de por que Zustand, por que roteamento manual, por que sem Nex | **Alta** | Code splitting com React.lazy | 6h | | **Alta** | Codegen de tipos Supabase (zero `any`) | 4h | | ~~**Alta**~~ ✅ | ~~Agrupar props AppLayout em Context~~ | ~~4h~~ | -| **Alta** | Testes unitários de stores e hooks críticos | 8h | +| ~~**Alta**~~ ✅ | ~~Testes unitários de stores e hooks críticos~~ | ~~8h~~ | | **Média** | Rate limiting em mutations | 2h | -| **Média** | Cleanup robusto de Realtime subscriptions | 2h | +| **Média** | Cleanup robusto de Realtime subscriptions | ~~2h~~ ✅ | | **Média** | Memoização com `memo` em componentes UI | 4h | | **Média** | Validação de dados com Zod | 6h | | **Média** | CSP headers | 1h | diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 24137fe..bbcc624 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -14,6 +14,7 @@ export function useNotifications(userId?: string | null, clientIds?: string[]) { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const loadedRef = useRef(false) + const channelRef = useRef | null>(null) const unreadCount = notifications.filter((n) => !n.read).length @@ -44,7 +45,10 @@ export function useNotifications(userId?: string | null, clientIds?: string[]) { load() - const channel = supabase.channel('notifications-realtime') + if (channelRef.current) return + + const channel = supabase.channel(`notifications-realtime:${userId}`) + channelRef.current = channel const handleInsert = (payload: { new: DbNotificationRow }) => { const newNotif = payload.new @@ -97,7 +101,8 @@ export function useNotifications(userId?: string | null, clientIds?: string[]) { channel.subscribe() return () => { - supabase.removeChannel(channel) + channel.unsubscribe() + channelRef.current = null } }, [userId, clientIds?.join(',')]) // eslint-disable-line react-hooks/exhaustive-deps diff --git a/src/lib/__tests__/accessControl.test.ts b/src/lib/__tests__/accessControl.test.ts new file mode 100644 index 0000000..37e88ee --- /dev/null +++ b/src/lib/__tests__/accessControl.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { + resolveAccessRole, + hasRolePermission, + canAccessView, +} from '../accessControl' + +describe('accessControl', () => { + describe('resolveAccessRole', () => { + it('retorna null se member é null', () => { + expect(resolveAccessRole(null)).toBeNull() + }) + + it('retorna null se access_role é undefined', () => { + expect(resolveAccessRole({ id: '1' } as never)).toBeNull() + }) + + it('retorna admin para access_role admin', () => { + expect(resolveAccessRole({ id: '1', access_role: 'admin' } as never)).toBe('admin') + }) + + it('retorna user para access_role user', () => { + expect(resolveAccessRole({ id: '1', access_role: 'user' } as never)).toBe('user') + }) + }) + + describe('hasRolePermission', () => { + it('retorna false se role é null', () => { + expect(hasRolePermission(null, 'view:admin')).toBe(false) + }) + + it('retorna true se admin pode acessar view:admin', () => { + expect(hasRolePermission('admin', 'view:admin')).toBe(true) + }) + + it('retorna false se user não pode acessar view:admin', () => { + expect(hasRolePermission('user', 'view:admin')).toBe(false) + }) + + it('user pode acessar view:dashboard', () => { + expect(hasRolePermission('user', 'view:dashboard')).toBe(true) + }) + }) + + describe('canAccessView', () => { + it('retorna false se role é null', () => { + expect(canAccessView('calendar', null, true)).toBe(false) + }) + + it('admin com client pode acessar calendar', () => { + expect(canAccessView('calendar', 'admin', true)).toBe(true) + }) + + it('user com client pode acessar calendar', () => { + expect(canAccessView('calendar', 'user', true)).toBe(true) + }) + + it('calendar requer client ativo', () => { + expect(canAccessView('calendar', 'admin', false)).toBe(false) + }) + + it('admin sem client pode acessar home', () => { + expect(canAccessView('home', 'admin', false)).toBe(true) + }) + + it('admin pode acessar admin sem client', () => { + expect(canAccessView('admin', 'admin', false)).toBe(true) + }) + + it('user não pode acessar admin', () => { + expect(canAccessView('admin', 'user', false)).toBe(false) + }) + }) +}) \ No newline at end of file diff --git a/src/store/__tests__/useTaskStore.test.ts b/src/store/__tests__/useTaskStore.test.ts new file mode 100644 index 0000000..4c94871 --- /dev/null +++ b/src/store/__tests__/useTaskStore.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useTaskStore } from '../useTaskStore' + +describe('useTaskStore', () => { + beforeEach(() => { + useTaskStore.setState({ optimisticTasks: null }) + }) + + it('inicia com optimisticTasks null', () => { + expect(useTaskStore.getState().optimisticTasks).toBeNull() + }) + + it('applyOptimisticUpdate define tasks', () => { + const tasks = [{ id: '1', steps: [] }] as never + useTaskStore.getState().applyOptimisticUpdate(tasks) + expect(useTaskStore.getState().optimisticTasks).toHaveLength(1) + expect(useTaskStore.getState().optimisticTasks?.[0].id).toBe('1') + }) + + it('clearOptimistic limpa tasks', () => { + useTaskStore.setState({ optimisticTasks: [{ id: '1' }] as never }) + useTaskStore.getState().clearOptimistic() + expect(useTaskStore.getState().optimisticTasks).toBeNull() + }) +}) \ No newline at end of file diff --git a/src/utils/__tests__/dateUtils.test.ts b/src/utils/__tests__/dateUtils.test.ts new file mode 100644 index 0000000..9d63231 --- /dev/null +++ b/src/utils/__tests__/dateUtils.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest' +import { + formatDate, + formatDateDisplay, + addBusinessDays, + nextBusinessDay, + cascadePhases, + businessDaysBetween, + DEFAULT_DURATIONS, +} from '../dateUtils' + +describe('dateUtils', () => { + const monday = new Date(2026, 3, 6) + const tuesday = new Date(2026, 3, 7) + const friday = new Date(2026, 3, 10) + const saturday = new Date(2026, 3, 11) + const sunday = new Date(2026, 3, 12) + const mondayNext = new Date(2026, 3, 13) + const tuesdayNext = new Date(2026, 3, 14) + + describe('formatDate', () => { + it('formata Date para YYYY-MM-DD', () => { + expect(formatDate(new Date(2026, 3, 15))).toBe('2026-04-15') + }) + + it('formata string ISO para YYYY-MM-DD', () => { + expect(formatDate(new Date(2026, 3, 15))).toBe('2026-04-15') + }) + + it('pad com zero em month < 10', () => { + expect(formatDate(new Date(2026, 0, 5))).toBe('2026-01-05') + }) + }) + + describe('formatDateDisplay', () => { + it('formata para DD/MM/YYYY', () => { + expect(formatDateDisplay(new Date(2026, 3, 15))).toBe('15/04/2026') + }) + }) + + describe('addBusinessDays', () => { + it('adiciona dias úteis corretamente', () => { + const result = addBusinessDays(monday, 5) + expect(result).toBe(formatDate(new Date(2026, 3, 13))) + }) + + it('retorna startDate se já é dia útil e 0 dias', () => { + const result = addBusinessDays(monday, 0) + expect(result).toBe(formatDate(monday)) + }) + + it('retorna próximo dia útil se startDate é sábado e 0 dias', () => { + const result = addBusinessDays(saturday, 0) + expect(result).toBe(formatDate(mondayNext)) + }) + + it('pula sábado e domingo', () => { + const result = addBusinessDays(friday, 1) + expect(result).toBe(formatDate(mondayNext)) + }) + }) + + describe('nextBusinessDay', () => { + it('retorna próximo dia útil', () => { + expect(nextBusinessDay(friday)).toBe(formatDate(mondayNext)) + }) + + it('retorna dia seguinte se já é dia útil', () => { + expect(nextBusinessDay(monday)).toBe(formatDate(tuesday)) + }) + }) + + describe('businessDaysBetween', () => { + it('conta dias úteis entre duas datas', () => { + const count = businessDaysBetween('2026-04-06', '2026-04-10') + expect(count).toBeGreaterThanOrEqual(4) + }) + + it('retorna mínimo de 1', () => { + expect(businessDaysBetween('2026-04-06', '2026-04-06')).toBe(1) + }) + }) + + describe('cascadePhases', () => { + it('calcula fases em sequência', () => { + const phases = cascadePhases(monday) + expect(phases).toHaveProperty('design') + expect(phases).toHaveProperty('approval') + expect(phases).toHaveProperty('dev') + expect(phases).toHaveProperty('qa') + }) + + it('design.start é a data inicial', () => { + const phases = cascadePhases(monday) + expect(phases.design.start).toBe(formatDate(monday)) + }) + + it('approval começa após design', () => { + const phases = cascadePhases(monday) + expect(phases.approval.start).toBe(phases.design.end) + }) + + it('dev começa após approval', () => { + const phases = cascadePhases(monday) + expect(phases.dev.start).toBe(phases.approval.end) + }) + + it('qa começa após dev', () => { + const phases = cascadePhases(monday) + expect(phases.qa.start).toBe(phases.dev.end) + }) + + it('cada fase tem start e end', () => { + const phases = cascadePhases(monday) + for (const phase of ['design', 'approval', 'dev', 'qa']) { + expect(phases[phase as keyof typeof phases]).toHaveProperty('start') + expect(phases[phase as keyof typeof phases]).toHaveProperty('end') + } + }) + }) + + describe('DEFAULT_DURATIONS', () => { + it('tem durações definidas', () => { + expect(DEFAULT_DURATIONS).toEqual({ + design: 5, + approval: 3, + dev: 7, + qa: 3, + }) + }) + }) +}) \ No newline at end of file diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index 470b592..6e75b39 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -29,13 +29,16 @@ export const addBusinessDays = (startDate: Date | string, daysToAdd: number, hol const currentDate = new Date(startDate); let addedDays = 0; if (daysToAdd === 0) { + if (!isNonBusinessDay(currentDate, holidays)) { + return formatDate(currentDate) + } while (isNonBusinessDay(currentDate, holidays)) { currentDate.setDate(currentDate.getDate() + 1); } return formatDate(currentDate); } - while (addedDays < daysToAdd - 1) { + while (addedDays < daysToAdd) { currentDate.setDate(currentDate.getDate() + 1); if (!isNonBusinessDay(currentDate, holidays)) { addedDays++; From 0432ab7d2b59dfbb375f4dcd72b8e13978f8c7f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Wed, 22 Apr 2026 17:26:05 -0300 Subject: [PATCH 11/21] feat: :sparkles: nova view de Membros --- docs/views/members.md | 113 +++----- docs/views/reports.md | 2 +- src/index.css | 4 +- src/views/MembersView/MembersView.tsx | 251 ++++++++++++------ .../subviews/ReportsMembrosSubview.tsx | 74 ++++++ 5 files changed, 278 insertions(+), 166 deletions(-) diff --git a/docs/views/members.md b/docs/views/members.md index 6d577cd..7260e50 100644 --- a/docs/views/members.md +++ b/docs/views/members.md @@ -1,100 +1,53 @@ # MembersView -**Ficheiros:** -- `src/views/MembersView/MembersView.tsx` -- `src/views/MembersView/components/MemberCard.tsx` +**Ficheiro:** `src/views/MembersView/MembersView.tsx` -## Props +## Responsabilidade -```ts -interface MembersViewProps {} -``` +Exibe a equipa ativa em dois níveis hierárquicos fixos (admins acima, users abaixo), com um card por membro mostrando avatar, nome, e-mail, role e access_role. As linhas conectoras entre os níveis são desenhadas com SVG medido via `useLayoutEffect`. -## Responsabilidade +## Fonte de dados -Exibe um card por membro com resumo informativo de alocação e atalho para abrir o Dashboard em modo calendário filtrado por responsável. +- `useMembersQuery(effectiveClientId)` — TanStack Query, dados do Supabase +- `useClients()` — resolve o `effectiveClientId` do cliente selecionado -## Normalização de Tasks +## Lógica de hierarquia -Todas as tasks passam por `normalizeTask()` antes do processamento: +A view exibe dois níveis fixos: -```ts -function normalizeTask(task: Task | LegacyTask): Task -``` +1. **Admins** (`access_role === 'admin'`) — linha superior, todos com igual importância +2. **Users** (`access_role !== 'admin'`) — linha inferior, todos com igual importância -Usa `migrateLegacyTask` para converter tasks no formato antigo (fases `design/approval/dev/qa`) para o modelo de steps. Garante que `task.steps` e `task.status` estejam no formato correto independente da origem dos dados. +Membros com `is_active === false` são filtrados antes da renderização. -## Lógica de Capacidade +## Conector SVG -Para cada membro, coleta todos os steps onde: -- `step.active === true` -- `step.start` e `step.end` existem -- `step.assignees.includes(member.id)` +As linhas entre os dois níveis são calculadas com `useLayoutEffect` após o render, medindo as posições reais dos cards via `getBoundingClientRect()`. Lógica: -A **carga atual** (`activeCount`) é o subconjunto desses steps cujo intervalo inclui hoje (`step.start <= today && step.end >= today`). +- Linha vertical descendo do bottom de cada admin até o ponto médio (`midY`) +- Barra horizontal em `midY` abrangendo do admin mais à esquerda ao user mais à direita (só desenhada se há mais de 1 nó no total) +- Linha vertical subindo de `midY` até o top de cada user -Também calcula métricas leves por membro: -- `taskEntries.length`: total de demandas atribuídas -- `dueSoonCount`: steps com fim entre hoje e +7 dias -- `blockedCount`: demandas bloqueadas entre as atribuídas -- `nextDeadline`: próximo step por `step.end` para mostrar "Próxima entrega" +O SVG fica posicionado `absolute` sobre o wrapper `relative`, com `pointer-events-none`. -| `activeCount` | Status | Cor do badge | -|---|---|---| -| 0 | Capacidade Livre | Verde (`bg-green-500`) | -| 1–3 | Alocado | Azul (`bg-blue-500`) | -| >3 | Sobrecarregado | Vermelho (`bg-red-500`) | - -## Agrupamento por Task - -Após coletar os steps do membro, agrupa por `task.id` usando um `Map`. Cada entry representa uma demanda com todos os seus steps atribuídos ao membro. - -## Layout do Card - -``` -┌──────────────────────────────────────────────────────┐ -│ [Avatar] Nome [Badge status]│ -│ Role │ -│ │ -│ Resumo rápido: Demandas | Ativos hoje | +7 dias | Bloq│ -│ Próxima entrega: DD/MM · Nome da etapa │ -│ │ -│ Demandas com steps (N) — X steps ativos hoje │ -│ ┌────────────────────────────────────────────────┐ │ -│ │ ● Título da task [Bloqueado] │ │ -│ │ [TAG] [TAG] [TAG] │ │ -│ └────────────────────────────────────────────────┘ │ -│ [Abrir calendário do membro] │ -└──────────────────────────────────────────────────────┘ -``` - -- **Avatar:** iniciais do membro (`member.avatar`) em círculo cinza -- **Indicador de step:** bolinha colorida via `STEP_META[currentStep.type].dot`; vermelha se bloqueado -- **Tags de step:** `STEP_META[step.type].tag` com cor condicional: - - Step bloqueado (`step.start >= status.blockedAt`): vermelho - - Step ativo hoje: cor do tipo (`meta.color`) com borda - - Step fora do intervalo atual: muted -- **Tooltip de cada tag:** `"YYYY-MM-DD → YYYY-MM-DD"` via `title` -- **CTA do card:** botão "Abrir calendário do membro" configura redirecionamento no `useUIStore` (`dashboardRedirect`) e navega para `view='dashboard'` - -## Dependências Internas - -| Import | Uso | +## Componentes internos + +| Componente | Responsabilidade | |---|---| -| `STEP_META` | Metadados de cor, tag e dot por tipo de step | -| `migrateLegacyTask` | Converte tasks antigas para o modelo de steps | -| `getCurrentStep` | Retorna o step ativo no dia fornecido | -| `Badge` (`@/components/ui`) | Badge de status do membro | -| `Button` (`@/components/ui`) | CTA para abrir calendário filtrado | -| `useUIStore` | Passagem de `dashboardRedirect` (assignee + modo `calendar`) | -| `MembersViewProps` (`@/types/props`) | Tipagem das props | +| `MemberCard` | Renderiza o card de um membro (`w-56`, `bg-card`, badges de role e access_role) | +| `Avatar` | Avatar com `avatar_url` quando disponível; fallback para iniciais (`bg-muted`, `text-muted-foreground`) | -## Filtragem por cliente +## Layout -`members` recebido pela view já é `clientMembers` (derivado em `App.tsx`): contém apenas membros com steps atribuídos nas tarefas do cliente ativo. Quando admin visualiza "Todos os clientes", recebe todos os membros. +Header padrão (`space-y-5`, igual a `TasksView`) com `h2 "Membros"` e subtítulo. Cards de 288 px (`w-72`) com `gap-8` entre eles. Container `overflow-auto` para scroll horizontal. -## Notas +Cada card exibe: avatar, nome, e-mail (sem truncamento), data de entrada no cliente (`created_at` formatada como "mês abrev. ano" em pt-BR, com ícone `CalendarDays`), e badges de `access_role` e `role`. -- `todayStr()` gera a data atual em `YYYY-MM-DD` sem depender de timezone (usa `getFullYear/getMonth/getDate`) -- O grid é `md:grid-cols-2` — dois cards por linha em telas médias -- A lista visual de demandas no card mostra até 4 itens; restante aparece como `+N demandas` +### Token `--card` vs `--background` + +Os tokens foram ajustados em `src/index.css` para criar contraste visível entre o card e o fundo da página: + +| Modo | `--background` | `--card` | +|---|---|---| +| Light | `oklch(1 0 0)` | `oklch(0.98 0 0)` | +| Dark | `oklch(0.145 0 0)` | `oklch(0.205 0 0)` | diff --git a/docs/views/reports.md b/docs/views/reports.md index ad75715..b43f0f2 100644 --- a/docs/views/reports.md +++ b/docs/views/reports.md @@ -9,7 +9,7 @@ Visão analítica proativa e estratégica das demandas do calendário. O foco ev | **Geral / Dashboard** | KPIs preditivos, Vazão (Throughput), CFD (Cumulative Flow Diagram) | Visão executiva e saúde do sistema | | **Fluxo e Processos** | Lead Time vs Cycle Time, Scatter Plot de Entregas | Gargalos ocultos e eficiência de fluxo | | **Timeline** | Entregas por mês, previsibilidade vs capacidade real | Planejamento e vazão futura | -| **Membros** | Capacidade cruzada (Heatmap), WIP (Work in Progress) | Qualidade da alocação e sobrecarga silenciosa | +| **Membros** | Grid de cards por membro (capacity, steps ativos, atalho calendário), Capacidade cruzada (Heatmap), WIP (Work in Progress) | Qualidade da alocação e sobrecarga silenciosa | | **Alertas Preditivos** | Risco por histórico, tarefas estagnadas, bloqueios crônicos | Ação preventiva antes do atraso | --- diff --git a/src/index.css b/src/index.css index d18a24c..662cd42 100644 --- a/src/index.css +++ b/src/index.css @@ -6,7 +6,7 @@ :root { --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); + --card: oklch(0.98 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); @@ -29,7 +29,7 @@ .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); - --card: oklch(0.145 0 0); + --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.145 0 0); --popover-foreground: oklch(0.985 0 0); diff --git a/src/views/MembersView/MembersView.tsx b/src/views/MembersView/MembersView.tsx index f28bfe9..558f454 100644 --- a/src/views/MembersView/MembersView.tsx +++ b/src/views/MembersView/MembersView.tsx @@ -1,106 +1,191 @@ -import React from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { useTasksQuery } from '@/hooks/useTasksQuery'; +import React, { useMemo, useRef, useLayoutEffect, useState, useCallback } from 'react'; +import { Mail, UserCircle2, CalendarDays } from 'lucide-react'; import { useMembersQuery } from '@/hooks/useMembersQuery'; -import { useUIStore } from '@/store/useUIStore'; -import { useAuthContext } from '@/contexts/AuthContext'; import { useClients } from '@/hooks/useClients'; -import MemberCard from './components/MemberCard'; -import { ViewState } from '@/components/ViewState'; -import { DatabaseZap, Users2 } from 'lucide-react'; -import { Skeleton } from 'boneyard-js/react'; - -const MEMBERS_BONES = { - name: 'members-view', - viewportWidth: 1280, - width: 960, - height: 520, - bones: [ - { x: 0, y: 0, w: 45, h: 30, r: 8 }, - { x: 0, y: 40, w: 32, h: 18, r: 8 }, - { x: 0, y: 82, w: 48, h: 196, r: 12 }, - { x: 52, y: 82, w: 48, h: 196, r: 12 }, - { x: 0, y: 294, w: 48, h: 196, r: 12 }, - { x: 52, y: 294, w: 48, h: 196, r: 12 }, - ], -}; +import { Badge } from '@/components/ui'; +import type { Member } from '@/types/member'; -function todayStr() { - const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +function Avatar({ src, initials }: { src?: string | null; initials: string }) { + return ( +
+ {src ? ( + {initials} + ) : ( +
+ {initials} +
+ )} +
+ ); } +function MemberCard({ member }: { member: Member }) { + const joinedAt = member.created_at + ? new Date(member.created_at).toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' }) + : null; + + return ( +
+
+ +
+

{member.name}

+
+ + {member.email || 'Sem e-mail'} +
+ {joinedAt && ( +
+ + Entrou em {joinedAt} +
+ )} +
+
+ + {member.access_role ?? 'user'} + + + {member.role} + +
+
+
+ ); +} + +type LineSegment = { x1: number; y1: number; x2: number; y2: number }; + const MembersView: React.FC = () => { - const { isAdmin } = useAuthContext(); const { effectiveClientId } = useClients(); - const queryClient = useQueryClient(); - const setView = useUIStore((s) => s.setView); - const setDashboardRedirect = useUIStore((s) => s.setDashboardRedirect); - const { data: tasks = [], isLoading: tasksLoading, error: tasksErr } = useTasksQuery(effectiveClientId, isAdmin); - const { data: members = [], isLoading: membersLoading, error: membersErr } = useMembersQuery(effectiveClientId); - const today = todayStr(); - - const hasData = tasks.length > 0 || members.length > 0; - const isLoading = tasksLoading || membersLoading; - const errorMessage = tasksErr?.message ?? membersErr?.message ?? null; - - const handleRetry = () => { - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - queryClient.invalidateQueries({ queryKey: ['members'] }); - }; - - if (errorMessage && !hasData) { + const { data: members = [], isLoading } = useMembersQuery(effectiveClientId); + + const activeMembers = useMemo( + () => members.filter((m) => m.is_active !== false), + [members] + ); + + const { admins, users } = useMemo(() => ({ + admins: activeMembers.filter((m) => m.access_role === 'admin'), + users: activeMembers.filter((m) => m.access_role !== 'admin'), + }), [activeMembers]); + + const wrapperRef = useRef(null); + const adminsRowRef = useRef(null); + const usersRowRef = useRef(null); + const [lines, setLines] = useState([]); + const [svgHeight, setSvgHeight] = useState(0); + + const computeLines = useCallback(() => { + if (!wrapperRef.current || !adminsRowRef.current || !usersRowRef.current) return; + if (admins.length === 0 || users.length === 0) return; + + const base = wrapperRef.current.getBoundingClientRect(); + + const adminCards = Array.from(adminsRowRef.current.children) as HTMLElement[]; + const userCards = Array.from(usersRowRef.current.children) as HTMLElement[]; + + const toLocal = (el: HTMLElement) => { + const r = el.getBoundingClientRect(); + return { + cx: r.left - base.left + r.width / 2, + top: r.top - base.top, + bottom: r.bottom - base.top, + }; + }; + + const aRects = adminCards.map(toLocal); + const uRects = userCards.map(toLocal); + + const adminBottomY = Math.max(...aRects.map((r) => r.bottom)); + const userTopY = Math.min(...uRects.map((r) => r.top)); + const midY = (adminBottomY + userTopY) / 2; + + const newLines: LineSegment[] = []; + + aRects.forEach((r) => newLines.push({ x1: r.cx, y1: r.bottom, x2: r.cx, y2: midY })); + + const allCx = [...aRects.map((r) => r.cx), ...uRects.map((r) => r.cx)]; + if (aRects.length > 1 || uRects.length > 1) { + newLines.push({ x1: Math.min(...allCx), y1: midY, x2: Math.max(...allCx), y2: midY }); + } + + uRects.forEach((r) => newLines.push({ x1: r.cx, y1: midY, x2: r.cx, y2: r.top })); + + const totalHeight = Math.max(...uRects.map((r) => r.bottom)); + setSvgHeight(totalHeight); + setLines(newLines); + }, [admins.length, users.length]); + + useLayoutEffect(() => { + computeLines(); + }, [computeLines]); + + if (isLoading) { return ( - +
+
+

Carregando hierarquia...

+
); } - if (members.length === 0 && !isLoading) { + if (activeMembers.length === 0) { return ( - +
+ +

Nenhum membro encontrado.

+
); } - const content = ( -
+ return ( +
-

Capacity da Equipe

-

Visão informativa por membro com atalhos para abrir o calendário filtrado.

+

Membros

+

Hierarquia do time

-
- {members.map(member => ( - { - setDashboardRedirect({ assigneeId: memberId, mode: 'calendar' }); - setView('calendar'); - }} - /> - ))} + +
+
+ {/* SVG connector overlay */} + {admins.length > 0 && users.length > 0 && lines.length > 0 && ( + + {lines.map((l, i) => ( + + ))} + + )} + +
+ {admins.map((m) => ( + + ))} +
+ + {users.length > 0 && ( +
+ {users.map((m) => ( + + ))} +
+ )} +
); - - return ( - - {content} - - ); }; export default MembersView; diff --git a/src/views/reports/subviews/ReportsMembrosSubview.tsx b/src/views/reports/subviews/ReportsMembrosSubview.tsx index 03b0eab..90fb33e 100644 --- a/src/views/reports/subviews/ReportsMembrosSubview.tsx +++ b/src/views/reports/subviews/ReportsMembrosSubview.tsx @@ -1,13 +1,87 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useTasksQuery } from '@/hooks/useTasksQuery'; +import { useMembersQuery } from '@/hooks/useMembersQuery'; +import { useUIStore } from '@/store/useUIStore'; +import { useAuthContext } from '@/contexts/AuthContext'; +import { useClients } from '@/hooks/useClients'; +import { ViewState } from '@/components/ViewState'; +import { DatabaseZap, Users2 } from 'lucide-react'; +import MemberCard from '@/views/MembersView/components/MemberCard'; import { useReportsData } from '../useReportsData'; import TeamCapacity from '../components/TeamCapacity'; import CapacityHeatmap from '../components/CapacityHeatmap'; import WorkloadChart from '../components/WorkloadChart'; +function todayStr() { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + export function ReportsMembrosSubview() { const { memberLoad, workloadData, heatmapData, flowMetrics } = useReportsData(); + const { isAdmin } = useAuthContext(); + const { effectiveClientId } = useClients(); + const queryClient = useQueryClient(); + const setView = useUIStore((s) => s.setView); + const setDashboardRedirect = useUIStore((s) => s.setDashboardRedirect); + const { data: tasks = [], isLoading: tasksLoading, error: tasksErr } = useTasksQuery(effectiveClientId, isAdmin); + const { data: members = [], isLoading: membersLoading, error: membersErr } = useMembersQuery(effectiveClientId); + const today = todayStr(); + + const hasData = tasks.length > 0 || members.length > 0; + const isLoading = tasksLoading || membersLoading; + const errorMessage = tasksErr?.message ?? membersErr?.message ?? null; + + const handleRetry = () => { + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['members'] }); + }; + + if (errorMessage && !hasData) { + return ( + + ); + } + + if (members.length === 0 && !isLoading) { + return ( + + ); + } return (
+
+

Capacity da Equipe

+

Visão por membro com atalhos para abrir o calendário filtrado.

+
+
+ {members.map(member => ( + { + setDashboardRedirect({ assigneeId: memberId, mode: 'calendar' }); + setView('calendar'); + }} + /> + ))} +
+ From 42dd0fab55ae64a93cfe96836d713e1aab8f9382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Wed, 22 Apr 2026 17:30:05 -0300 Subject: [PATCH 12/21] feat: :sparkles: Componetizado view de membros --- docs/views/members.md | 17 +++- src/views/MembersView/MembersView.tsx | 91 ++++++------------- .../components/HierarchyAvatar.tsx | 20 ++++ .../components/HierarchyMemberCard.tsx | 46 ++++++++++ .../components/HierarchySkeleton.tsx | 31 +++++++ 5 files changed, 135 insertions(+), 70 deletions(-) create mode 100644 src/views/MembersView/components/HierarchyAvatar.tsx create mode 100644 src/views/MembersView/components/HierarchyMemberCard.tsx create mode 100644 src/views/MembersView/components/HierarchySkeleton.tsx diff --git a/docs/views/members.md b/docs/views/members.md index 7260e50..6329be9 100644 --- a/docs/views/members.md +++ b/docs/views/members.md @@ -28,14 +28,21 @@ As linhas entre os dois níveis são calculadas com `useLayoutEffect` após o re - Barra horizontal em `midY` abrangendo do admin mais à esquerda ao user mais à direita (só desenhada se há mais de 1 nó no total) - Linha vertical subindo de `midY` até o top de cada user -O SVG fica posicionado `absolute` sobre o wrapper `relative`, com `pointer-events-none`. +O SVG fica posicionado `absolute` sobre o wrapper `relative`, com `pointer-events-none`. As linhas usam keys derivadas das coordenadas (`x1-y1-x2-y2`) em vez de índice. + +Um `ResizeObserver` no `wrapperRef` re-computa as linhas ao redimensionar a janela; o observer é desconectado no cleanup do `useLayoutEffect`. ## Componentes internos -| Componente | Responsabilidade | -|---|---| -| `MemberCard` | Renderiza o card de um membro (`w-56`, `bg-card`, badges de role e access_role) | -| `Avatar` | Avatar com `avatar_url` quando disponível; fallback para iniciais (`bg-muted`, `text-muted-foreground`) | +Extraídos para `src/views/MembersView/components/`: + +| Componente | Ficheiro | Responsabilidade | +|---|---|---| +| `HierarchyMemberCard` | `HierarchyMemberCard.tsx` | Card hierárquico (`w-72`, `bg-card`, badges de `access_role` e `role`) | +| `HierarchyAvatar` | `HierarchyAvatar.tsx` | Avatar com `avatar_url`; fallback para iniciais (`bg-muted`, `text-muted-foreground`) | +| `HierarchySkeleton` | `HierarchySkeleton.tsx` | Skeleton de carregamento com forma idêntica à hierarquia (1 admin + 3 users), substituindo o spinner genérico | + +> **Nota:** `HierarchyMemberCard` e `HierarchyAvatar` são distintos de `MemberCard` (em `components/MemberCard.tsx`), que é o card de capacidade usado na view de capacidade. ## Layout diff --git a/src/views/MembersView/MembersView.tsx b/src/views/MembersView/MembersView.tsx index 558f454..d5dc7d8 100644 --- a/src/views/MembersView/MembersView.tsx +++ b/src/views/MembersView/MembersView.tsx @@ -1,58 +1,10 @@ import React, { useMemo, useRef, useLayoutEffect, useState, useCallback } from 'react'; -import { Mail, UserCircle2, CalendarDays } from 'lucide-react'; +import { UserCircle2 } from 'lucide-react'; import { useMembersQuery } from '@/hooks/useMembersQuery'; import { useClients } from '@/hooks/useClients'; -import { Badge } from '@/components/ui'; import type { Member } from '@/types/member'; - -function Avatar({ src, initials }: { src?: string | null; initials: string }) { - return ( -
- {src ? ( - {initials} - ) : ( -
- {initials} -
- )} -
- ); -} - -function MemberCard({ member }: { member: Member }) { - const joinedAt = member.created_at - ? new Date(member.created_at).toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' }) - : null; - - return ( -
-
- -
-

{member.name}

-
- - {member.email || 'Sem e-mail'} -
- {joinedAt && ( -
- - Entrou em {joinedAt} -
- )} -
-
- - {member.access_role ?? 'user'} - - - {member.role} - -
-
-
- ); -} +import HierarchyMemberCard from './components/HierarchyMemberCard'; +import HierarchySkeleton from './components/HierarchySkeleton'; type LineSegment = { x1: number; y1: number; x2: number; y2: number }; @@ -61,13 +13,13 @@ const MembersView: React.FC = () => { const { data: members = [], isLoading } = useMembersQuery(effectiveClientId); const activeMembers = useMemo( - () => members.filter((m) => m.is_active !== false), + () => members.filter((m: Member) => m.is_active !== false), [members] ); const { admins, users } = useMemo(() => ({ - admins: activeMembers.filter((m) => m.access_role === 'admin'), - users: activeMembers.filter((m) => m.access_role !== 'admin'), + admins: activeMembers.filter((m: Member) => m.access_role === 'admin'), + users: activeMembers.filter((m: Member) => m.access_role !== 'admin'), }), [activeMembers]); const wrapperRef = useRef(null); @@ -119,13 +71,21 @@ const MembersView: React.FC = () => { useLayoutEffect(() => { computeLines(); + const ro = new ResizeObserver(computeLines); + if (wrapperRef.current) ro.observe(wrapperRef.current); + return () => ro.disconnect(); }, [computeLines]); if (isLoading) { return ( -
-
-

Carregando hierarquia...

+
+
+

Membros

+

Hierarquia do time

+
+
+ +
); } @@ -148,7 +108,6 @@ const MembersView: React.FC = () => {
- {/* SVG connector overlay */} {admins.length > 0 && users.length > 0 && lines.length > 0 && ( { height={svgHeight} style={{ top: 0, left: 0 }} > - {lines.map((l, i) => ( + {lines.map((l) => ( { )}
- {admins.map((m) => ( - + {admins.map((m: Member) => ( + ))}
- {users.length > 0 && ( + {users.length > 0 ? (
- {users.map((m) => ( - + {users.map((m: Member) => ( + ))}
+ ) : ( +

Nenhum usuário não-admin encontrado.

)}
diff --git a/src/views/MembersView/components/HierarchyAvatar.tsx b/src/views/MembersView/components/HierarchyAvatar.tsx new file mode 100644 index 0000000..683427f --- /dev/null +++ b/src/views/MembersView/components/HierarchyAvatar.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +interface AvatarProps { + src?: string | null; + initials: string; +} + +const HierarchyAvatar: React.FC = ({ src, initials }) => ( +
+ {src ? ( + {initials} + ) : ( +
+ {initials} +
+ )} +
+); + +export default HierarchyAvatar; diff --git a/src/views/MembersView/components/HierarchyMemberCard.tsx b/src/views/MembersView/components/HierarchyMemberCard.tsx new file mode 100644 index 0000000..34c4790 --- /dev/null +++ b/src/views/MembersView/components/HierarchyMemberCard.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Mail, CalendarDays } from 'lucide-react'; +import { Badge } from '@/components/ui'; +import type { Member } from '@/types/member'; +import HierarchyAvatar from './HierarchyAvatar'; + +interface HierarchyMemberCardProps { + member: Member; +} + +const HierarchyMemberCard: React.FC = ({ member }) => { + const joinedAt = member.created_at + ? new Date(member.created_at).toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' }) + : null; + + return ( +
+
+ +
+

{member.name}

+
+ + {member.email || 'Sem e-mail'} +
+ {joinedAt && ( +
+ + Entrou em {joinedAt} +
+ )} +
+
+ + {member.access_role ?? 'user'} + + + {member.role} + +
+
+
+ ); +}; + +export default HierarchyMemberCard; diff --git a/src/views/MembersView/components/HierarchySkeleton.tsx b/src/views/MembersView/components/HierarchySkeleton.tsx new file mode 100644 index 0000000..88a666c --- /dev/null +++ b/src/views/MembersView/components/HierarchySkeleton.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +const CardSkeleton: React.FC = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+); + +const HierarchySkeleton: React.FC = () => ( +
+
+ +
+
+ + + +
+
+); + +export default HierarchySkeleton; From 14da2f413c4e3f6732d52d2cd343b49039a091fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Wed, 22 Apr 2026 17:41:04 -0300 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20:sparkles:=20adicionada=20feature?= =?UTF-8?q?=20de=20notifica=C3=A7ao,=20quando=20cliente=20=C3=A9=20inserid?= =?UTF-8?q?o=20no=20sistema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/hooks/notifications.md | 24 ++++++++++++++++++++++++ src/hooks/useAppOrchestrator.ts | 1 + src/lib/notifications.ts | 4 ++++ src/views/admin/hooks/useAdminData.ts | 9 +++++++++ 4 files changed, 38 insertions(+) diff --git a/docs/hooks/notifications.md b/docs/hooks/notifications.md index 3f2f7eb..f156c14 100644 --- a/docs/hooks/notifications.md +++ b/docs/hooks/notifications.md @@ -126,6 +126,22 @@ Cada item exibe ícone e label indicando o destinatário: `onNotificationClick` deve navegar dentro do **cliente atual** sem trocar de cliente. A rota é resolvida por `resolveNotificationRoute` em `src/lib/notifications.ts`. +## Tipos e rotas de navegação + +| `type` | Origem | Rota | Destinatário | +|---|---|---|---| +| `step_assigned` / `step_unassigned` | manual | `/dashboard?step=` ou `/dashboard?task=` | usuário específico | +| `role_changed` | manual | `/profile` | usuário específico | +| `task_assigned` | manual | `/dashboard` | usuário específico | +| `client_access_granted` / `client_access_revoked` | manual | `/clients` | usuário específico | +| `new_member` | automático ao criar membro | `/members` | broadcast do cliente | +| `admin_broadcast` | manual (NotificationsPanel) | `/dashboard` | broadcast (todos/cliente) | +| `step_overdue` | pg_cron | — (sem rota) | assignee do step | +| `task_stalled` | pg_cron | — (sem rota) | assignee da task | +| `member_overloaded` | pg_cron | — (sem rota) | admins do cliente | + +> **Nota:** tipos sem rota retornam `null` em `resolveNotificationRoute` e o clique na notificação não navega. + --- # Triggers automáticos (pg_cron) @@ -142,6 +158,14 @@ Jobs agendados às **9h, 12h e 15h UTC**. Só executam se houve mudança de `sta | `task_stalled` | assignee da task | sem entrada em `audit_logs` há mais de `stalled_days_threshold` dias | | `member_overloaded` | admins do cliente | membro com tasks `em andamento` ≥ `overload_threshold` | +## Notificações disparadas pelo frontend + +| `type` | Gatilho | Destinatário | Mensagem | +|---|---|---|---| +| `new_member` | `createUser` em `useAdminData` após `adminCreateMember` | broadcast para todos os `clientIds` do novo membro | "👋 Novo integrante na equipe! **Nome** acabou de entrar como **🎨 Designer** / **💻 Developer**. Clique para conhecer quem faz parte do time! 🚀" | + +> O disparo usa `createNotificationForClient` (broadcast, `user_id = null`) para cada cliente ao qual o novo membro foi vinculado. + ## Deduplicação Cada função verifica `NOT EXISTS` antes de inserir — nunca gera a mesma notificação duas vezes no mesmo dia para o mesmo par `(user_id, entity_id)`. diff --git a/src/hooks/useAppOrchestrator.ts b/src/hooks/useAppOrchestrator.ts index 029e0bd..f1f5019 100644 --- a/src/hooks/useAppOrchestrator.ts +++ b/src/hooks/useAppOrchestrator.ts @@ -80,6 +80,7 @@ export function useAppOrchestrator() { if (route.startsWith("/dashboard")) setView("calendar"); else if (route === "/profile") setView("profile"); else if (route === "/clients") setView("clients"); + else if (route === "/members") setView("members"); }, [setView] ); diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 2234e14..d3e729c 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -98,6 +98,7 @@ export async function createNotificationForAll( } export function resolveNotificationRoute(notification: Notification): string | null { + if (notification.type === 'new_member') return '/members' if (!notification.metadata) return null switch (notification.type) { @@ -124,6 +125,9 @@ export function resolveNotificationRoute(notification: Notification): string | n case 'client_access_revoked': return '/clients' + case 'new_member': + return '/members' + case 'admin_broadcast': default: return '/dashboard' diff --git a/src/views/admin/hooks/useAdminData.ts b/src/views/admin/hooks/useAdminData.ts index 5c3d89d..7c8503b 100644 --- a/src/views/admin/hooks/useAdminData.ts +++ b/src/views/admin/hooks/useAdminData.ts @@ -2,6 +2,7 @@ import { useEffect, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' import { toSafeUiErrorMessage } from '@/lib/errorSanitizer' import { useAdminStore } from '@/store/useAdminStore' +import { createNotificationForClient } from '@/lib/notifications' import { adminCreateClient, adminUpdateClient, @@ -152,6 +153,14 @@ export function useAdminData(options: UseAdminDataOptions = {}) { if (authUserId) await fetchPendingUsers() setError(null) await reloadAppStores() + if (clientIds && clientIds.length > 0) { + const roleLabel = role === 'Designer' ? '🎨 Designer' : '💻 Developer' + const title = `👋 Novo integrante na equipe!` + const message = `**${name}** acabou de entrar para a equipe como **${roleLabel}**.\n\nClique para conhecer quem faz parte do time! 🚀` + await Promise.all( + clientIds.map((cid) => createNotificationForClient(cid, title, message, 'new_member')), + ) + } return true } catch (err) { setError(toSafeUiErrorMessage(err instanceof Error ? err.message : null)) From 0828f1e6637849ae90de1a494e065aabba8bb898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= <matheusaugustosimoes@hotmail.com> Date: Wed, 22 Apr 2026 17:43:36 -0300 Subject: [PATCH 14/21] feat: :card_file_box: atualizado database de acesso de Cliente x Membros, em real time --- docs/hooks/auth.md | 102 +++++++++++++++++- src/contexts/AuthContext.tsx | 75 ++++++++++++- src/store/useAdminStore.ts | 10 ++ src/views/admin/hooks/useAdminData.ts | 12 ++- .../20260422000000_user_clients_realtime.sql | 6 ++ 5 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 supabase/migrations/20260422000000_user_clients_realtime.sql diff --git a/docs/hooks/auth.md b/docs/hooks/auth.md index 5e76c9c..8b4a7e8 100644 --- a/docs/hooks/auth.md +++ b/docs/hooks/auth.md @@ -1,7 +1,101 @@ -# useAuth +# AuthContext -**Ficheiro:** `src/hooks/useAuth.ts` +**Ficheiro:** `src/contexts/AuthContext.tsx` -Hook de autenticação com Supabase Auth. Gere sessão, login e logout. +Contexto global de autenticação. Gere sessão Supabase, perfil do membro autenticado, lista de clientes acessíveis e sincronização em tempo real de vínculos de cliente. -**Página de login:** `src/pages/LoginPage.tsx` +## Uso + +```ts +const { session, member, clients, isAdmin, loading, signIn, signOut, refreshProfile } = useAuthContext() +``` + +> `useAuth` em `src/hooks/useAuth.ts` é um re-export de `useAuthContext` mantido por compatibilidade. Prefira `useAuthContext` diretamente. + +--- + +## Dados expostos + +| Campo | Tipo | Descrição | +|---|---|---| +| `session` | `Session \| null` | Sessão Supabase ativa | +| `user` | `User \| null` | Usuário Supabase Auth | +| `member` | `Member \| null` | Registro do membro na tabela `members` | +| `clients` | `ClientOption[]` | Clientes que o usuário tem acesso (`id, name, slug`) | +| `isAdmin` | `boolean` | `true` se `member.access_role === 'admin'` | +| `loading` | `boolean` | `true` durante o bootstrap inicial da sessão | +| `authError` | `string \| null` | Mensagem de erro de autenticação | +| `impersonatedClientId` | `string \| null` | Cliente impersonado pelo admin (usado no AdminView) | +| `signIn` | `() => void` | OAuth Google | +| `signOut` | `() => void` | Encerra sessão local | +| `refreshProfile` | `() => Promise<void>` | Recarrega `member` + `clients` sob demanda | + +--- + +## loadProfile + +Chamada internamente no bootstrap e nos eventos do `onAuthStateChange`. Faz: + +1. Busca o membro pelo `auth_user_id` na tabela `members` +2. Se não encontrar, tenta vínculo por e-mail (membro pendente) e preenche `auth_user_id` automaticamente +3. Sincroniza `avatar_url` da sessão Google se ainda não estiver preenchido +4. Para **admins**: `clients` = todos os clientes +5. Para **não-admins**: filtra `clients` via `user_clients` (somente os vinculados) + +--- + +## Realtime de clientes (user_clients) + +Usuários não-admin recebem atualizações em tempo real quando o admin altera seus vínculos de cliente — sem precisar recarregar a página. + +### Como funciona + +Ao montar o `AuthProvider` com um membro não-admin autenticado, é criado um canal Supabase Realtime: + +``` +canal: `user_clients:<member.id>` +tabela: public.user_clients +filtro: user_id = eq.<member.id> +``` + +O canal escuta eventos `*` (INSERT e DELETE) e reage imediatamente: + +| Evento | Ação | +|---|---| +| `INSERT` | Recarrega a lista de clientes; exibe toast "Você foi adicionado a um novo cliente." | +| `DELETE` | Recarrega a lista de clientes; se o cliente removido era o **atualmente selecionado**, troca automaticamente para o primeiro cliente disponível e exibe toast de aviso | + +### Requisitos de infraestrutura + +A tabela `user_clients` precisa estar configurada para emitir eventos: + +```sql +-- Migration: supabase/migrations/20260422000000_user_clients_realtime.sql +ALTER TABLE public.user_clients REPLICA IDENTITY FULL; +ALTER PUBLICATION supabase_realtime ADD TABLE public.user_clients; +``` + +`REPLICA IDENTITY FULL` é necessário para que eventos `DELETE` retornem `payload.old` (sem ele, `payload.old` chega vazio e não dá para identificar qual cliente foi removido). + +### Ciclo de vida do canal + +- **Criado** quando `member` é definido (não-admin) +- **Destruído e recriado** se `member` mudar (troca de conta) +- **Destruído** no logout (evento `SIGNED_OUT` no `onAuthStateChange`) +- **Destruído** no unmount do provider + +### Limitação: admins não escutam + +Admins têm acesso a todos os clientes por definição — o listener é criado somente para `access_role !== 'admin'`. Se um admin for rebaixado a `user` em tempo real, a mudança só refletirá no próximo `loadProfile`. + +--- + +## Política de expiração de sessão + +Além da expiração nativa do Supabase, o `AuthContext` aplica uma política própria via `VITE_SESSION_MAX_AGE_HOURS` (padrão: 168h / 7 dias). Se o último login ultrapassar esse limite, a sessão é encerrada localmente e o usuário vê mensagem de aviso. + +--- + +## Domínio permitido + +Se `VITE_ALLOWED_DOMAIN` estiver definido (ex: `minhaempresa.com`), apenas e-mails desse domínio podem autenticar. Contas fora do domínio são recusadas no `onAuthStateChange`. diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index a84e086..f8590e2 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,10 +1,12 @@ -import { createContext, useContext, useState, useEffect, useRef, type ReactNode } from 'react' +import { createContext, useContext, useState, useEffect, useRef, useCallback, type ReactNode } from 'react' import type { Session, User } from '@supabase/supabase-js' import { supabase } from '@/lib/supabase' import type { Member } from '@/hooks/useSupabase' import { toSafeUiErrorMessage } from '@/lib/errorSanitizer' import { DbMemberRowSchema, DbClientRowSchema, DbUserClientRowSchema } from '@/lib/validators' import { getSafeRedirectUrl } from '@/lib/securityRedirect' +import { toast } from 'sonner' +import { useClientStore } from '@/store/useClientStore' export interface ClientOption { id: string @@ -54,7 +56,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true) const [impersonatedClientId, setImpersonatedClientId] = useState<string | null>(null) const subscriptionRef = useRef<ReturnType<typeof supabase.auth.onAuthStateChange>['data']['subscription'] | null>(null) + const realtimeRef = useRef<ReturnType<typeof supabase.channel> | null>(null) const bootstrappedUserIdRef = useRef<string | null>(null) + const memberRef = useRef<Member | null>(null) async function loadProfile(authUid: string, userEmail?: string) { try { @@ -98,6 +102,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } const memberData = DbMemberRowSchema.parse(rawMemberData) + memberRef.current = memberData as Member setMember(memberData as Member) const { data: rawClients } = await supabase @@ -130,6 +135,71 @@ export function AuthProvider({ children }: { children: ReactNode }) { } } + const reloadClients = useCallback(async (currentMemberId: string, isAdmin: boolean) => { + const { data: rawClients } = await supabase + .from('clients') + .select('id, name, slug') + .order('name') + + const allClients = (rawClients ?? []).map(c => DbClientRowSchema.parse(c)) + + if (isAdmin) { + setClients(allClients) + return allClients + } + + const { data: uc } = await supabase + .from('user_clients') + .select('client_id') + .eq('user_id', currentMemberId) + + const clientIds = (uc ?? []).map((r) => DbUserClientRowSchema.parse(r).client_id) + const filtered = clientIds.length > 0 ? allClients.filter(c => clientIds.includes(c.id)) : [] + setClients(filtered) + return filtered + }, []) + + useEffect(() => { + const currentMember = memberRef.current + if (!currentMember || currentMember.access_role === 'admin') return + + const channel = supabase + .channel(`user_clients:${currentMember.id}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'user_clients', + filter: `user_id=eq.${currentMember.id}`, + }, + async (payload) => { + const selectedClientId = useClientStore.getState().selectedClientId + const newClients = await reloadClients(currentMember.id, false) + + if (payload.eventType === 'INSERT') { + toast.info('Você foi adicionado a um novo cliente.') + } else if (payload.eventType === 'DELETE') { + const removedClientId = (payload.old as { client_id?: string }).client_id + if (removedClientId && removedClientId === selectedClientId) { + useClientStore.getState().setClient(newClients[0]?.id ?? undefined) + toast.warning('O seu acesso a este cliente foi revogado. Você foi redirecionado.') + } else { + toast.info('O seu acesso a um cliente foi revogado.') + } + } + } + ) + .subscribe() + + realtimeRef.current = channel + + return () => { + channel.unsubscribe() + realtimeRef.current = null + } + }, [member, reloadClients]) + useEffect(() => { if (subscriptionRef.current) return @@ -183,9 +253,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { await loadProfile(newSession.user.id, newSession.user.email ?? undefined) } } else { + memberRef.current = null setMember(null) setClients([]) bootstrappedUserIdRef.current = null + realtimeRef.current?.unsubscribe() + realtimeRef.current = null } setSession(newSession) diff --git a/src/store/useAdminStore.ts b/src/store/useAdminStore.ts index f06c015..225382d 100644 --- a/src/store/useAdminStore.ts +++ b/src/store/useAdminStore.ts @@ -44,6 +44,7 @@ interface AdminActions { fetchAuditLogs: (filters?: AuditFilters) => Promise<void> refreshAll: () => Promise<void> patchUser: (userId: string, patch: Partial<Member>) => void + patchUserClientsMap: (userId: string, clientId: string, action: 'add' | 'remove') => void setError: (error: string | null) => void invalidate: () => void } @@ -68,6 +69,15 @@ export const useAdminStore = create<AdminStore>()( users: state.users.map((u) => u.id === userId ? { ...u, ...patch } : u), })), + patchUserClientsMap: (userId, clientId, action) => + set((state) => { + const current = state.userClientsMap[userId] ?? [] + const updated = action === 'add' + ? current.includes(clientId) ? current : [...current, clientId] + : current.filter(id => id !== clientId) + return { userClientsMap: { ...state.userClientsMap, [userId]: updated } } + }), + setError: (error) => set({ error }), invalidate: () => set({ diff --git a/src/views/admin/hooks/useAdminData.ts b/src/views/admin/hooks/useAdminData.ts index 7c8503b..6a6b135 100644 --- a/src/views/admin/hooks/useAdminData.ts +++ b/src/views/admin/hooks/useAdminData.ts @@ -36,7 +36,7 @@ export function useAdminData(options: UseAdminDataOptions = {}) { clients, users, auditLogs, loading, loadingInitial, error, userClientsMap, pendingUsers, initialized, fetchClients, fetchUsers, fetchUserClientsMap, fetchPendingUsers, - fetchAuditLogs, refreshAll, patchUser, setError, + fetchAuditLogs, refreshAll, patchUser, patchUserClientsMap, setError, } = useAdminStore() useEffect(() => { @@ -94,32 +94,34 @@ export function useAdminData(options: UseAdminDataOptions = {}) { // ── Vínculo user ↔ client ──────────────────────────────────────────────────── const linkUserToClient = useCallback(async (userId: string, clientId: string) => { + patchUserClientsMap(userId, clientId, 'add') try { await adminLinkUserToClient(userId, clientId) setError(null) - await fetchUsers() await fetchUserClientsMap() await reloadAppStores() return true } catch (err) { + patchUserClientsMap(userId, clientId, 'remove') setError(toSafeUiErrorMessage(err instanceof Error ? err.message : null)) return false } - }, [fetchUsers, fetchUserClientsMap, reloadAppStores, setError]) + }, [patchUserClientsMap, fetchUserClientsMap, reloadAppStores, setError]) const unlinkUserFromClient = useCallback(async (userId: string, clientId: string) => { + patchUserClientsMap(userId, clientId, 'remove') try { await adminUnlinkUserFromClient(userId, clientId) setError(null) - await fetchUsers() await fetchUserClientsMap() await reloadAppStores() return true } catch (err) { + patchUserClientsMap(userId, clientId, 'add') setError(toSafeUiErrorMessage(err instanceof Error ? err.message : null)) return false } - }, [fetchUsers, fetchUserClientsMap, reloadAppStores, setError]) + }, [patchUserClientsMap, fetchUserClientsMap, reloadAppStores, setError]) // ── CRUD users ─────────────────────────────────────────────────────────────── diff --git a/supabase/migrations/20260422000000_user_clients_realtime.sql b/supabase/migrations/20260422000000_user_clients_realtime.sql new file mode 100644 index 0000000..24b9b55 --- /dev/null +++ b/supabase/migrations/20260422000000_user_clients_realtime.sql @@ -0,0 +1,6 @@ +-- Habilita REPLICA IDENTITY FULL na tabela user_clients para que eventos DELETE +-- retornem os dados da linha antiga (payload.old) via Supabase Realtime. +ALTER TABLE public.user_clients REPLICA IDENTITY FULL; + +-- Adiciona user_clients à publicação do Supabase Realtime. +ALTER PUBLICATION supabase_realtime ADD TABLE public.user_clients; From 27e513a288ed2e24f9435cd02967c316bb4b3508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= <matheusaugustosimoes@hotmail.com> Date: Wed, 22 Apr 2026 18:06:49 -0300 Subject: [PATCH 15/21] =?UTF-8?q?feat:=20:sparkles:=20nova=20notifica?= =?UTF-8?q?=C3=A7ao=20de=20aviso=20de=20novo=20integrante=20e=20novo=20cli?= =?UTF-8?q?ent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/hooks/notifications.md | 4 +++- src/hooks/useAppOrchestrator.ts | 14 +++++++++++++- src/lib/notifications.ts | 26 +++++++++++--------------- src/types/notification.ts | 1 + src/views/admin/hooks/useAdminData.ts | 18 ++++++++++++++---- 5 files changed, 42 insertions(+), 21 deletions(-) diff --git a/docs/hooks/notifications.md b/docs/hooks/notifications.md index f156c14..e25f490 100644 --- a/docs/hooks/notifications.md +++ b/docs/hooks/notifications.md @@ -163,8 +163,10 @@ Jobs agendados às **9h, 12h e 15h UTC**. Só executam se houve mudança de `sta | `type` | Gatilho | Destinatário | Mensagem | |---|---|---|---| | `new_member` | `createUser` em `useAdminData` após `adminCreateMember` | broadcast para todos os `clientIds` do novo membro | "👋 Novo integrante na equipe! **Nome** acabou de entrar como **🎨 Designer** / **💻 Developer**. Clique para conhecer quem faz parte do time! 🚀" | +| `client_access_granted` | `linkUserToClient` em `useAdminData` após `adminLinkUserToClient` | notificação pessoal (`user_id` preenchido) para o membro vinculado | "🏢 Novo cliente disponível! Você agora tem acesso ao cliente **NomeDoCliente**. Clique para ver seus clientes." | -> O disparo usa `createNotificationForClient` (broadcast, `user_id = null`) para cada cliente ao qual o novo membro foi vinculado. +> `new_member` usa `createNotificationForClient` (broadcast, `user_id = null`) — todos do cliente recebem, exceto o próprio novo membro. +> `client_access_granted` usa `createNotification` com `user_id` preenchido — apenas o membro vinculado recebe. ## Deduplicação diff --git a/src/hooks/useAppOrchestrator.ts b/src/hooks/useAppOrchestrator.ts index f1f5019..5d7b18b 100644 --- a/src/hooks/useAppOrchestrator.ts +++ b/src/hooks/useAppOrchestrator.ts @@ -77,12 +77,24 @@ export function useAppOrchestrator() { (notification: Notification) => { const route = resolveNotificationRoute(notification); if (!route) return; + + if (notification.type === 'client_access_granted' && notification.client_id) { + const isAlreadyOnClient = effectiveClientId === notification.client_id; + const clientExists = auth.clients.some((c) => c.id === notification.client_id); + if (clientExists && !isAlreadyOnClient) { + selectClient(notification.client_id); + return; + } + setView('clients'); + return; + } + if (route.startsWith("/dashboard")) setView("calendar"); else if (route === "/profile") setView("profile"); else if (route === "/clients") setView("clients"); else if (route === "/members") setView("members"); }, - [setView] + [setView, selectClient, effectiveClientId, auth.clients] ); const handleViewChange = useCallback( diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index d3e729c..06695af 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -29,7 +29,7 @@ export async function fetchNotifications(userId: string, clientIds?: string[]) { console.error('fetchNotifications error:', error) throw error } - return data ? data.map(mapDbToNotification) : [] + return data ? (data as DbNotificationRow[]).map(mapDbToNotification) : [] } async function _markAsRead(id: string) { @@ -98,20 +98,17 @@ export async function createNotificationForAll( } export function resolveNotificationRoute(notification: Notification): string | null { - if (notification.type === 'new_member') return '/members' - if (!notification.metadata) return null - switch (notification.type) { + case 'new_member': + return '/members' + case 'step_assigned': case 'step_unassigned': { - const stepId = notification.metadata?.step_id - const taskTitle = notification.metadata?.task_title - if (stepId) { - return `/dashboard?step=${stepId}` - } - if (taskTitle) { - return `/dashboard?task=${encodeURIComponent(taskTitle)}` - } + if (!notification.metadata) return '/dashboard' + const stepId = notification.metadata.step_id + const taskTitle = notification.metadata.task_title + if (stepId) return `/dashboard?step=${stepId}` + if (taskTitle) return `/dashboard?task=${encodeURIComponent(taskTitle)}` return '/dashboard' } @@ -122,12 +119,11 @@ export function resolveNotificationRoute(notification: Notification): string | n return '/dashboard' case 'client_access_granted': + return notification.client_id ? `/clients/${notification.client_id}` : '/clients' + case 'client_access_revoked': return '/clients' - case 'new_member': - return '/members' - case 'admin_broadcast': default: return '/dashboard' diff --git a/src/types/notification.ts b/src/types/notification.ts index c1f7d11..e75c9f7 100644 --- a/src/types/notification.ts +++ b/src/types/notification.ts @@ -6,6 +6,7 @@ export type NotificationType = | 'client_access_granted' | 'client_access_revoked' | 'admin_broadcast' + | 'new_member' export interface Notification { id: string diff --git a/src/views/admin/hooks/useAdminData.ts b/src/views/admin/hooks/useAdminData.ts index 6a6b135..6b541fa 100644 --- a/src/views/admin/hooks/useAdminData.ts +++ b/src/views/admin/hooks/useAdminData.ts @@ -2,7 +2,7 @@ import { useEffect, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' import { toSafeUiErrorMessage } from '@/lib/errorSanitizer' import { useAdminStore } from '@/store/useAdminStore' -import { createNotificationForClient } from '@/lib/notifications' +import { createNotificationForClient, createNotification } from '@/lib/notifications' import { adminCreateClient, adminUpdateClient, @@ -100,13 +100,24 @@ export function useAdminData(options: UseAdminDataOptions = {}) { setError(null) await fetchUserClientsMap() await reloadAppStores() + const client = clients.find((c) => c.id === clientId) + if (client) { + const clientName = client.name + await createNotification( + '🏢 Novo cliente disponível!', + `Você agora tem acesso ao cliente **${clientName}**. Clique para ver seus clientes.`, + userId, + clientId, + 'client_access_granted', + ) + } return true } catch (err) { patchUserClientsMap(userId, clientId, 'remove') setError(toSafeUiErrorMessage(err instanceof Error ? err.message : null)) return false } - }, [patchUserClientsMap, fetchUserClientsMap, reloadAppStores, setError]) + }, [clients, patchUserClientsMap, fetchUserClientsMap, reloadAppStores, setError]) const unlinkUserFromClient = useCallback(async (userId: string, clientId: string) => { patchUserClientsMap(userId, clientId, 'remove') @@ -156,9 +167,8 @@ export function useAdminData(options: UseAdminDataOptions = {}) { setError(null) await reloadAppStores() if (clientIds && clientIds.length > 0) { - const roleLabel = role === 'Designer' ? '🎨 Designer' : '💻 Developer' const title = `👋 Novo integrante na equipe!` - const message = `**${name}** acabou de entrar para a equipe como **${roleLabel}**.\n\nClique para conhecer quem faz parte do time! 🚀` + const message = `**${name}** acabou de entrar para a equipe como **${role}**.\n\nClique para conhecer quem faz parte do time! 🚀` await Promise.all( clientIds.map((cid) => createNotificationForClient(cid, title, message, 'new_member')), ) From bb1e7cc460fe46ee8bbf32a26c7b43fa7bd93f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= <matheusaugustosimoes@hotmail.com> Date: Wed, 22 Apr 2026 18:11:15 -0300 Subject: [PATCH 16/21] =?UTF-8?q?docs:=20adicionado=20arquivo=20de=20hist?= =?UTF-8?q?=C3=B3rico=20de=20evolu=C3=A7=C3=A3o=20do=20projeto=20-=20ADR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 1 + docs/decisions.md | 138 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 docs/decisions.md diff --git a/CLAUDE.md b/CLAUDE.md index dbca1ba..3213b81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ npm run lint # ESLint - `.env` nunca commitado - **Testes:** toda nova feature ou bugfix deve ter testes unitários com Vitest. Rodar com `npm run test:run`. Arquivos de teste ficam em `src/**/__tests__/` co-localizados com o módulo testado - **Migrations:** toda alteração de schema no Supabase requer um arquivo SQL em `supabase/migrations/`. Nomenclatura: `YYYYMMDD<seq>_<descricao>.sql` — seq começa em `000000`; se houver mais de uma migration no mesmo dia, incrementar a seq (`000001`, `000002`, …). Nunca alterar migrations já aplicadas. +- **ADRs:** toda decisão arquitetural aceita (nova feature, mudança de stack, padrão novo) deve ser registrada em `docs/decisions.md` com status, decisão, racional e consequências. Usar o próximo número sequencial disponível. ## CI/CD (.github/workflows/) diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..8433bed --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,138 @@ +# Architecture Decision Records + +Registro de decisões arquiteturais significativas do projeto Run/Way. + +--- + +## ADR-001: Zustand vs Redux + +**Status:** Aceito (Abr 2026) +**Decisão:** 5 stores Zustand independentes (useTaskStore, useUIStore, useAdminStore, useClientStore, useMemberStore) +**Racional:** minimal boilerplate, bundle ~2 KB, devtools built-in, sem necessidade de Provider/Context aninhado, middleware `persist` e `devtools` via composição simples +**Consequências:** sem time-travel debug nativo; logging manual necessário quando necessário + +--- + +## ADR-002: TanStack Query para estado servidor + +**Status:** Aceito (Abr 2026) +**Decisão:** Dados do Supabase gerenciados via TanStack Query (hooks `use*Query`), não diretamente em Zustand +**Racional:** cache automático, refetch em background, stale-while-revalidate, deduplicação de requests — eliminam boilerplate de loading/error manual +**Consequências:** dois sistemas de estado em paralelo (Zustand para UI local, TanStack Query para servidor); `useMemberStore` foi depreciado após migração + +--- + +## ADR-003: Roteamento manual sem React Router + +**Status:** Aceito (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` + +--- + +## ADR-004: Vite sem Next.js + +**Status:** Aceito (Abr 2026) +**Decisão:** SPA puro com Vite 8; sem SSR, sem Next.js +**Racional:** aplicação interna de capacity planning — SEO e SSR não são requisitos; Vite oferece HMR instantâneo e build simples; Supabase provê o backend +**Consequências:** sem SSR/SSG; sem roteamento baseado em sistema de arquivos; carregamento inicial depende do bundle client-side + +--- + +## ADR-005: Tailwind CSS v4 sem tailwind.config.js + +**Status:** Aceito (Abr 2026) +**Decisão:** Tailwind v4 via plugin Vite (`@tailwindcss/vite`), configuração inline em `src/index.css` com variáveis CSS no espaço de cor OKLch +**Racional:** v4 elimina o arquivo de config; OKLch permite inversão de paleta (light/dark) com uma variável; plugin Vite integra ao pipeline sem PostCSS separado +**Consequências:** sem `tailwind.config.js` para referenciar tokens via JS; configuração distribuída entre `index.css` e classes utilitárias + +--- + +## ADR-006: Supabase como backend + +**Status:** Aceito (Abr 2026) +**Decisão:** Supabase (PostgreSQL + Auth + Realtime) como única camada de backend; sem API server próprio +**Racional:** elimina custo de manter servidor; Row Level Security garante isolamento de dados por usuário; cliente JS tipado gerado automaticamente; suporte a realtime via WebSocket nativo +**Consequências:** lógica de negócio fica no cliente ou em Edge Functions; migrações de schema são arquivos SQL versionados em `supabase/migrations/` + +--- + +## ADR-007: Radix UI como base de componentes + +**Status:** Aceito (Abr 2026) +**Decisão:** Primitivos Radix UI (Dialog, Select, DropdownMenu, etc.) com estilização via Tailwind; sem biblioteca de componentes completa (ex: MUI, Chakra) +**Racional:** componentes headless, acessíveis por padrão (ARIA, keyboard), sem estilos embutidos — composição livre com Tailwind; bundle tree-shakeable por pacote +**Consequências:** mais código de estilização por componente; sem temas prontos; acessibilidade delegada ao Radix em vez de implementada manualmente + +--- + +## ADR-008: Co-location de componentes por view + +**Status:** Aceito (Abr 2026) +**Decisão:** componentes e hooks privados de uma view ficam em `views/<nome>/components/` e `views/<nome>/hooks/`; sobem para `src/components/` ou `src/hooks/` apenas quando usados por 2+ views +**Racional:** reduz acoplamento acidental; facilita deletar uma feature inteira; explicita qual código é compartilhado vs. específico +**Consequências:** alguma duplicação temporária aceitável antes de promover componentes; imports internos usam `./` enquanto externos usam `@/` + +--- + +## ADR-009: Testes co-localizados com Vitest + +**Status:** Aceito (Abr 2026) +**Decisão:** testes unitários em `src/**/__tests__/` adjacentes ao módulo testado, rodando com Vitest +**Racional:** Vitest é ESM-nativo, compartilha config do Vite, sem duplicar transform pipeline; co-location facilita encontrar o teste junto com o módulo +**Consequências:** sem diretório `tests/` centralizado; cobertura de integração E2E não contemplada neste ADR + +--- + +## ADR-010: LayoutContext para eliminar prop drilling no shell + +**Status:** Aceito (Abr 2026) +**Decisão:** `AppLayout` cria um `LayoutContext.Provider` com três namespaces (`header`, `sidebar`, `router`); `AppHeader`, `AppSidebar` e `AppRouter` são zero-props e consomem o contexto via `useLayoutContext()` +**Racional:** o shell possui muitas props cruzadas entre header, sidebar e router — passá-las manualmente criava acoplamento frágil e dificultava adicionar novos filhos; Context API é o mecanismo idiomático do React para este padrão +**Consequências:** `useLayoutContext()` lança erro se usado fora de `AppLayout`, tornando o escopo explícito; qualquer novo componente filho do shell deve consumir o contexto em vez de receber props + +--- + +## ADR-011: Code splitting via React.lazy em AppRouter + +**Status:** Aceito (Abr 2026) +**Decisão:** `AppRouter` carrega todas as views com `React.lazy` + `Suspense`; cada view vira um chunk separado; fallback é um spinner centralizado (`ViewSkeleton`) +**Racional:** sem code splitting, todo o código de views era carregado no bundle inicial mesmo que o usuário nunca abrisse aquela view; lazy loading reduziu o bundle inicial em ~40% +**Consequências:** primeiro acesso a uma view tem latência de rede do chunk; Suspense é obrigatório ao redor de `AppRouter` + +--- + +## ADR-012: Validação runtime de rows do banco com Zod + +**Status:** Aceito (Abr 2026) +**Decisão:** schemas Zod em `src/lib/validators.ts` validam cada row retornada pelo Supabase antes de mapear para tipos internos; aplicado em `queries.ts`, `AuthContext`, `useAdminStore` e `useProfile` +**Racional:** o TypeScript não alcança o runtime — um schema de banco alterado sem atualizar os tipos faria dados silenciosamente inválidos propagarem pela UI; Zod garante falha explícita com mensagem clara +**Consequências:** erro de schema lança exceção imediatamente, superficializando bugs de contrato; custo de parse por row é negligenciável frente ao I/O de rede + +--- + +## ADR-013: Query keys centralizadas em queries.ts + +**Status:** Aceito (Abr 2026) +**Decisão:** todas as query keys do TanStack Query são exportadas de `src/lib/queries.ts` (`queryKeys.tasks`, `queryKeys.members`); nenhum hook ou componente define sua própria key inline +**Racional:** keys duplicadas ou inconsistentes causam cache miss silencioso e fetches redundantes; centralizar garante que `invalidateQueries` e os hooks de leitura sempre referenciam a mesma key +**Consequências:** adicionar uma nova entidade requer atualizar `queries.ts`; template de novo hook disponível em `src/hooks/__templates__/` + +--- + +## ADR-014: Operações admin exclusivamente em Edge Functions + +**Status:** Aceito (Abr 2026) +**Decisão:** qualquer operação que requer service role key (bypass de RLS) roda em Supabase Edge Functions server-side; o cliente chama via `supabase.functions.invoke` através de `src/lib/adminApi.ts` +**Racional:** expor a service role key no cliente comprometeria toda a segurança do banco; Edge Functions validam o JWT e o `access_role = 'admin'` antes de qualquer acesso privilegiado via `requireAdmin` em `_shared/auth.ts` +**Consequências:** novas operações admin precisam de uma Edge Function correspondente; service role key nunca chega ao bundle do cliente + +--- + +## ADR-015: RLS de members com USING(true) após falha de recursão + +**Status:** Aceito (Abr 2026) +**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) From 5a64d55a7d8426c77aba4a23e91b3e8e57969331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= <matheusaugustosimoes@hotmail.com> Date: Wed, 22 Apr 2026 18:15:42 -0300 Subject: [PATCH 17/21] docs: :memo: Atualizado o ReadMe.md --- README.md | 166 ++++++++++++++++++++++++++++++-------- docs/assets/dashboard.png | Bin 0 -> 180702 bytes 2 files changed, 134 insertions(+), 32 deletions(-) create mode 100644 docs/assets/dashboard.png diff --git a/README.md b/README.md index 00857c4..6966bc1 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,92 @@ -# Capacity Dashboard +<div align="center"> -Aplicação web de capacity planning para equipes de design e desenvolvimento. Permite criar demandas com fases de entrega (Design → Approval → Dev → QA), visualizar em calendário e timeline, e acompanhar a carga de cada membro. +# ⚡ Run/Way -## Stack +**Capacity planning visual para times de design e desenvolvimento.** +Gerencie demandas, acompanhe fases de entrega e visualize a carga da equipe — tudo em tempo real. -- **React 19** + **TypeScript 5.9** + **Vite 8** -- **Tailwind CSS 4** (plugin Vite, sem `tailwind.config.js`) -- **Supabase** (banco de dados + autenticação Google OAuth) -- **Radix UI** + **Lucide React** + **Sonner** +<br/> +![Dashboard](docs/assets/dashboard.png) +<br/> -## Pré-requisitos +![React](https://img.shields.io/badge/React_19-61DAFB?style=for-the-badge&logo=react&logoColor=black) +![TypeScript](https://img.shields.io/badge/TypeScript_5.9-3178C6?style=for-the-badge&logo=typescript&logoColor=white) +![Vite](https://img.shields.io/badge/Vite_8-646CFF?style=for-the-badge&logo=vite&logoColor=white) +![Tailwind](https://img.shields.io/badge/Tailwind_v4-06B6D4?style=for-the-badge&logo=tailwindcss&logoColor=white) +![Supabase](https://img.shields.io/badge/Supabase-3ECF8E?style=for-the-badge&logo=supabase&logoColor=white) + +</div> + +--- + +## ✨ O que é o Run/Way? + +O Run/Way é uma aplicação web de **capacity planning** para equipes de criação. Com ele você: + +- 📋 **Cria e gerencia demandas** com fases de entrega encadeadas automaticamente (Design → Approval → Dev → QA) +- 📅 **Visualiza no calendário** mensal com drag-and-drop de tarefas +- 📊 **Acompanha no Gantt** — timeline por fase com arrastar e soltar +- 👥 **Monitora a capacidade** de cada membro da equipe em tempo real +- 🔔 **Recebe notificações** de tarefas atrasadas, membros sobrecarregados e novos integrantes +- 🌙 **Dark mode** nativo com preferência persistida por usuário +- 🔐 **Login seguro** via Google OAuth com controle de domínio + +--- + +## 🗂️ Documentação + +| Área | Arquivo | +|---|---| +| 🏛️ Arquitetura e fluxo de dados | [docs/architecture.md](docs/architecture.md) | +| 📅 DashboardView, Calendário, Timeline | [docs/views/dashboard.md](docs/views/dashboard.md) | +| ✅ TasksView, filtros, ActionMenu | [docs/views/tasks.md](docs/views/tasks.md) | +| 👥 MembersView, capacidade | [docs/views/members.md](docs/views/members.md) | +| 🛡️ AdminView, painel de usuários | [docs/views/admin.md](docs/views/admin.md) | +| 🙍 ProfileView, preferências | [docs/views/profile.md](docs/views/profile.md) | +| 🪟 TaskModal, cascata de fases | [docs/components/task-modal.md](docs/components/task-modal.md) | +| 🎨 Design system (Button, Input, Badge…) | [docs/components/ui.md](docs/components/ui.md) | +| 🪝 useSupabase, CRUD, steps | [docs/hooks/supabase.md](docs/hooks/supabase.md) | +| 🔑 useAuth, login | [docs/hooks/auth.md](docs/hooks/auth.md) | +| 🔔 useNotifications, NotificationBell | [docs/hooks/notifications.md](docs/hooks/notifications.md) | +| 📆 dateUtils, dias úteis, cascadePhases | [docs/utils/date-utils.md](docs/utils/date-utils.md) | +| 🛠️ ToolsView, BriefingAnalyzerView | [docs/views/tools.md](docs/views/tools.md) | +| 📐 Convenções e padrões | [docs/guidelines.md](docs/guidelines.md) | +| 🗺️ Decisões arquiteturais (ADRs) | [docs/decisions.md](docs/decisions.md) | +| 📝 TODOs e melhorias pendentes | [docs/todo/melhorias.md](docs/todo/melhorias.md) | + +--- + +## 🚀 Começando + +### Pré-requisitos - Node.js 18+ - Conta no [Supabase](https://supabase.com) com projeto criado - Google OAuth configurado no Supabase -## Configuração +### Instalação + +```bash +# 1. Clone o repositório +git clone <url-do-repositório> +cd run-way + +# 2. Instale as dependências +npm install + +# 3. Configure as variáveis de ambiente +cp .env.example .env +# Edite o .env com suas credenciais + +# 4. Inicie o servidor de desenvolvimento +npm run dev +``` + +Acesse em **http://localhost:5173** -1. Clone o repositório -2. Instale as dependências: - ```bash - npm install - ``` -3. Copie o arquivo de exemplo e preencha com suas credenciais: - ```bash - cp .env.example .env - ``` +--- -### Variáveis de ambiente +## ⚙️ Variáveis de Ambiente | Variável | Descrição | |---|---| @@ -40,7 +99,9 @@ Aplicação web de capacity planning para equipes de design e desenvolvimento. P | `VITE_REDIRECT_ALLOWED_PATHS` | Paths permitidos no redirect OAuth (ex: `/,/auth/callback`) | | `VITE_SESSION_MAX_AGE_HOURS` | Duração máxima da sessão em horas (mínimo efetivo: 48) | -## Comandos +--- + +## 🛠️ Comandos ```bash npm run dev # Servidor de desenvolvimento — localhost:5173 @@ -50,20 +111,61 @@ npm run test # Testes em modo watch (Vitest) npm run test:run # Testes em modo CI (execução única) ``` -## Modelo de dados +--- + +## 🗃️ Modelo de Dados + +### Fases de entrega + +Cada demanda passa por 4 fases com **cascata automática de datas** em dias úteis: + +| Fase | ⏱️ Duração padrão | 🎨 Cor | +|---|---|---| +| 🎨 Design | 5 dias úteis | Violeta | +| ✅ Approval | 3 dias úteis | Laranja | +| 💻 Dev | 7 dias úteis | Azul | +| 🧪 QA | 3 dias úteis | Esmeralda | + +### Entidades principais + +**Task** — demanda com fases de entrega +- `status`: `backlog` · `em andamento` · `bloqueado` · `concluído` +- `phases`: cada fase com `start` e `end` no formato `YYYY-MM-DD` -**Task** — uma demanda com fases de entrega: -- `status`: `backlog` | `em andamento` | `bloqueado` | `concluído` -- `phases`: `design` · `approval` · `dev` · `qa` — cada fase com `start` e `end` (`YYYY-MM-DD`) -- Cascata automática de datas entre fases +**Member** — membro da equipe +- `role`: `Designer` | `Developer` +- `access_role`: `admin` | `user` -**Member** — membro da equipe com `role`: `Designer` | `Developer` +--- -**Fases e durações padrão:** +## 🏗️ Stack -| Fase | Duração | Cor | +| Camada | Tecnologia | +|---|---| +| Frontend | React 19 + TypeScript 5.9 + Vite 8 | +| Estilização | Tailwind CSS v4 (plugin Vite) | +| Backend / DB | Supabase (PostgreSQL + Auth + Edge Functions) | +| Estado / Cache | TanStack Query v5 + Zustand | +| UI Primitivos | Radix UI + Lucide React + Sonner | +| Testes | Vitest | +| CI/CD | GitHub Actions | + +--- + +## 🔒 Segurança e CI/CD + +| Workflow | Trigger | O que faz | |---|---|---| -| Design | 5 dias úteis | Violeta | -| Approval | 3 dias úteis | Laranja | -| Dev | 7 dias úteis | Azul | -| QA | 3 dias úteis | Esmeralda | +| `secrets.yml` | push + PR→main | Scan de secrets com Gitleaks | +| `test.yml` | push + PR→main | Vitest + ESLint em paralelo | +| `codeql.yml` | push/PR→main + semanal | Análise de vulnerabilidades CodeQL | +| `tag-version.yml` | push→main | Tag git automática ao bumpar versão | +| `no-friday-deploy.yml` | PR→main | Bloqueia merge às sextas-feiras | + +--- + +<div align="center"> + +Feito com ☕ pelo time Run/Way + +</div> diff --git a/docs/assets/dashboard.png b/docs/assets/dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..eb7f4a5802f938a3deb3e01ce4ff0ccf6fef59d4 GIT binary patch literal 180702 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 In5p;w1Ilid!~g&Q literal 0 HcmV?d00001 From 9ef3ad99582e9723d4923321705df6e2987c525e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= <matheusaugustosimoes@hotmail.com> Date: Wed, 22 Apr 2026 18:19:23 -0300 Subject: [PATCH 18/21] fix: :rotating_light: ajuste de linting --- src/contexts/{LayoutContext.tsx => LayoutContext.ts} | 0 src/hooks/__templates__/useEntityQuery.template.ts | 2 +- src/hooks/useThrottledMutation.ts | 1 - src/store/useAdminStore.ts | 2 +- src/utils/__tests__/dateUtils.test.ts | 4 ++-- 5 files changed, 4 insertions(+), 5 deletions(-) rename src/contexts/{LayoutContext.tsx => LayoutContext.ts} (100%) diff --git a/src/contexts/LayoutContext.tsx b/src/contexts/LayoutContext.ts similarity index 100% rename from src/contexts/LayoutContext.tsx rename to src/contexts/LayoutContext.ts diff --git a/src/hooks/__templates__/useEntityQuery.template.ts b/src/hooks/__templates__/useEntityQuery.template.ts index a65b635..cdb6649 100644 --- a/src/hooks/__templates__/useEntityQuery.template.ts +++ b/src/hooks/__templates__/useEntityQuery.template.ts @@ -17,7 +17,7 @@ * // em caso de erro: queryClient.setQueryData(queryKeys.entity(param), prev) */ -import { useQuery } from '@tanstack/react-query' +import { useQuery as _useQuery } from '@tanstack/react-query' // import { fetchEntityFromDb, queryKeys } from '@/lib/queries' // export function useEntityQuery(param: string | null | undefined) { diff --git a/src/hooks/useThrottledMutation.ts b/src/hooks/useThrottledMutation.ts index 01327e6..96291e5 100644 --- a/src/hooks/useThrottledMutation.ts +++ b/src/hooks/useThrottledMutation.ts @@ -22,7 +22,6 @@ export function useThrottledMutation<T extends unknown[], R>( lastCall.current = now return fn(...args) }, - // eslint-disable-next-line react-hooks/exhaustive-deps [fn, ms], ) } diff --git a/src/store/useAdminStore.ts b/src/store/useAdminStore.ts index 225382d..274900c 100644 --- a/src/store/useAdminStore.ts +++ b/src/store/useAdminStore.ts @@ -11,7 +11,7 @@ import { type PendingAuthUser, } from '@/lib/adminApi' import { toSafeUiErrorMessage } from '@/lib/errorSanitizer' -import { DbClientRowSchema, DbMemberRowSchema, DbUserClientMapRowSchema } from '@/lib/validators' +import { DbClientRowSchema, DbMemberRowSchema } from '@/lib/validators' export type { PendingAuthUser } diff --git a/src/utils/__tests__/dateUtils.test.ts b/src/utils/__tests__/dateUtils.test.ts index 9d63231..ef584e2 100644 --- a/src/utils/__tests__/dateUtils.test.ts +++ b/src/utils/__tests__/dateUtils.test.ts @@ -14,9 +14,9 @@ describe('dateUtils', () => { const tuesday = new Date(2026, 3, 7) const friday = new Date(2026, 3, 10) const saturday = new Date(2026, 3, 11) - const sunday = new Date(2026, 3, 12) + const _sunday = new Date(2026, 3, 12) const mondayNext = new Date(2026, 3, 13) - const tuesdayNext = new Date(2026, 3, 14) + const _tuesdayNext = new Date(2026, 3, 14) describe('formatDate', () => { it('formata Date para YYYY-MM-DD', () => { From 113c9c804abf7608a8275f50def2d4e2e3d4edd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= <matheusaugustosimoes@hotmail.com> Date: Wed, 22 Apr 2026 21:54:07 -0300 Subject: [PATCH 19/21] fix: :bug: testes ajustado com escopo de cascade --- src/utils/dateUtils.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index 6e75b39..fe0c136 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -67,13 +67,13 @@ export const cascadePhases = (startDesignDate: Date | string, holidays: Holiday[ const designStart = formatDate(startDesignDate); const designEnd = addBusinessDays(designStart, DEFAULT_DURATIONS.design, holidays); - const approvalStart = nextBusinessDay(designEnd, holidays); + const approvalStart = designEnd; const approvalEnd = addBusinessDays(approvalStart, DEFAULT_DURATIONS.approval, holidays); - const devStart = nextBusinessDay(approvalEnd, holidays); + const devStart = approvalEnd; const devEnd = addBusinessDays(devStart, DEFAULT_DURATIONS.dev, holidays); - const qaStart = nextBusinessDay(devEnd, holidays); + const qaStart = devEnd; const qaEnd = addBusinessDays(qaStart, DEFAULT_DURATIONS.qa, holidays); return { @@ -81,7 +81,7 @@ export const cascadePhases = (startDesignDate: Date | string, holidays: Holiday[ approval: { start: approvalStart, end: approvalEnd }, dev: { start: devStart, end: devEnd }, qa: { start: qaStart, end: qaEnd } - }; + } }; export const businessDaysBetween = (start: string, end: string): number => { From 9daab6dc05da76f4f49a919040bd56fdcc830b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= <matheusaugustosimoes@hotmail.com> Date: Wed, 22 Apr 2026 21:59:38 -0300 Subject: [PATCH 20/21] =?UTF-8?q?fix:=20:bug:=20ajustes=20nos=20teste=20un?= =?UTF-8?q?it=C3=A3rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/adminApi.ts | 5 +++-- src/lib/queries.ts | 10 ++++++++-- src/types/db.ts | 2 +- src/views/admin/hooks/useAdminData.ts | 6 ++---- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/lib/adminApi.ts b/src/lib/adminApi.ts index 466bcf0..da5ed6d 100644 --- a/src/lib/adminApi.ts +++ b/src/lib/adminApi.ts @@ -1,6 +1,7 @@ import { supabase } from './supabase' import type { DbClientRow, DbAuditLogRow } from '@/types/db' import type { Member } from '@/hooks/useSupabase' +import type { Notification } from '@/types/notification' import type { AuditFilters } from '@/store/useAdminStore' const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string @@ -142,8 +143,8 @@ export async function adminSetUserRole(userId: string, role: 'admin' | 'user'): // ── notifications ───────────────────────────────────────────────────────────── -export async function adminFetchAllNotifications() { - return invoke<unknown[]>('admin-notifications') +export async function adminFetchAllNotifications(): Promise<Notification[]> { + return invoke<Notification[]>('admin-notifications') } export async function adminCreateNotification( diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 91bf14a..2d61a00 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -90,7 +90,10 @@ export async function fetchMembersFromDb( .select('id, name, role, avatar, avatar_url, email, auth_user_id, access_role') .order('name') if (error) throw new Error(error.message) - return data ?? [] + return (data ?? []).map(m => ({ + ...m, + access_role: m.access_role as Member['access_role'] + })) } const ucResult = await supabase @@ -110,5 +113,8 @@ export async function fetchMembersFromDb( .order('name') if (error) throw new Error(error.message) - return data ?? [] + return (data ?? []).map(m => ({ + ...m, + access_role: m.access_role as Member['access_role'] + })) } diff --git a/src/types/db.ts b/src/types/db.ts index 5ad0380..8e539fe 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -41,7 +41,7 @@ export interface DbClientRow { id: string name: string slug: string - created_at: string + created_at?: string | null } export type Client = DbClientRow diff --git a/src/views/admin/hooks/useAdminData.ts b/src/views/admin/hooks/useAdminData.ts index 6b541fa..580067e 100644 --- a/src/views/admin/hooks/useAdminData.ts +++ b/src/views/admin/hooks/useAdminData.ts @@ -212,8 +212,7 @@ export function useAdminData(options: UseAdminDataOptions = {}) { const { deactivated_at } = await adminDeactivateMember(userId) patchUser(userId, { is_active: false, deactivated_at }) return true - } catch (err) { - console.error('[deactivateUser] error:', err) + } catch { return false } }, [patchUser]) @@ -223,8 +222,7 @@ export function useAdminData(options: UseAdminDataOptions = {}) { await adminReactivateMember(userId) patchUser(userId, { is_active: true, deactivated_at: null }) return true - } catch (err) { - console.error('[reactivateUser] error:', err) + } catch { return false } }, [patchUser]) From 214229a6721ef01c9140a622228ce02e89c39447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= <matheusaugustosimoes@hotmail.com> Date: Wed, 22 Apr 2026 22:02:51 -0300 Subject: [PATCH 21/21] test: :bug: corrigido ambiente supabase pra tests --- src/test-setup.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/test-setup.ts b/src/test-setup.ts index c37287b..2b5ec47 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -2,6 +2,14 @@ import '@testing-library/jest-dom' import { cleanup } from '@testing-library/react' import { afterEach, vi } from 'vitest' +vi.mock('./lib/supabase', () => ({ + supabase: { + from: vi.fn(() => ({ select: vi.fn(() => ({ order: vi.fn(() => ({ data: [], error: null })) })) })), + }, +})) + +vi.mock('./lib/notifications', () => ({})) + afterEach(() => { cleanup() })