diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..076a6fc --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,28 @@ +{ + "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..." + } + ] + } + ] + }, + "permissions": { + "deny": [ + "Read(.env*)", + "Edit(.env*)", + "Bash(cat *.env*)", + "Bash(head *.env*)", + "Bash(tail *.env*)", + "Bash(less *.env*)", + "Bash(more *.env*)" + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e1ddaa6..0c3ddd5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -38,7 +38,15 @@ "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\\)\")", + "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(node -e \"const rw = require\\('react-window'\\); console.log\\(Object.keys\\(rw\\)\\)\")" ] }, "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/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_.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/README.md b/README.md index 00857c4..6966bc1 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,92 @@ -# Capacity Dashboard +
-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** +
+![Dashboard](docs/assets/dashboard.png) +
-## 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) + +
+ +--- + +## ✨ 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 +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 | + +--- + +
+ +Feito com ☕ pelo time Run/Way + +
diff --git a/docs/architecture.md b/docs/architecture.md index d7b56cc..221ce97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,21 +31,27 @@ 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 +│ ├── 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 │ └── utils.ts # Utilitários gerais ├── types/ @@ -62,11 +68,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 @@ -80,7 +86,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 ``` @@ -112,29 +118,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 +196,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 +242,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 @@ -280,8 +289,44 @@ 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: + +```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 (4 stores separadas por responsabilidade: UI, Client, Tasks, Members) -- `any` intencional em dados do DB sem schema fixo em runtime +- 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__/` +- 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/assets/dashboard.png b/docs/assets/dashboard.png new file mode 100644 index 0000000..eb7f4a5 Binary files /dev/null and b/docs/assets/dashboard.png differ 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//components/` e `views//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) 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/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` | 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:` +tabela: public.user_clients +filtro: user_id = eq. +``` + +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/docs/hooks/notifications.md b/docs/hooks/notifications.md index 3f2f7eb..e25f490 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,16 @@ 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! 🚀" | +| `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." | + +> `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 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/docs/hooks/supabase.md b/docs/hooks/supabase.md index 5f0f85b..68601f4 100644 --- a/docs/hooks/supabase.md +++ b/docs/hooks/supabase.md @@ -5,29 +5,45 @@ ## 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 + +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`) ``` -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 +54,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..c3ae75b --- /dev/null +++ b/docs/tech-audit.md @@ -0,0 +1,435 @@ +# 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 <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<Database>(url, key) +// Agora supabase.from('tasks').select() retorna Task['Row'][] — zero any +``` + +**Prioridade:** Alta + +--- + +### ~~Problema: Props gigantes em AppLayout~~ ✅ Resolvido + +`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()`. + +**Prioridade:** ~~Alta~~ Concluído + +--- + +### 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().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(), + step_assignees: z.array(z.object({ member_id: z.string() })).default([]), +}) + +const DbTaskRowSchema = z.object({ + id: z.string().min(1), + 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 +``` + +> **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 + +--- + +## 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 ( + <Suspense fallback={<ViewSkeleton />}> + {view === 'calendar' && <DashboardView subview="calendar" {...props} />} + {view === 'members' && <MembersView {...props} />} + {/* ... */} + </Suspense> + ) +} +``` + +**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' + +<List height={600} itemCount={members.length} itemSize={60} width="100%"> + {({ index, style }) => ( + <div style={style}> + <MemberRow member={members[index]} /> + </div> + )} +</List> +``` + +**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~~ ✅ Resolvido + +`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. + +--- + +### 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~~ ✅ Resolvido + +`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. + +**Prioridade:** ~~Média~~ Concluído + +--- + +## 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)~~ ✅ Resolvido + +Testes unitários adicionados para: + +| Arquivo | Cobertura | +|---|---| +| `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 + +```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/<nome>/`) +- 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/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/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/docs/views/members.md b/docs/views/members.md index 6d577cd..6329be9 100644 --- a/docs/views/members.md +++ b/docs/views/members.md @@ -1,100 +1,60 @@ # 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 + +- `useMembersQuery(effectiveClientId)` — TanStack Query, dados do Supabase +- `useClients()` — resolve o `effectiveClientId` do cliente selecionado + +## Lógica de hierarquia + +A view exibe dois níveis fixos: + +1. **Admins** (`access_role === 'admin'`) — linha superior, todos com igual importância +2. **Users** (`access_role !== 'admin'`) — linha inferior, todos com igual importância + +Membros com `is_active === false` são filtrados antes da renderização. -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. +## Conector SVG -## Normalização de Tasks +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: -Todas as tasks passam por `normalizeTask()` antes do processamento: +- 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 + +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 + +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 | -```ts -function normalizeTask(task: Task | LegacyTask): Task -``` +> **Nota:** `HierarchyMemberCard` e `HierarchyAvatar` são distintos de `MemberCard` (em `components/MemberCard.tsx`), que é o card de capacidade usado na view de capacidade. -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. +## Layout -## Lógica de Capacidade +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. -Para cada membro, coleta todos os steps onde: -- `step.active === true` -- `step.start` e `step.end` existem -- `step.assignees.includes(member.id)` +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`. -A **carga atual** (`activeCount`) é o subconjunto desses steps cujo intervalo inclui hoje (`step.start <= today && step.end >= today`). +### Token `--card` vs `--background` -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" +Os tokens foram ajustados em `src/index.css` para criar contraste visível entre o card e o fundo da página: -| `activeCount` | Status | Cor do badge | +| Modo | `--background` | `--card` | |---|---|---| -| 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<string, { task, steps[] }>`. 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 | -|---|---| -| `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 | - -## Filtragem por cliente - -`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. - -## Notas - -- `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` +| 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/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 a12aec1..f02b949 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", @@ -27,10 +28,12 @@ "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", "vaul": "^1.1.2", + "zod": "^4.3.6", "zustand": "^5.0.12" }, "devDependencies": { @@ -42,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", @@ -297,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" @@ -3263,6 +3266,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", @@ -3472,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", @@ -5858,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", @@ -6920,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", @@ -7993,7 +8055,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 5a8872f..6e6da5f 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", @@ -31,10 +32,12 @@ "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", "vaul": "^1.1.2", + "zod": "^4.3.6", "zustand": "^5.0.12" }, "devDependencies": { @@ -46,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/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 ( <header className="bg-neutral-100 dark:bg-neutral-950 border-b border-border sticky top-0 z-10 print:hidden"> <div className="px-4 h-16 flex items-center justify-between"> @@ -59,14 +48,14 @@ export function AppHeader({ <NotificationBell notifications={notifications} unreadCount={unreadCount} - onMarkAsRead={onMarkNotificationAsRead ?? (() => {})} - onMarkAllAsRead={onMarkAllNotificationsAsRead ?? (() => {})} - onNotificationClick={onNotificationClick ?? (() => {})} - reload={reloadNotifications} + onMarkAsRead={onMarkNotificationAsRead} + onMarkAllAsRead={onMarkAllNotificationsAsRead} + onNotificationClick={onNotificationClick} + reload={onReloadNotifications} selectedClientId={selectedClientId ?? undefined} /> </div> </div> </header> - ); + ) } 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<void>; - onMarkAllNotificationsAsRead: () => Promise<void>; - onNotificationClick: (notification: Notification) => void; - onReloadNotifications: () => Promise<void>; - - // 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<boolean>; + darkMode: boolean + onToggleDark: () => void + notifications: Notification[] + unreadCount: number + selectedClientId: string | null + onToggleMobileSidebar: () => void + onMarkNotificationAsRead: (id: string) => Promise<void> + onMarkAllNotificationsAsRead: () => Promise<void> + onNotificationClick: (notification: Notification) => void + onReloadNotifications: () => Promise<void> + 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<boolean> } -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 ( - <div className="flex flex-col h-screen bg-background text-foreground font-sans"> - <AppHeader - darkMode={darkMode} - onToggleDark={onToggleDark} - notifications={notifications} - unreadCount={unreadCount} - selectedClientId={selectedClientId} - onToggleMobileSidebar={onToggleMobileSidebar} - onMarkNotificationAsRead={onMarkNotificationAsRead} - onMarkAllNotificationsAsRead={onMarkAllNotificationsAsRead} - onNotificationClick={onNotificationClick} - reloadNotifications={onReloadNotifications} - /> + <LayoutContext.Provider value={{ + view, + header: { + darkMode, onToggleDark, + notifications, unreadCount, selectedClientId, + onToggleMobileSidebar, + onMarkNotificationAsRead, onMarkAllNotificationsAsRead, + onNotificationClick, onReloadNotifications, + }, + sidebar: { + sidebarOpen, mobileSidebarOpen, onToggleSidebar, onCloseMobileSidebar, + view, onViewChange, hasClients, role, + userEmail, userAvatarUrl, onSignOut, + selectedClient, availableClients, onSelectClient, isAdmin, + darkMode, onToggleDark, + }, + router: { + effectiveClientId, selectedClient, userName, userEmail, + holidays, hasClients, onViewChange, + onEditTask, onOpenNewTask, onDeleteTask, onUpdateTask, + }, + }}> + <div className="flex flex-col h-screen bg-background text-foreground font-sans"> + <AppHeader /> - <div className="flex flex-row flex-1 overflow-hidden"> - <AppSidebar - open={sidebarOpen} - onToggle={onToggleSidebar} - mobileOpen={mobileSidebarOpen} - onCloseMobile={onCloseMobileSidebar} - view={view} - onViewChange={onViewChange} - hasClient={hasClients} - role={role} - darkMode={darkMode} - onToggleDark={onToggleDark} - userEmail={userEmail} - userAvatarUrl={userAvatarUrl} - onSignOut={onSignOut} - selectedClient={selectedClient} - availableClients={availableClients} - onSelectClient={onSelectClient} - isAdmin={isAdmin} - /> + <div className="flex flex-row flex-1 overflow-hidden"> + <AppSidebar /> - <main - key={view} - className={cn( - "flex-1 overflow-auto animate-blur-fade-in", - view === "home" ? "p-0" : "px-4 sm:px-6 lg:px-8 py-8" - )} - > - <TooltipProvider> - <AppRouter - view={view} - hasClients={hasClients} - effectiveClientId={effectiveClientId} - selectedClient={selectedClient} - userName={userName} - userEmail={userEmail} - holidays={holidays} - onViewChange={onViewChange} - onEditTask={onEditTask} - onOpenNewTask={onOpenNewTask} - onDeleteTask={onDeleteTask} - onUpdateTask={onUpdateTask} - /> - </TooltipProvider> - </main> + <main + key={view} + className={cn( + "flex-1 overflow-auto animate-blur-fade-in", + view === "home" ? "p-0" : "px-4 sm:px-6 lg:px-8 py-8" + )} + > + <TooltipProvider> + <AppRouter /> + </TooltipProvider> + </main> + </div> </div> - </div> - ); + </LayoutContext.Provider> + ) } diff --git a/src/components/AppRouter.tsx b/src/components/AppRouter.tsx index 6f91d24..251cbd9 100644 --- a/src/components/AppRouter.tsx +++ b/src/components/AppRouter.tsx @@ -1,19 +1,28 @@ -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 { 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"; + +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 ( + <div className="flex h-full w-full items-center justify-center"> + <div className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary" /> + </div> + ); +} + type DashboardSubview = "calendar" | "timeline" | "list"; type ToolsSubview = Extract<ViewType, "tools-briefing-analyzer" | "tools-import" | "tools-export" | "tools-integrations">; @@ -30,39 +39,20 @@ const REPORTS_SUBVIEW_MAP: Partial<Record<ViewType, ReportsSubview>> = { "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<boolean>; -} - -/** - * 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 || ""; @@ -74,65 +64,53 @@ export function AppRouter({ return <NoClientView hasClients={true} onGoToClients={goToClients} />; } - if (view === "home") { - return ( - <HomeView - userName={displayName} - clientName={selectedClient?.name} - hasClient={!!effectiveClientId} - onViewChange={onViewChange} - /> - ); - } - - if (view === "admin") { - return <RequireAdmin><AdminView /></RequireAdmin>; - } - - if (view === "clients") { - return <UserClientsView client={selectedClient ?? null} />; - } - - if (DASHBOARD_VIEWS.has(view)) { - return ( - <DashboardView - subview={view as DashboardSubview} - onEdit={onEditTask} - onDelete={onDeleteTask} - onUpdateTask={onUpdateTask} - onOpenNew={onOpenNewTask} - onExport={() => window.print()} - holidays={holidays} - /> - ); - } - - if (view === "demandas") { - return <TasksView onEdit={onEditTask} onOpenNew={onOpenNewTask} />; - } - - if (view === "profile") { - return <ProfileView />; - } - - if (view === "members") { - return <MembersView />; - } - - if (TOOLS_VIEWS.has(view)) { - return <ToolsView subview={view === "tools" ? undefined : view as ToolsSubview} />; - } - - if (REPORTS_VIEWS.has(view)) { - return <ReportsView subview={REPORTS_SUBVIEW_MAP[view]} />; - } - return ( - <HomeView - userName={displayName} - clientName={selectedClient?.name} - hasClient={!!effectiveClientId} - onViewChange={onViewChange} - /> + <Suspense fallback={<ViewSkeleton />}> + {view === "home" && ( + <HomeView + userName={displayName} + clientName={selectedClient?.name} + hasClient={!!effectiveClientId} + onViewChange={onViewChange} + /> + )} + + {view === "admin" && <RequireAdmin><AdminView /></RequireAdmin>} + + {view === "clients" && <UserClientsView client={selectedClient ?? null} />} + + {DASHBOARD_VIEWS.has(view) && ( + <DashboardView + subview={view as DashboardSubview} + onEdit={onEditTask} + onDelete={onDeleteTask} + onUpdateTask={onUpdateTask} + onOpenNew={onOpenNewTask} + onExport={() => window.print()} + holidays={holidays} + /> + )} + + {view === "demandas" && <TasksView onEdit={onEditTask} onOpenNew={onOpenNewTask} />} + + {view === "profile" && <ProfileView />} + + {view === "members" && <MembersView />} + + {TOOLS_VIEWS.has(view) && ( + <ToolsView subview={view === "tools" ? undefined : view as ToolsSubview} /> + )} + + {REPORTS_VIEWS.has(view) && <ReportsView subview={REPORTS_SUBVIEW_MAP[view]} />} + + {!view && ( + <HomeView + userName={displayName} + clientName={selectedClient?.name} + hasClient={!!effectiveClientId} + onViewChange={onViewChange} + /> + )} + </Suspense> ); } 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<string[]>(() => { const initial: string[] = [] if (CALENDAR_VIEWS.includes(view)) initial.push("Calendário") 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<string, string> = { } 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<Set<string>>(new Set()) @@ -325,4 +326,9 @@ export function NotificationBell({ </DropdownMenuContent> </DropdownMenu> ) -} +}, + (prev, next) => + prev.unreadCount === next.unreadCount && + prev.notifications.length === next.notifications.length && + prev.selectedClientId === next.selectedClientId +) 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<TaskModalProps> = ({ 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/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 4d2734f..f8590e2 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,9 +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 @@ -53,17 +56,19 @@ 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 { - 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 +86,47 @@ 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) + memberRef.current = memberData as Member 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) @@ -126,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 @@ -179,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/contexts/LayoutContext.ts b/src/contexts/LayoutContext.ts new file mode 100644 index 0000000..c62f005 --- /dev/null +++ b/src/contexts/LayoutContext.ts @@ -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<void> + onMarkAllNotificationsAsRead: () => Promise<void> + onNotificationClick: (notification: Notification) => void + onReloadNotifications: () => Promise<void> +} + +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<boolean> +} + +interface LayoutCtx { + header: HeaderCtx + sidebar: SidebarCtx + router: RouterCtx + view: ViewType +} + +const LayoutContext = createContext<LayoutCtx | null>(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 } diff --git a/src/hooks/__templates__/useEntityQuery.template.ts b/src/hooks/__templates__/useEntityQuery.template.ts new file mode 100644 index 0000000..cdb6649 --- /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/use<Entity>Query.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 } = use<Entity>Query(...) + * + * 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 as _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..5d7b18b 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, @@ -88,11 +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/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<TransitionTarget | null>(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/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<string | null>(null) const loadedRef = useRef(false) + const channelRef = useRef<ReturnType<typeof supabase.channel> | 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/hooks/useSupabase.ts b/src/hooks/useSupabase.ts index 53aa3b5..3c8b8f3 100644 --- a/src/hooks/useSupabase.ts +++ b/src/hooks/useSupabase.ts @@ -1,10 +1,13 @@ 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' 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) @@ -81,13 +84,15 @@ 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() - 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<Task, 'id' | 'createdAt'>): Promise<boolean> => { const resolvedClientId = taskData.clientId ?? clientId ?? null @@ -112,7 +117,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 +133,35 @@ export function useSupabase(options: UseSupabaseOptions = {}) { }) } - await refresh() + invalidateTasks() return true - }, [clientId, memberId, refresh]) + }, [clientId, memberId, invalidateTasks]) const updateTask = useCallback(async (taskData: Task): Promise<boolean> => { const resolvedClientId = taskData.clientId ?? clientId ?? null - const previousTasks = useTaskStore.getState().tasks - const prevTask = previousTasks.find(t => t.id === taskData.id) + + const cachedTasks = queryClient.getQueryData<Task[]>( + 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 +178,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 +222,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 +239,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 +258,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 +270,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 +309,14 @@ export function useSupabase(options: UseSupabaseOptions = {}) { } return true - }, [clientId, memberId, refresh]) + }, [clientId, isAdmin, memberId, queryClient, invalidateTasks]) const deleteTask = useCallback(async (id: string): Promise<boolean> => { - const deletedTask = useTaskStore.getState().tasks.find(t => t.id === id) + const cachedTasks = queryClient.getQueryData<Task[]>( + 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 +324,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 +343,11 @@ export function useSupabase(options: UseSupabaseOptions = {}) { } return true - }, [memberId]) + }, [clientId, isAdmin, memberId, queryClient]) + + const throttledCreateTask = useThrottledMutation(createTask, MUTATION_THROTTLE_MS) + const throttledUpdateTask = useThrottledMutation(updateTask, MUTATION_THROTTLE_MS) + const throttledDeleteTask = useThrottledMutation(deleteTask, MUTATION_THROTTLE_MS) - return { createTask, updateTask, deleteTask } + 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/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/hooks/useThrottledMutation.ts b/src/hooks/useThrottledMutation.ts new file mode 100644 index 0000000..96291e5 --- /dev/null +++ b/src/hooks/useThrottledMutation.ts @@ -0,0 +1,27 @@ +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<T extends unknown[], R>( + fn: (...args: T) => Promise<R>, + ms: number, +): (...args: T) => Promise<R | false> { + 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) + }, + [fn, ms], + ) +} 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/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/lib/adminApi.ts b/src/lib/adminApi.ts new file mode 100644 index 0000000..da5ed6d --- /dev/null +++ b/src/lib/adminApi.ts @@ -0,0 +1,172 @@ +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 +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string + +type InvokeOptions = { method?: string; body?: unknown; query?: Record<string, string> } + +async function invoke<T>(fn: string, options?: InvokeOptions): Promise<T> { + 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<T> +} + +function q(params: Record<string, string | undefined>): Record<string, string> { + const result: Record<string, string> = {} + for (const [k, v] of Object.entries(params)) if (v !== undefined) result[k] = v + return result +} + +// ── clients ────────────────────────────────────────────────────────────────── + +export async function adminFetchClients(): Promise<DbClientRow[]> { + return invoke<DbClientRow[]>('admin-clients') +} + +export async function adminCreateClient(name: string, slug: string): Promise<void> { + await invoke('admin-clients', { method: 'POST', body: { name, slug } }) +} + +export async function adminUpdateClient(id: string, name: string, slug: string): Promise<void> { + await invoke('admin-clients', { method: 'PUT', query: q({ id }), body: { name, slug } }) +} + +export async function adminDeleteClient(id: string, name: string, actorUserId?: string): Promise<void> { + await invoke('admin-clients', { method: 'DELETE', query: q({ id }), body: { name, actorUserId } }) +} + +// ── members ─────────────────────────────────────────────────────────────────── + +export async function adminFetchMembers(): Promise<Member[]> { + return invoke<Member[]>('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<Member> { + return invoke<Member>('admin-members', { method: 'POST', body: payload }) +} + +export async function adminUpdateMember(id: string, name: string, role: string, email?: string | null): Promise<void> { + 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<void> { + 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<void> { + 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<PendingAuthUser[]> { + return invoke<PendingAuthUser[]>('admin-users', { query: q({ action: 'listPending' }) }) +} + +export async function adminListAuthUsers(search?: string): Promise<GoogleUser[]> { + return invoke<GoogleUser[]>('admin-users', { query: q({ action: 'listAuthUsers', search }) }) +} + +export async function adminFetchUserClientsMap(): Promise<Record<string, string[]>> { + return invoke<Record<string, string[]>>('admin-users', { query: q({ action: 'userClientsMap' }) }) +} + +export async function adminFetchAuditLogs(filters: AuditFilters = {}): Promise<DbAuditLogRow[]> { + return invoke<DbAuditLogRow[]>('admin-users', { query: q({ action: 'auditLogs', ...filters }) }) +} + +export async function adminLinkUserToClient(userId: string, clientId: string): Promise<void> { + await invoke('admin-users', { method: 'POST', query: q({ action: 'linkUser' }), body: { userId, clientId } }) +} + +export async function adminUnlinkUserFromClient(userId: string, clientId: string): Promise<void> { + await invoke('admin-users', { method: 'POST', query: q({ action: 'unlinkUser' }), body: { userId, clientId } }) +} + +export async function adminSetUserRole(userId: string, role: 'admin' | 'user'): Promise<void> { + await invoke('admin-users', { method: 'POST', query: q({ action: 'setRole' }), body: { userId, role } }) +} + +// ── notifications ───────────────────────────────────────────────────────────── + +export async function adminFetchAllNotifications(): Promise<Notification[]> { + return invoke<Notification[]>('admin-notifications') +} + +export async function adminCreateNotification( + title: string, + message: string, + userId?: string, + clientId?: string, + type = 'admin_broadcast', + metadata?: Record<string, string> | null, +): Promise<void> { + 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<void> { + 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..06695af 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -1,4 +1,10 @@ -import { supabase, supabaseAdmin } from './supabase' +import { supabase } from './supabase' +import { + adminFetchAllNotifications as apiFetchAll, + 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[]) { @@ -23,10 +29,10 @@ 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) : [] } -export async function markAsRead(id: string) { +async function _markAsRead(id: string) { const { error } = await supabase .from('notifications') .update({ read: true }) @@ -35,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) { @@ -54,19 +60,12 @@ export async function markAllAsRead(userId: string, clientIds?: string[]) { if (error) throw error } -export async function fetchAllNotifications() { - if (!supabaseAdmin) { - throw new Error('Admin não configurado') - } +export const markAsRead = throttleAsync(_markAsRead, 300) +export const markAllAsRead = throttleAsync(_markAllAsRead, 1000) - const { data, error } = await supabaseAdmin - .from('notifications') - .select('*') - .order('created_at', { ascending: false }) - .limit(100) - - if (error) throw error - return data ? data.map(mapDbToNotification) : [] +export async function fetchAllNotifications() { + const data = await apiFetchAll() + return (data as DbNotificationRow[]).map(mapDbToNotification) } export async function createNotification( @@ -75,83 +74,41 @@ export async function createNotification( userId?: string, clientId?: string, type: string = 'admin_broadcast', - metadata?: Record<string, string> | null + metadata?: Record<string, string> | 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 { - 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' } @@ -162,6 +119,8 @@ 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' diff --git a/src/lib/queries.ts b/src/lib/queries.ts new file mode 100644 index 0000000..2d61a00 --- /dev/null +++ b/src/lib/queries.ts @@ -0,0 +1,120 @@ +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 ─────────────────────────────────────────────────────────────── + +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 parsed = DbTaskRowSchema.parse(row) + const steps: Step[] = parsed.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 => a.member_id), + })) + + return { + id: parsed.id, + title: parsed.title, + clickupLink: parsed.clickup_link ?? undefined, + clientId: parsed.client_id ?? undefined, + status: { + blocked: parsed.blocked, + blockedAt: parsed.blocked_at ?? undefined, + }, + createdAt: parsed.created_at, + concludedAt: parsed.concluded_at ?? undefined, + concludedBy: parsed.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<Task[]> { + 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<Member[]> { + 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 ?? []).map(m => ({ + ...m, + access_role: m.access_role as Member['access_role'] + })) + } + + 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 ?? []).map(m => ({ + ...m, + access_role: m.access_role as Member['access_role'] + })) +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 3bcd2db..613ef31 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -1,12 +1,7 @@ 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) - -/** Usar APENAS em contexto admin — bypassa RLS */ -export const supabaseAdmin = serviceRoleKey - ? createClient(supabaseUrl, serviceRoleKey) - : null +export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey) 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<T extends unknown[], R>( + fn: (...args: T) => Promise<R>, + ms: number, +): (...args: T) => Promise<R | false> { + 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/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<typeof DbTaskRowSchema> + +// ─── 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<typeof DbMemberRowSchema> + +// ─── 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<typeof DbClientRowSchema> + +// ─── 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<typeof DbUserPreferencesSchema> + +// ─── 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/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( <StrictMode> - <AuthProvider> - <App /> - </AuthProvider> + <QueryClientProvider client={queryClient}> + <AuthProvider> + <App /> + </AuthProvider> + </QueryClientProvider> </StrictMode>, ) 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/store/useAdminStore.ts b/src/store/useAdminStore.ts index a697d30..274900c 100644 --- a/src/store/useAdminStore.ts +++ b/src/store/useAdminStore.ts @@ -1,27 +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 } 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' - -const ALLOWED_DOMAIN = import.meta.env.VITE_ALLOWED_DOMAIN as string | undefined interface AdminState { clients: DbClientRow[] @@ -43,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 } @@ -67,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({ @@ -80,98 +91,42 @@ export const useAdminStore = create<AdminStore>()( }), 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 ?? [] }) + 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 ?? [] }) + 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<string, string[]> = {} - ;(data ?? []).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/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<Member[]> { - 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<void> - invalidate: () => void -} - -type MemberStore = MemberState & MemberActions - -export const useMemberStore = create<MemberStore>()( +export const useMemberStore = create<MemberStoreState>()( 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<Task[]> { - 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<void> - invalidate: () => void + applyOptimisticUpdate: (tasks: Task[]) => void + clearOptimistic: () => void } type TaskStore = TaskState & TaskActions export const useTaskStore = create<TaskStore>()( 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/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() }) 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/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/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<Database, { PostgrestVersion: 'XX' }>(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<Database, "__InternalSupabase"> + +type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">] + +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 diff --git a/src/utils/__tests__/dateUtils.test.ts b/src/utils/__tests__/dateUtils.test.ts new file mode 100644 index 0000000..ef584e2 --- /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..fe0c136 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++; @@ -64,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 { @@ -78,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 => { diff --git a/src/views/MembersView/MembersView.tsx b/src/views/MembersView/MembersView.tsx index d3b937a..d5dc7d8 100644 --- a/src/views/MembersView/MembersView.tsx +++ b/src/views/MembersView/MembersView.tsx @@ -1,123 +1,152 @@ -import React, { useEffect } from 'react'; -import { useTaskStore } from '@/store/useTaskStore'; -import { useMemberStore } from '@/store/useMemberStore'; -import { useUIStore } from '@/store/useUIStore'; -import { useAuthContext } from '@/contexts/AuthContext'; +import React, { useMemo, useRef, useLayoutEffect, useState, useCallback } from 'react'; +import { UserCircle2 } from 'lucide-react'; +import { useMembersQuery } from '@/hooks/useMembersQuery'; 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 type { Member } from '@/types/member'; +import HierarchyMemberCard from './components/HierarchyMemberCard'; +import HierarchySkeleton from './components/HierarchySkeleton'; -function todayStr() { - const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; -} +type LineSegment = { x1: number; y1: number; x2: number; y2: number }; const MembersView: React.FC = () => { - const { isAdmin } = useAuthContext(); const { effectiveClientId } = useClients(); - 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 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 handleRetry = () => { - invalidateTasks(); - invalidateMembers(); - fetchTasks(effectiveClientId, isAdmin); - fetchMembers(effectiveClientId); - }; - - if (errorMessage && !hasData) { + const { data: members = [], isLoading } = useMembersQuery(effectiveClientId); + + const activeMembers = useMemo( + () => members.filter((m: Member) => m.is_active !== false), + [members] + ); + + const { admins, users } = useMemo(() => ({ + admins: activeMembers.filter((m: Member) => m.access_role === 'admin'), + users: activeMembers.filter((m: Member) => m.access_role !== 'admin'), + }), [activeMembers]); + + const wrapperRef = useRef<HTMLDivElement>(null); + const adminsRowRef = useRef<HTMLDivElement>(null); + const usersRowRef = useRef<HTMLDivElement>(null); + const [lines, setLines] = useState<LineSegment[]>([]); + 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(); + const ro = new ResizeObserver(computeLines); + if (wrapperRef.current) ro.observe(wrapperRef.current); + return () => ro.disconnect(); + }, [computeLines]); + + if (isLoading) { return ( - <ViewState - icon={DatabaseZap} - title="Erro ao carregar membros" - description={`Não foi possível consultar o banco agora. Detalhe: ${errorMessage}`} - actionLabel="Tentar novamente" - onAction={handleRetry} - /> + <div className="space-y-5"> + <div> + <h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-foreground">Membros</h2> + <p className="text-sm text-muted-foreground">Hierarquia do time</p> + </div> + <div className="overflow-auto pb-8"> + <HierarchySkeleton /> + </div> + </div> ); } - if (members.length === 0 && !isLoading) { + if (activeMembers.length === 0) { return ( - <ViewState - icon={Users2} - title="Sem membros neste cliente" - description="Associe membros ao cliente para visualizar a capacidade por etapa." - actionLabel="Atualizar" - onAction={handleRetry} - /> + <div className="flex flex-col items-center justify-center flex-1 text-muted-foreground"> + <UserCircle2 className="w-12 h-12 mb-2 opacity-50" /> + <p>Nenhum membro encontrado.</p> + </div> ); } - const content = ( - <div className="space-y-6 max-w-4xl mx-auto"> + return ( + <div className="space-y-5"> <div> - <h2 className="text-2xl font-semibold tracking-tight">Capacity da Equipe</h2> - <p className="text-muted-foreground">Visão informativa por membro com atalhos para abrir o calendário filtrado.</p> + <h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-foreground">Membros</h2> + <p className="text-sm text-muted-foreground">Hierarquia do time</p> </div> - <div className="grid gap-4 md:grid-cols-2"> - {members.map(member => ( - <MemberCard - key={member.id} - member={member} - tasks={tasks} - today={today} - onOpenCalendar={(memberId: string) => { - setDashboardRedirect({ assigneeId: memberId, mode: 'calendar' }); - setView('calendar'); - }} - /> - ))} + + <div className="overflow-auto pb-8"> + <div ref={wrapperRef} className="relative flex flex-col items-center gap-12 min-w-max mx-auto py-4"> + {admins.length > 0 && users.length > 0 && lines.length > 0 && ( + <svg + className="absolute inset-0 pointer-events-none" + width="100%" + height={svgHeight} + style={{ top: 0, left: 0 }} + > + {lines.map((l) => ( + <line + key={`${l.x1}-${l.y1}-${l.x2}-${l.y2}`} + x1={l.x1} y1={l.y1} + x2={l.x2} y2={l.y2} + stroke="currentColor" + strokeWidth={1} + className="text-border" + /> + ))} + </svg> + )} + + <div ref={adminsRowRef} className="flex justify-center gap-8"> + {admins.map((m: Member) => ( + <HierarchyMemberCard key={m.id} member={m} /> + ))} + </div> + + {users.length > 0 ? ( + <div ref={usersRowRef} className="flex justify-center gap-8"> + {users.map((m: Member) => ( + <HierarchyMemberCard key={m.id} member={m} /> + ))} + </div> + ) : ( + <p className="text-sm text-muted-foreground italic">Nenhum usuário não-admin encontrado.</p> + )} + </div> </div> </div> ); - - return ( - <Skeleton loading={isLoading} initialBones={MEMBERS_BONES} animate="shimmer"> - {content} - </Skeleton> - ); }; export default MembersView; 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<AvatarProps> = ({ src, initials }) => ( + <div className="relative flex h-14 w-14 shrink-0 overflow-hidden rounded-full border-2 border-border bg-muted"> + {src ? ( + <img src={src} alt={initials} className="aspect-square h-full w-full object-cover" /> + ) : ( + <div className="flex h-full w-full items-center justify-center font-medium text-muted-foreground"> + {initials} + </div> + )} + </div> +); + +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<HierarchyMemberCardProps> = ({ member }) => { + const joinedAt = member.created_at + ? new Date(member.created_at).toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' }) + : null; + + return ( + <div className="w-72 rounded-xl border border-border bg-card text-card-foreground shadow-sm hover:shadow-md transition-shadow duration-200"> + <div className="p-5 flex flex-col items-center text-center gap-3"> + <HierarchyAvatar src={member.avatar_url} initials={member.avatar} /> + <div className="w-full space-y-1"> + <h4 className="font-semibold text-sm leading-none">{member.name}</h4> + <div className="flex items-center justify-center text-xs text-muted-foreground mt-1"> + <Mail className="w-3 h-3 mr-1 shrink-0" /> + <span className="truncate">{member.email || 'Sem e-mail'}</span> + </div> + {joinedAt && ( + <div className="flex items-center justify-center text-xs text-muted-foreground"> + <CalendarDays className="w-3 h-3 mr-1 shrink-0" /> + <span>Entrou em {joinedAt}</span> + </div> + )} + </div> + <div className="flex gap-2 mt-1"> + <Badge variant={member.access_role === 'admin' ? 'default' : 'secondary'} className="text-[10px] uppercase"> + {member.access_role ?? 'user'} + </Badge> + <Badge variant="outline" className="text-[10px]"> + {member.role} + </Badge> + </div> + </div> + </div> + ); +}; + +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 = () => ( + <div className="w-72 rounded-xl border border-border bg-card shadow-sm p-5 flex flex-col items-center gap-3 animate-pulse"> + <div className="h-14 w-14 rounded-full bg-muted" /> + <div className="w-full space-y-2 flex flex-col items-center"> + <div className="h-3 w-28 rounded bg-muted" /> + <div className="h-3 w-36 rounded bg-muted" /> + <div className="h-3 w-24 rounded bg-muted" /> + </div> + <div className="flex gap-2"> + <div className="h-4 w-12 rounded-full bg-muted" /> + <div className="h-4 w-16 rounded-full bg-muted" /> + </div> + </div> +); + +const HierarchySkeleton: React.FC = () => ( + <div className="flex flex-col items-center gap-12 min-w-max mx-auto py-4"> + <div className="flex justify-center gap-8"> + <CardSkeleton /> + </div> + <div className="flex justify-center gap-8"> + <CardSkeleton /> + <CardSkeleton /> + <CardSkeleton /> + </div> + </div> +); + +export default HierarchySkeleton; 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<MemberCardProps> = ({ member, tasks, today, onOpenCalendar }) => { +const MemberCard: React.FC<MemberCardProps> = 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<MemberCardProps> = ({ member, tasks, today, onOpenCal </div> </div> ); -}; +}, (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/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 ( <ViewState icon={ShieldAlert} - title="Configuração necessária" - description="Para acessar o painel admin, configure VITE_SUPABASE_SERVICE_ROLE_KEY no .env.local." + title="Acesso restrito" + description="Esta área é restrita a administradores." /> ) } 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<typeof import('@/contexts/AuthContext').useAuthCon const mockContextValue: MockContext = { session: null, user: null, - member: { id: 'admin-1', name: 'Admin User' } as never, + member: { id: 'admin-1', name: 'Admin User', access_role: 'admin' } as never, clients: [], isAdmin: true, impersonatedClientId: null, @@ -24,15 +24,6 @@ const mockContextValue: MockContext = { refreshProfile: vi.fn(), } -vi.mock('@/lib/supabase', () => ({ - 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<string | null>(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 26ee746..2fa3348 100644 --- a/src/views/admin/hooks/__tests__/useAdminData.test.ts +++ b/src/views/admin/hooks/__tests__/useAdminData.test.ts @@ -1,28 +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(), - }, - }, - }, -})) +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal<typeof import('@tanstack/react-query')>() + return { + ...actual, + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + } +}) -// Mock das stores para evitar erros de contexto -vi.mock('@/store/useTaskStore', () => ({ - useTaskStore: (selector: (s: { invalidate: () => void; fetchTasks: () => Promise<void> }) => unknown) => - selector({ invalidate: vi.fn(), fetchTasks: vi.fn() }), -})) -vi.mock('@/store/useMemberStore', () => ({ - useMemberStore: (selector: (s: { invalidate: () => void; fetchMembers: () => Promise<void> }) => unknown) => - selector({ invalidate: vi.fn(), fetchMembers: vi.fn() }), -})) vi.mock('@/store/useClientStore', () => ({ useClientStore: (selector: (s: { selectedClientId: string | null }) => unknown) => selector({ selectedClientId: null }), @@ -59,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<typeof returnValue> & Record<string, unknown> { - const base = Promise.resolve(returnValue) as Promise<typeof returnValue> & Record<string, unknown> - 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<ReturnType<typeof admin.auth.admin.listUsers>>) }) -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 cbb1915..580067e 100644 --- a/src/views/admin/hooks/useAdminData.ts +++ b/src/views/admin/hooks/useAdminData.ts @@ -1,13 +1,28 @@ import { useEffect, useCallback } from 'react' -import { supabaseAdmin } from '@/lib/supabase' +import { useQueryClient } from '@tanstack/react-query' 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' - -export type { PendingAuthUser, AuditFilters } +import { createNotificationForClient, createNotification } from '@/lib/notifications' +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 @@ -15,43 +30,31 @@ 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, userClientsMap, pendingUsers, initialized, fetchClients, fetchUsers, fetchUserClientsMap, fetchPendingUsers, - fetchAuditLogs, refreshAll, patchUser, setError, + fetchAuditLogs, refreshAll, patchUser, patchUserClientsMap, 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(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 ──────────────────────────────────────────────────────────── 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() @@ -63,10 +66,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() @@ -78,26 +79,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() @@ -106,57 +89,56 @@ 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 + patchUserClientsMap(userId, clientId, 'add') try { + await adminLinkUserToClient(userId, clientId) setError(null) - await fetchUsers() 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 } - }, [fetchUsers, fetchUserClientsMap, reloadAppStores, setError]) + }, [clients, patchUserClientsMap, 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 + 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 ─────────────────────────────────────────────────────────────── 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() @@ -174,33 +156,23 @@ 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() setError(null) await reloadAppStores() + if (clientIds && clientIds.length > 0) { + const title = `👋 Novo integrante na equipe!` + 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')), + ) + } return true } catch (err) { setError(toSafeUiErrorMessage(err instanceof Error ? err.message : null)) @@ -209,13 +181,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() @@ -227,20 +194,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() @@ -252,52 +208,31 @@ 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 { 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 { 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<GoogleUser[]> => { + 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 { @@ -309,4 +244,3 @@ export function useAdminData(options: UseAdminDataOptions = {}) { createUser, setUserAuthId, updateUser, deactivateUser, reactivateUser, listGoogleUsers, } } - 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<string, string> = { 'publicacao': '#bafc50', }; -const StepBar: React.FC<StepBarProps> = ({ bar, task, isFirstBarOfStep, isLastBarOfStep, dragPreview, onStartDrag, onClick, viewMode = 'step', demandColor }) => { +const StepBar: React.FC<StepBarProps> = 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<StepBarProps> = ({ bar, task, isFirstBarOfStep, isLastBa )} </div> ); -}; +}, (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<WeekRowProps> = ({ +const WeekRow: React.FC<WeekRowProps> = React.memo(({ week, tasks, today, currentMonth, rowHeight, dragPreview, didDragRef, onStartDrag, onEdit, holidays, viewMode = 'step', weekIndex = 0, @@ -101,6 +101,15 @@ const WeekRow: React.FC<WeekRowProps> = ({ })} </div> ); -}; +}, (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/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<string, { title: string; description: string }> = { const DashboardView: React.FC<DashboardViewProps> = ({ 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<DashboardViewProps> = ({ 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..38069d7 100644 --- a/src/views/list/ListView.tsx +++ b/src/views/list/ListView.tsx @@ -1,9 +1,8 @@ import { useAuthContext } from '@/contexts/AuthContext'; -import { useTaskStore } from '@/store/useTaskStore'; -import { useMemberStore } from '@/store/useMemberStore'; +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'; @@ -19,9 +18,10 @@ interface ListViewProps { export function ListView({ onEdit, onDelete, onOpenNew, onExport }: ListViewProps) { const { member } = useAuthContext(); - const { tasks, fetchTasks, invalidate } = useTaskStore(); - const { members } = useMemberStore(); 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, @@ -31,55 +31,17 @@ 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); - 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`); - invalidate(); - await fetchTasks(effectiveClientId, isAdmin); - }; - - 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`); - invalidate(); - await fetchTasks(effectiveClientId, isAdmin); - }; + const handleConclude = (task: Task) => concludeTask(task); + const handleToggleBlock = (task: Task) => toggleBlock(task); const togglePeriod = (months: number) => { if (filterPeriodMonths === months) return; @@ -89,7 +51,7 @@ export function ListView({ onEdit, onDelete, onOpenNew, onExport }: ListViewProp return ( <div className="space-y-4"> <FilterBar - members={members ?? []} + members={members} filterAssignee={filterAssignee} onChangeAssignee={setFilterAssignee} filterStatus={filterStatus} @@ -107,7 +69,7 @@ export function ListView({ onEdit, onDelete, onOpenNew, onExport }: ListViewProp hasActiveFilters={hasActiveFilters} onClear={clearFilters} filteredCount={groupedTasks.size} - totalCount={(tasks ?? []).length} + totalCount={tasks.length} /> {groupedTasks.size === 0 ? ( @@ -122,7 +84,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/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 </div> </div> ); -} +}, (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/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 437e057..560cead 100644 --- a/src/views/profile/hooks/useProfile.ts +++ b/src/views/profile/hooks/useProfile.ts @@ -1,6 +1,8 @@ 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 @@ -68,10 +70,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]) @@ -138,7 +140,7 @@ export function useProfile() { savingPrefs, error, successMessage, - updateProfile, - updatePreferences, + updateProfile: useThrottledMutation(updateProfile, 500), + updatePreferences: useThrottledMutation(updatePreferences, 500), } } 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 ( + <ViewState + icon={DatabaseZap} + title="Erro ao carregar membros" + description={`Não foi possível consultar o banco agora. Detalhe: ${errorMessage}`} + actionLabel="Tentar novamente" + onAction={handleRetry} + /> + ); + } + + if (members.length === 0 && !isLoading) { + return ( + <ViewState + icon={Users2} + title="Sem membros neste cliente" + description="Associe membros ao cliente para visualizar a capacidade por etapa." + actionLabel="Atualizar" + onAction={handleRetry} + /> + ); + } return ( <div className="space-y-6"> + <div> + <h3 className="text-lg font-semibold tracking-tight">Capacity da Equipe</h3> + <p className="text-sm text-muted-foreground">Visão por membro com atalhos para abrir o calendário filtrado.</p> + </div> + <div className="grid gap-4 md:grid-cols-2"> + {members.map(member => ( + <MemberCard + key={member.id} + member={member} + tasks={tasks} + today={today} + onOpenCalendar={(memberId: string) => { + setDashboardRedirect({ assigneeId: memberId, mode: 'calendar' }); + setView('calendar'); + }} + /> + ))} + </div> + <TeamCapacity memberLoad={memberLoad} /> <CapacityHeatmap heatmapData={heatmapData} p85ByStep={flowMetrics.p85ByStep} /> <WorkloadChart workloadData={workloadData} /> 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<typeof enrichTask>[]; filteredEnriched: ReturnType<typeof enrichTask>[]; - members: ReturnType<typeof useMemberStore.getState>['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<TimeFilter>('all'); const [memberFilter, setMemberFilter] = useState<string>('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..5136c71 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,11 +35,10 @@ const TASKS_BONES = { ], }; import { useAuthContext } from '@/contexts/AuthContext'; -import { useTaskStore } from '@/store/useTaskStore'; -import { useMemberStore } from '@/store/useMemberStore'; +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'; @@ -61,43 +60,15 @@ interface TasksViewProps { export default function TasksView({ onEdit, onOpenNew }: TasksViewProps) { const { member } = useAuthContext(); - const { tasks, fetchTasks, invalidate, loading } = useTaskStore(); - const { members, fetchMembers } = useMemberStore(); 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<FiltersState>(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]; - 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`); - invalidate(); - await fetchTasks(effectiveClientId, isAdmin); - }; - - 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`); - invalidate(); - await fetchTasks(effectiveClientId, isAdmin); - }; + 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/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 && ( - <div className="p-3 space-y-2"> - {tasks.map(task => ( - <TaskRow - key={task.id} - task={task} - stepType={stepType} - members={members} - onToggleBlock={onToggleBlock} - onConclude={onConclude} - onEdit={onEdit} - /> - ))} + <div className="p-3"> + {tasks.length > VIRTUALIZE_THRESHOLD ? ( + <List + height={Math.min(tasks.length * TASK_ROW_HEIGHT, 600)} + itemCount={tasks.length} + itemSize={TASK_ROW_HEIGHT} + width="100%" + itemData={{ tasks, stepType, members, onToggleBlock, onConclude, onEdit }} + > + {({ index, style, data }: ListChildComponentProps<{ + tasks: Task[]; + stepType: StepType; + members: Member[]; + onToggleBlock: (task: Task) => void; + onConclude: (task: Task) => void; + onEdit: (task: Task) => void; + }>) => ( + <div style={{ ...style, paddingBottom: 8 }}> + <TaskRow + task={data.tasks[index]} + stepType={data.stepType} + members={data.members} + onToggleBlock={data.onToggleBlock} + onConclude={data.onConclude} + onEdit={data.onEdit} + /> + </div> + )} + </List> + ) : ( + <div className="space-y-2"> + {tasks.map(task => ( + <TaskRow + key={task.id} + task={task} + stepType={stepType} + members={members} + onToggleBlock={onToggleBlock} + onConclude={onConclude} + onEdit={onEdit} + /> + ))} + </div> + )} </div> )} 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 </div> </div> ); -} +}, (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<PhaseBarProps> = ({ step, task, days, dragPreview, didDragRef, startDrag, onEdit }) => { +const PhaseBar: React.FC<PhaseBarProps> = 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<PhaseBarProps> = ({ step, task, days, dragPreview, didD )} </div> ); -}; +}, (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<StepRowProps> = ({ step, days, daysRange, task, dragPreview, didDragRef, startDrag, onEdit, holidays = [], flex1 = false }) => ( +const StepRow: React.FC<StepRowProps> = React.memo(({ step, days, daysRange, task, dragPreview, didDragRef, startDrag, onEdit, holidays = [], flex1 = false }) => ( <div className={`relative overflow-hidden${flex1 ? ' flex-1' : ''}`} style={{ minHeight: PHASE_ROW_H, width: daysRange * DAY_COL_W }}> <div className="absolute inset-0 flex pointer-events-none"> {days.map((d, i) => { @@ -43,6 +43,17 @@ const StepRow: React.FC<StepRowProps> = ({ step, days, daysRange, task, dragPrev /> )} </div> +), (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; 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, + } } 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<string, string[]> = {} + 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) +}) 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; 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'),