From 2854b37432b19fb6900a1a93706e16e78a9571cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Thu, 23 Apr 2026 14:13:41 -0300 Subject: [PATCH 01/11] feat: :sparkles: novas politicas de deploy --- vercel.json | 25 +++++++++++++++++++++++-- vite.config.ts | 2 +- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/vercel.json b/vercel.json index aa9ea6a..2180e92 100644 --- a/vercel.json +++ b/vercel.json @@ -1,13 +1,34 @@ { + "rewrites": [{ "source": "/(.*)", "destination": "/" }], "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:;" + "value": "default-src 'self'; connect-src 'self' https://*.supabase.co wss://*.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:; frame-ancestors 'none';" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=()" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000; includeSubDomains" } ] } ] -} +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 188798a..94f29ff 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ 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:;", + "default-src 'self'; connect-src 'self' https://*.supabase.co wss://*.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: { From ad9b0e3dc587d5d5c8956e20cdf0e67daf4685eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Thu, 23 Apr 2026 14:14:39 -0300 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20:sparkles:=20criado=20novo=20util?= =?UTF-8?q?it=C3=A1rio=20para=20react=20router=20dom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 146 +++++++++++++++++++++++++++++------------- package.json | 2 + src/lib/clientSlug.ts | 21 ++++++ src/main.tsx | 5 +- 4 files changed, 130 insertions(+), 44 deletions(-) create mode 100644 src/lib/clientSlug.ts diff --git a/package-lock.json b/package-lock.json index f02b949..cd8ebca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@supabase/supabase-js": "^2.101.1", "@tanstack/react-query": "^5.99.2", + "@types/react-router-dom": "^5.3.3", "boneyard-js": "^1.6.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -28,6 +29,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", + "react-router-dom": "^6.30.3", "react-window": "^1.8.11", "remark-breaks": "^4.0.0", "sonner": "^2.0.7", @@ -518,6 +520,7 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -529,6 +532,7 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -539,6 +543,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -864,6 +869,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2622,6 +2628,15 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", @@ -2629,6 +2644,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2645,6 +2661,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2661,6 +2678,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2677,6 +2695,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2693,6 +2712,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2709,9 +2729,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2728,9 +2746,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2747,9 +2763,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2766,9 +2780,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2785,9 +2797,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2804,9 +2814,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2823,6 +2831,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2839,6 +2848,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2857,6 +2867,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2873,6 +2884,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3115,9 +3127,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3135,9 +3144,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3155,9 +3161,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3175,9 +3178,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3386,6 +3386,7 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3451,6 +3452,12 @@ "@types/unist": "*" } }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3501,6 +3508,27 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@types/react-window": { "version": "1.8.8", "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", @@ -4901,6 +4929,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5212,7 +5241,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -5397,6 +5426,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5417,6 +5447,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5437,6 +5468,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5457,6 +5489,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5477,6 +5510,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5497,9 +5531,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5520,9 +5552,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5543,9 +5573,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5566,9 +5594,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5589,6 +5615,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5609,6 +5636,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6943,6 +6971,38 @@ } } }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", diff --git a/package.json b/package.json index 6e6da5f..6c20609 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@supabase/supabase-js": "^2.101.1", "@tanstack/react-query": "^5.99.2", + "@types/react-router-dom": "^5.3.3", "boneyard-js": "^1.6.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -32,6 +33,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", + "react-router-dom": "^6.30.3", "react-window": "^1.8.11", "remark-breaks": "^4.0.0", "sonner": "^2.0.7", diff --git a/src/lib/clientSlug.ts b/src/lib/clientSlug.ts new file mode 100644 index 0000000..a406921 --- /dev/null +++ b/src/lib/clientSlug.ts @@ -0,0 +1,21 @@ +import type { ClientOption } from "@/contexts/AuthContext" + +export function clientToSlug(client: ClientOption): string { + return client.slug ?? nameToSlug(client.name) +} + +export function nameToSlug(name: string): string { + return name + .toLowerCase() + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +export function slugToClient( + slug: string, + clients: ClientOption[] +): ClientOption | undefined { + return clients.find((c) => clientToSlug(c) === slug) +} diff --git a/src/main.tsx b/src/main.tsx index 4bead42..dd6ad6a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import './index.css' import App from './App.tsx' @@ -19,7 +20,9 @@ createRoot(document.getElementById('root')!).render( - + + + , From 32dce0fcfdc2ded8a0c6db7edfa2c2c5345168ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Thu, 23 Apr 2026 14:17:55 -0300 Subject: [PATCH 03/11] feat: :sparkles: migrado a logica do orquestrador direto pro router e dos hooks pela URL --- src/App.tsx | 3 + src/components/AppLayout.tsx | 5 + src/components/AppRouter.tsx | 5 +- src/components/AppRoutes.tsx | 78 +++++++++++++++ src/contexts/LayoutContext.ts | 3 + src/hooks/useAppNavigation.ts | 163 +++++++++++++++++++++++++++++++ src/hooks/useAppOrchestrator.ts | 144 +++++++++++++++------------ src/hooks/useClientTransition.ts | 22 ++--- 8 files changed, 343 insertions(+), 80 deletions(-) create mode 100644 src/components/AppRoutes.tsx create mode 100644 src/hooks/useAppNavigation.ts diff --git a/src/App.tsx b/src/App.tsx index 4f7c77f..e858e5e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -71,6 +71,9 @@ export default function App() { onOpenNewTask={app.taskActions.openNewTask} onDeleteTask={app.taskActions.requestDeleteTask} onUpdateTask={app.updateTask} + urlTaskId={app.urlTaskId} + onOpenTask={app.openTask} + onCloseTask={app.closeTask} /> void onDeleteTask: (id: string) => void onUpdateTask: (task: Task) => Promise + urlTaskId: string | null + onOpenTask: (taskId: string, subview?: "calendar" | "timeline" | "list") => void + onCloseTask: () => void } export function AppLayout(props: AppLayoutProps) { @@ -58,6 +61,7 @@ export function AppLayout(props: AppLayoutProps) { selectedClient, availableClients, onSelectClient, isAdmin, effectiveClientId, userName, holidays, onEditTask, onOpenNewTask, onDeleteTask, onUpdateTask, + urlTaskId, onOpenTask, onCloseTask, } = props return ( @@ -81,6 +85,7 @@ export function AppLayout(props: AppLayoutProps) { effectiveClientId, selectedClient, userName, userEmail, holidays, hasClients, onViewChange, onEditTask, onOpenNewTask, onDeleteTask, onUpdateTask, + urlTaskId, onOpenTask, onCloseTask, }, }}>
diff --git a/src/components/AppRouter.tsx b/src/components/AppRouter.tsx index 251cbd9..6ac72cb 100644 --- a/src/components/AppRouter.tsx +++ b/src/components/AppRouter.tsx @@ -23,7 +23,6 @@ function ViewSkeleton() { ); } - type DashboardSubview = "calendar" | "timeline" | "list"; type ToolsSubview = Extract; @@ -60,7 +59,7 @@ export function AppRouter() { return ; } - if (hasClients && !effectiveClientId && view !== "clients") { + if (hasClients && !effectiveClientId && view !== "clients" && view !== "profile" && view !== "admin") { return ; } @@ -77,7 +76,7 @@ export function AppRouter() { {view === "admin" && } - {view === "clients" && } + {(view === "clients") && } {DASHBOARD_VIEWS.has(view) && ( + {/* Raiz sem cliente */} + + + {/* Rotas globais (sem cliente) */} + + + + + {/* Rotas por cliente */} + + + + {/* Tasks */} + + + + + + + + + + {/* Members */} + + + {/* Reports */} + + + + + + + {/* Tools */} + + + + + + + {/* Fallback */} + } /> + + ) +} diff --git a/src/contexts/LayoutContext.ts b/src/contexts/LayoutContext.ts index c62f005..b1bfc56 100644 --- a/src/contexts/LayoutContext.ts +++ b/src/contexts/LayoutContext.ts @@ -51,6 +51,9 @@ interface RouterCtx { onOpenNewTask: () => void onDeleteTask: (id: string) => void onUpdateTask: (task: Task) => Promise + urlTaskId: string | null + onOpenTask: (taskId: string, subview?: "calendar" | "timeline" | "list") => void + onCloseTask: () => void } interface LayoutCtx { diff --git a/src/hooks/useAppNavigation.ts b/src/hooks/useAppNavigation.ts new file mode 100644 index 0000000..ff04b46 --- /dev/null +++ b/src/hooks/useAppNavigation.ts @@ -0,0 +1,163 @@ +import { useCallback } from "react" +import { useNavigate, useLocation, useParams } from "react-router-dom" +import type { ClientOption } from "@/contexts/AuthContext" +import type { ViewType } from "@/store/useUIStore" +import { clientToSlug, slugToClient } from "@/lib/clientSlug" + +// ─── URL → ViewType ─────────────────────────────────────────────────────────── + +export function urlToView(pathname: string): ViewType { + const segments = pathname.replace(/^\//, "").split("/") + + // Rotas globais (sem clientSlug) + const first = segments[0] + if (first === "profile") return "profile" + if (first === "admin") return "admin" + if (first === "clients") return "clients" + + // /:clientSlug/... + const rest = segments.slice(1) + if (rest.length === 0) return "home" + + const [section, sub] = rest + + if (section === "client-info") return "clients" + + if (section === "tasks") { + if (!sub || sub === "id") return "demandas" + if (sub === "calendar") return "calendar" + if (sub === "timeline") return "timeline" + if (sub === "list") return "list" + return "demandas" + } + + if (section === "members") return "members" + + if (section === "reports") { + if (!sub) return "reports" + if (sub === "fluxo") return "reports-fluxo" + if (sub === "timeline") return "reports-timeline" + if (sub === "membros") return "reports-membros" + if (sub === "alertas") return "reports-alertas" + return "reports" + } + + if (section === "tools") { + if (!sub) return "tools" + if (sub === "briefing-analyzer") return "tools-briefing-analyzer" + if (sub === "import") return "tools-import" + if (sub === "export") return "tools-export" + if (sub === "integrations") return "tools-integrations" + return "tools" + } + + return "home" +} + +// ─── ViewType + clientSlug → path ──────────────────────────────────────────── + +export function viewToPath(view: ViewType, clientSlug: string | null): string { + if (view === "profile") return "/profile" + if (view === "admin") return "/admin" + if (view === "clients") return "/clients" + + if (!clientSlug) return "/" + + const base = `/${clientSlug}` + + const MAP: Partial> = { + home: base, + clients: `${base}/client-info`, + demandas: `${base}/tasks`, + calendar: `${base}/tasks/calendar`, + timeline: `${base}/tasks/timeline`, + list: `${base}/tasks/list`, + members: `${base}/members`, + reports: `${base}/reports`, + "reports-fluxo": `${base}/reports/fluxo`, + "reports-timeline": `${base}/reports/timeline`, + "reports-membros": `${base}/reports/membros`, + "reports-alertas": `${base}/reports/alertas`, + tools: `${base}/tools`, + "tools-briefing-analyzer": `${base}/tools/briefing-analyzer`, + "tools-import": `${base}/tools/import`, + "tools-export": `${base}/tools/export`, + "tools-integrations": `${base}/tools/integrations`, + } + + return MAP[view] ?? base +} + +// ─── Task path ──────────────────────────────────────────────────────────────── + +export function taskPath( + taskId: string, + clientSlug: string, + subview: "calendar" | "timeline" | "list" = "calendar" +): string { + return `/${clientSlug}/tasks/${subview}/id/${taskId}` +} + +// ─── Hook principal ─────────────────────────────────────────────────────────── + +export function useAppNavigation(clients: ClientOption[]) { + const navigate = useNavigate() + const location = useLocation() + const params = useParams<{ clientSlug?: string; taskId?: string }>() + + const currentSlug = params.clientSlug ?? null + const currentClient = currentSlug ? slugToClient(currentSlug, clients) : null + const view = urlToView(location.pathname) + + // taskId presente na URL (para abrir modal) + const urlTaskId = params.taskId ?? null + + const navigateTo = useCallback( + (newView: ViewType, client?: ClientOption | null) => { + const slug = client ? clientToSlug(client) : currentSlug + navigate(viewToPath(newView, slug)) + }, + [navigate, currentSlug] + ) + + const navigateToClient = useCallback( + (client: ClientOption, keepView = false) => { + const slug = clientToSlug(client) + if (keepView && view !== "home" && view !== "clients") { + navigate(viewToPath(view, slug)) + } else { + navigate(`/${slug}`) + } + }, + [navigate, view] + ) + + const openTask = useCallback( + (taskId: string, subview: "calendar" | "timeline" | "list" = "calendar") => { + if (!currentSlug) return + navigate(taskPath(taskId, currentSlug, subview)) + }, + [navigate, currentSlug] + ) + + const closeTask = useCallback(() => { + if (!currentSlug) return + // Remove o segmento /id/:taskId mantendo a subview + const segments = location.pathname.replace(/^\//, "").split("/") + const idIdx = segments.indexOf("id") + if (idIdx !== -1) { + navigate("/" + segments.slice(0, idIdx).join("/"), { replace: true }) + } + }, [navigate, location.pathname, currentSlug]) + + return { + view, + currentSlug, + currentClient, + urlTaskId, + navigateTo, + navigateToClient, + openTask, + closeTask, + } +} diff --git a/src/hooks/useAppOrchestrator.ts b/src/hooks/useAppOrchestrator.ts index 5d7b18b..826c82c 100644 --- a/src/hooks/useAppOrchestrator.ts +++ b/src/hooks/useAppOrchestrator.ts @@ -1,6 +1,5 @@ import { useEffect, useCallback } from "react"; import { toast } from "sonner"; -import { useClientStore } from "@/store/useClientStore"; import { useUIStore } from "@/store/useUIStore"; import { useAuthContext } from "@/contexts/AuthContext"; import { useSupabase } from "@/hooks/useSupabase"; @@ -9,10 +8,13 @@ import { useNotifications } from "@/hooks/useNotifications"; import { useAppTheme } from "@/hooks/useAppTheme"; import { useAppSidebar } from "@/hooks/useAppSidebar"; import { useTaskActions } from "@/hooks/useTaskActions"; +import { useAppNavigation } from "@/hooks/useAppNavigation"; import { useClientTransition } from "@/hooks/useClientTransition"; import { useMembersQuery } from "@/hooks/useMembersQuery"; import { resolveNotificationRoute } from "@/lib/notifications"; import { canAccessView, resolveAccessRole } from "@/lib/accessControl"; +import { clientToSlug } from "@/lib/clientSlug"; +import type { ClientOption } from "@/contexts/AuthContext"; import type { Notification } from "@/types/notification"; import type { ViewType } from "@/store/useUIStore"; @@ -24,29 +26,30 @@ export function useAppOrchestrator() { const accessRole = resolveAccessRole(auth.member); const hasClients = auth.clients.length > 0; - const { selectedClientId, setClient } = useClientStore(); + const nav = useAppNavigation(auth.clients); - const effectiveClientId = - selectedClientId === undefined - ? hasClients ? auth.clients[0].id : null - : auth.isAdmin - ? selectedClientId - : selectedClientId ?? (hasClients ? auth.clients[0].id : null); + // O cliente efetivo vem da URL (slug) ou cai no primeiro disponível + const effectiveClient = + nav.currentClient ?? + (hasClients ? auth.clients[0] : null); + const effectiveClientId = effectiveClient?.id ?? null; + + // Sincroniza: se não há slug na URL mas há clientes, redireciona para o primeiro useEffect(() => { if (auth.loading || !auth.session) return; + if (!hasClients) return; + if (nav.currentSlug) return; - if (selectedClientId === undefined && hasClients) { - setClient(auth.clients[0].id); - } else if (!hasClients) { - setClient(undefined); - } else if ( - typeof selectedClientId === "string" && - !auth.clients.find((c) => c.id === selectedClientId) - ) { - setClient(auth.clients[0]?.id ?? undefined); - } - }, [auth.loading, auth.session, hasClients, auth.clients, selectedClientId, setClient]); + // Só redireciona se estiver na raiz ou numa rota sem slug conhecida + const isGlobal = ["/profile", "/admin", "/clients"].some((p) => + window.location.pathname.startsWith(p) + ); + if (isGlobal) return; + + // Na raiz "/" não redireciona — HomeView sem cliente é válido + if (window.location.pathname === "/") return; + }, [auth.loading, auth.session, hasClients, nav.currentSlug]); const { data: members = [] } = useMembersQuery(effectiveClientId); @@ -61,40 +64,32 @@ export function useAppOrchestrator() { const allClientIds = auth.clients.map((c) => c.id); const notifications = useNotifications(auth.member?.id, allClientIds); - const view = useUIStore((s) => s.view); - const setView = useUIStore((s) => s.setView); + // Modal state ainda vive no UIStore (não é URL) const isModalOpen = useUIStore((s) => s.isTaskModalOpen); const closeTaskModal = useUIStore((s) => s.closeTaskModal); - const { transitionTarget, selectClient, onTransitionComplete } = useClientTransition( - auth.clients, - effectiveClientId - ); - const taskActions = useTaskActions({ createTask, updateTask, deleteTask, effectiveClientId }); - const handleNotificationClick = useCallback( - (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"); + // Abre modal se há taskId na URL + useEffect(() => { + if (nav.urlTaskId) { + useUIStore.getState().openTaskModal(); + } + }, [nav.urlTaskId]); + + const { transitionTarget, selectClient: selectClientWithTransition, onTransitionComplete } = + useClientTransition( + auth.clients, + effectiveClientId, + useCallback((client: ClientOption) => nav.navigateToClient(client, true), [nav]) + ); + + const selectClient = useCallback( + (clientId: string | null | undefined) => { + if (!clientId) return; + selectClientWithTransition(clientId); }, - [setView, selectClient, effectiveClientId, auth.clients] + [selectClientWithTransition] ); const handleViewChange = useCallback( @@ -103,24 +98,45 @@ export function useAppOrchestrator() { const canAccess = canAccessView(newView, accessRole, effectiveClientId !== null); if (!canAccess) { if (newView !== "clients") toast.error("Selecione um cliente para acessar esta funcionalidade."); - setView("clients"); + nav.navigateTo("clients", effectiveClient); } else { - setView(newView); + nav.navigateTo(newView, effectiveClient); } }, - [effectiveClientId, accessRole, setView, sidebar] + [effectiveClientId, effectiveClient, accessRole, nav, sidebar] ); + // Garante que a view atual é acessível useEffect(() => { if (!auth.loading && auth.session) { - const canAccess = canAccessView(view, accessRole, effectiveClientId !== null); - if (!canAccess) setView("clients"); + const canAccess = canAccessView(nav.view, accessRole, effectiveClientId !== null); + if (!canAccess) nav.navigateTo("clients", effectiveClient); } - }, [auth.loading, auth.session, effectiveClientId, accessRole, view, setView]); + }, [auth.loading, auth.session, effectiveClientId, effectiveClient, accessRole, nav.view]); + + const handleNotificationClick = useCallback( + (notification: Notification) => { + const route = resolveNotificationRoute(notification); + if (!route) return; + + if (notification.type === "client_access_granted" && notification.client_id) { + const targetClient = auth.clients.find((c) => c.id === notification.client_id); + if (targetClient) { + nav.navigateToClient(targetClient); + return; + } + nav.navigateTo("clients", effectiveClient); + return; + } + + if (route.startsWith("/dashboard")) nav.navigateTo("calendar", effectiveClient); + else if (route === "/profile") nav.navigateTo("profile", null); + else if (route === "/clients") nav.navigateTo("clients", effectiveClient); + else if (route === "/members") nav.navigateTo("members", effectiveClient); + }, + [nav, effectiveClient, auth.clients] + ); - const selectedClient = effectiveClientId - ? auth.clients.find((c) => c.id === effectiveClientId) ?? null - : null; return { // auth @@ -137,11 +153,19 @@ export function useAppOrchestrator() { // client effectiveClientId, - selectedClient, + effectiveClient, + selectedClient: effectiveClient, selectClient, transitionTarget, onTransitionComplete, + // navigation + view: nav.view, + urlTaskId: nav.urlTaskId, + handleViewChange, + clientSlug: nav.currentSlug, + clientToSlug, + // data members, holidays, @@ -150,10 +174,6 @@ export function useAppOrchestrator() { notifications, handleNotificationClick, - // view - view, - handleViewChange, - // modal isModalOpen, closeTaskModal, @@ -161,5 +181,7 @@ export function useAppOrchestrator() { // task actions taskActions, updateTask, + openTask: nav.openTask, + closeTask: nav.closeTask, }; } diff --git a/src/hooks/useClientTransition.ts b/src/hooks/useClientTransition.ts index e523196..bacdfd3 100644 --- a/src/hooks/useClientTransition.ts +++ b/src/hooks/useClientTransition.ts @@ -1,8 +1,6 @@ import { useState, useCallback } from "react"; import { toast } from "sonner"; import { useQueryClient } from "@tanstack/react-query"; -import { useClientStore } from "@/store/useClientStore"; -import { useUIStore } from "@/store/useUIStore"; import { queryKeys } from "@/lib/queries"; import type { ClientOption } from "@/contexts/AuthContext"; @@ -11,34 +9,26 @@ interface TransitionTarget { name: string; } -/** - * Handles the animated client-switch flow: - * shows `ClientTransitionOverlay`, then swaps the active client in stores. - */ export function useClientTransition( clients: ClientOption[], - effectiveClientId: string | null | undefined + effectiveClientId: string | null | undefined, + onNavigateToClient: (client: ClientOption) => void ) { const queryClient = useQueryClient(); - const setClient = useClientStore((s) => s.setClient); - const setView = useUIStore((s) => s.setView); - const [transitionTarget, setTransitionTarget] = useState(null); const selectClient = useCallback((clientId: string | null | undefined) => { const target = clients.find((c) => c.id === clientId); if (!target || clientId === effectiveClientId) return; - // Show overlay first; after fade-in (~650ms) trigger the swap so data fetches happen in background setTransitionTarget({ id: clientId, name: target.name }); setTimeout(() => { - setClient(clientId); queryClient.invalidateQueries({ queryKey: queryKeys.tasks(clientId ?? null, false) }); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - queryClient.invalidateQueries({ queryKey: ['members'] }); - setView("home"); + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + queryClient.invalidateQueries({ queryKey: ["members"] }); + onNavigateToClient(target); }, 650); - }, [clients, effectiveClientId, setClient, queryClient, setView]); + }, [clients, effectiveClientId, queryClient, onNavigateToClient]); const onTransitionComplete = useCallback(() => { if (!transitionTarget) return; From 233d8a1dea5851b58cd1ec84fa380b999d037af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Thu, 23 Apr 2026 17:55:39 -0300 Subject: [PATCH 04/11] feat: :sparkles: adicionado path nas rotas --- docs/architecture.md | 45 ++++- docs/decisions.md | 20 +- docs/todo/client-picker-view.md | 188 ++++++++++++++++++ src/App.tsx | 20 ++ src/components/AppRouter.tsx | 4 - src/components/ClientPickerLayout.tsx | 132 ++++++++++++ src/contexts/AuthContext.tsx | 9 +- src/hooks/useAppNavigation.ts | 21 +- src/hooks/useAppOrchestrator.ts | 56 +++--- src/hooks/useAppSidebar.ts | 6 + src/lib/__tests__/accessControl.test.ts | 18 +- src/lib/accessControl.ts | 11 +- src/views/client-picker/ClientPickerView.tsx | 67 +++++++ .../__tests__/ClientPickerView.test.tsx | 74 +++++++ .../client-picker/components/ClientCard.tsx | 38 ++++ src/views/client-picker/index.ts | 1 + 16 files changed, 654 insertions(+), 56 deletions(-) create mode 100644 docs/todo/client-picker-view.md create mode 100644 src/components/ClientPickerLayout.tsx create mode 100644 src/views/client-picker/ClientPickerView.tsx create mode 100644 src/views/client-picker/__tests__/ClientPickerView.test.tsx create mode 100644 src/views/client-picker/components/ClientCard.tsx create mode 100644 src/views/client-picker/index.ts diff --git a/docs/architecture.md b/docs/architecture.md index 221ce97..af6c0cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,8 +7,10 @@ src/ ├── App.tsx # Root: gates de auth + composição declarativa — ~80 linhas ├── main.tsx # Entry point ├── components/ -│ ├── AppRouter.tsx # Mapeia view → componente; guards de cliente +│ ├── AppRouter.tsx # Mapeia view (lida da URL) → componente; guards de cliente +│ ├── AppRoutes.tsx # Árvore de rotas React Router (declarativa, sem renderização) │ ├── AppLayout.tsx # Shell do layout: AppHeader + AppSidebar + main/AppRouter +│ ├── ClientPickerLayout.tsx # Layout leve para "/" sem slug: header + mini-sidebar recolhida + ClientPickerView │ ├── AppModals.tsx # TaskModal + ConfirmModal + ClientTransitionOverlay agrupados │ ├── TaskModal.tsx # Modal criar/editar demanda │ ├── AppHeader.tsx # Header: logo, hamburger mobile, NotificationBell, theme toggle (desktop) @@ -16,7 +18,10 @@ src/ │ ├── ClientTransitionOverlay.tsx # Overlay animado exibido ao trocar de cliente │ └── ui/ # Design system (Button, Input, Label, Badge) ├── views/ -│ ├── home/ # HomeView — saudação, SearchLauncher, QuickAccess +│ ├── home/ # HomeView — saudação, SearchLauncher, QuickAccess (pós-seleção de cliente) +│ ├── client-picker/ # ClientPickerView — boas-vindas + grid de seleção de cliente (rota "/") +│ │ └── components/ +│ │ └── ClientCard.tsx │ ├── dashboard/ # DashboardView → Calendar/Timeline │ ├── calendar/ # CalendarView — calendário mensal com drag-drop │ ├── timeline/ # TimelineView — Gantt com drag-drop por fase @@ -35,13 +40,14 @@ src/ │ └── 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) +│ ├── useAppNavigation.ts # URL ↔ ViewType: urlToView, viewToPath, taskPath; lê clientSlug via location.pathname (não useParams) │ ├── useSupabase.ts # Mutations CRUD (createTask, updateTask, deleteTask) via TanStack Query │ ├── useTasksQuery.ts # Query hook TanStack Query para tasks │ ├── useMembersQuery.ts # Query hook TanStack Query para members │ ├── useHolidays.ts # Feriados │ ├── useFormState.ts # Estado do formulário TaskModal │ ├── useAppTheme.ts # Dark mode: estado + sync com localStorage e -│ ├── useAppSidebar.ts # Sidebar desktop (persist) e mobile open/close +│ ├── useAppSidebar.ts # Sidebar desktop (persist) e mobile open/close; openSidebar() força expansão │ ├── useTaskActions.ts # Estado e handlers de create/update/delete de tasks │ └── useClientTransition.ts # Fluxo animado de troca de cliente (overlay + TanStack Query invalidate) ├── contexts/ @@ -52,6 +58,7 @@ src/ │ ├── adminApi.ts # Funções tipadas que chamam as Supabase Edge Functions admin │ ├── queries.ts # fetchTasksFromDb, fetchMembersFromDb, queryKeys — valida rows com Zod antes do mapeamento │ ├── validators.ts # Schemas Zod para rows do banco (DbTaskRowSchema, DbStepRowSchema, DbStepAssigneeSchema) +│ ├── clientSlug.ts # clientToSlug, nameToSlug, slugToClient — converte entre ClientOption e slug de URL │ ├── steps.ts # Definição e lógica de steps │ └── utils.ts # Utilitários gerais ├── types/ @@ -67,6 +74,11 @@ src/ AuthContext (AuthProvider) ↓ session, member, clients (filtrado por access_role), isAdmin, refreshProfile App.tsx (gates de auth + composição) + ├── !session → LoginView + ├── !hasClients → OnboardingView + ├── !clientSlug (rota "/") → ClientPickerLayout → ClientPickerView + │ 1 cliente: redirect automático para /:slug + │ N clientes: grid de ClientCards └── useAppOrchestrator (toda a lógica de orquestração) ├── useClientStore → selectedClientId (persist) ├── useMembersQuery → members com cache TanStack Query @@ -188,20 +200,35 @@ Relê o perfil do usuário atual (member + clients) sem reiniciar o ciclo de aut **Chave:** `App.tsx` usa `AuthContext.clients` diretamente (não `useUserClients`). `useUserClients` existe apenas em `UserClientsView` para `linkToClient`/`unlinkFromClient`. +## Leitura do clientSlug na URL + +`useAppNavigation` deriva `currentSlug` diretamente de `location.pathname` em vez de `useParams`. O motivo: `App.tsx` está montado diretamente dentro de `` sem nenhum `` como ancestral — logo `useParams()` retorna sempre `{}`. A extração manual lê o primeiro segmento do pathname e descarta rotas globais conhecidas (`profile`, `admin`, `clients`, `""`). + +`urlTaskId` é derivado da mesma forma: busca o segmento `"id"` no pathname e retorna o próximo segmento. + +## Persistência e Restauração do Cliente Selecionado + +`useAppOrchestrator` integra `useClientStore` para dois comportamentos: + +1. **Persistência:** sempre que `effectiveClientId` muda (URL com slug válido), grava o ID na `useClientStore` (localStorage via zustand/persist). +2. **Restauração:** ao cair em `/` sem slug (ex: pós-login), se `storedClientId` existir e corresponder a um cliente válido do usuário, redireciona automaticamente para `/:slug` — sem exibir o `ClientPickerView`. + +Usuários com apenas 1 cliente já tinham redirect automático no `ClientPickerView`. Este mecanismo cobre usuários com múltiplos clientes que já fizeram uma escolha anterior. + ## Troca de Cliente A troca de cliente exibe um overlay de transição animado antes de efetivar a mudança: ``` -handleSelectClient(newId) - → setTransitionClient({ id, name }) ← exibe ClientTransitionOverlay (~3.2s) - → após 650ms: setClient(newId) ← persiste no localStorage - queryClient.invalidateQueries(['tasks']) ← TanStack Query refetch automático +selectClient(clientId) + → sidebar.openSidebar() ← garante sidebar expandida ao chegar na HomeView + → setTransitionTarget({ id, name }) ← exibe ClientTransitionOverlay (~3.2s) + → após 650ms: queryClient.invalidateQueries(['tasks']) ← TanStack Query refetch automático queryClient.invalidateQueries(['members']) ← TanStack Query refetch automático - setView("home") + navigateToClient(client) → navigate('/:slug') → view="home" ↓ onComplete (após fade-out do overlay) → toast cinza "Trocado para " (sonner, 3s) - → setTransitionClient(null) + → setTransitionTarget(null) ``` > O TanStack Query revalida automaticamente ao mudar as queries keys (clientId muda) — o `invalidateQueries` força refetch imediato mesmo que ainda esteja dentro do `staleTime`. diff --git a/docs/decisions.md b/docs/decisions.md index 8433bed..80903c1 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -24,7 +24,7 @@ Registro de decisões arquiteturais significativas do projeto Run/Way. ## ADR-003: Roteamento manual sem React Router -**Status:** Aceito (Abr 2026) +**Status:** Substituído por ADR-016 (Abr 2026) **Decisão:** Roteamento implementado via `useUIStore` (view string enum) + renderização condicional em `App.tsx` **Racional:** a aplicação é um SPA com estados bem definidos (loading → não autenticado → onboarding → app); o grafo de navegação é simples e não se beneficia de URLs parametrizadas ou lazy-loading por rota **Consequências:** sem URLs navegáveis por deep link; adicionar URL-based routing no futuro exigiria refatoração da orquestração em `App.tsx` @@ -136,3 +136,21 @@ Registro de decisões arquiteturais significativas do projeto Run/Way. **Decisão:** a policy de leitura de `members` usa `USING (true)` para qualquer usuário autenticado — sem filtro por cliente **Racional:** todas as abordagens tentadas para restringir visibilidade por cliente (subquery em `members`, `SECURITY DEFINER`, tabela auxiliar `member_roles`) resultaram em `infinite recursion` no Supabase; a visibilidade plana é aceitável dado que a aplicação é interna **Consequências:** qualquer usuário autenticado vê todos os membros; filtro por cliente é feito no cliente via `user_clients` (com policy `user_read_same_client_user_clients` que não causa recursão) + +--- + +## ADR-016: Roteamento via React Router DOM com slugs de cliente + +**Status:** Aceito (Abr 2026) +**Decisão:** React Router DOM v6 substituiu o roteamento manual (ADR-003); cada view tem uma URL própria baseada no slug do cliente; `useAppNavigation` encapsula URL ↔ ViewType; `BrowserRouter` wraps o `App` no `main.tsx` +**Racional:** deep linking (compartilhar link de uma task, de uma view específica), navegação pelo browser (botões voltar/avançar), abertura de tasks por URL (`/:clientSlug/tasks/:subview/id/:taskId`) — todos impossíveis sem URL-based routing; o crescimento da app tornou a manutenção do roteamento manual custosa +**Consequências:** slug do cliente é lido do campo `slug` da tabela `clients` (já existente); `useUIStore.view` e `useClientStore` são mantidos para compatibilidade com código legado mas a source of truth é a URL; fechar o modal de task limpa o segmento `/id/:taskId` da URL; `vercel.json` já possuía rewrite `/*` → `/` (sem mudança necessária) + +--- + +## ADR-017: ClientPickerView como tela obrigatória de seleção de cliente + +**Status:** Aceito (Abr 2026) +**Decisão:** a rota `/` (sem `clientSlug`) exibe uma tela de boas-vindas (`ClientPickerView`) com cards dos clientes disponíveis, em vez de assumir `clients[0]` implicitamente; usuários com 1 cliente têm redirect transparente; `App.tsx` renderiza `ClientPickerLayout` (header + mini-sidebar recolhida) quando `!clientSlug` +**Racional:** o fallback `clients[0]` causava estado ambíguo logo após o login — o usuário nunca escolheu um cliente, mas a app operava como se tivesse, gerando bugs em funcionalidades que dependem do cliente efetivo; tornar a escolha explícita elimina a ambiguidade estruturalmente +**Consequências:** usuários com múltiplos clientes veem a tela de seleção ao acessar `/`; usuários com 1 cliente têm redirect automático sem fricção; o fallback `clients[0]` foi removido de `useAppOrchestrator`; o guard `!effectiveClientId` foi removido de `AppRouter` (esse estado é agora válido e tratado antes do AppLayout) diff --git a/docs/todo/client-picker-view.md b/docs/todo/client-picker-view.md new file mode 100644 index 0000000..8491563 --- /dev/null +++ b/docs/todo/client-picker-view.md @@ -0,0 +1,188 @@ +# Plano de Implementação — ClientPickerView (Opção B) + +## Problema + +Após o login, o router cai em `/` sem `clientSlug`. O orquestrador resolve isso pegando `clients[0]` como `effectiveClient` implicitamente (linha 34–35 de `useAppOrchestrator.ts`). Isso causa estado ambíguo: o usuário nunca escolheu um cliente, mas a app opera como se tivesse. + +## Solução + +Transformar `/` em uma **tela de boas-vindas obrigatória** que exibe cards dos clientes disponíveis. Só após o usuário escolher um cliente, ele é redirecionado para `/:clientSlug`. O fallback `clients[0]` é removido do orquestrador. + +**Atalho para 1 cliente:** Se `clients.length === 1`, a view faz o redirect automaticamente sem renderizar a tela (transparente ao usuário). + +--- + +## Arquitetura da Solução + +### Nova view: `src/views/client-picker/` + +Estrutura (seguindo convenção de views-como-pastas): + +``` +src/views/client-picker/ +├── ClientPickerView.tsx # componente principal +├── components/ +│ └── ClientCard.tsx # card de cliente (reutiliza estilo de QuickAccessCard) +└── index.ts +``` + +### Novo layout: `ClientPickerLayout` + +A tela de boas-vindas não usa `AppLayout` (sidebar de navegação completa seria ruído). Ela tem um layout próprio mais leve: + +- **Header:** mesmo `AppHeader` (logo + toggle de tema + notificações) — sem o `selectedClientId` relevante, mas mantém consistência visual +- **Sidebar:** sidebar simplificada — **apenas o botão de Início (ativo) + footer recolhido** (`open = false` fixo, sem toggle) +- **Main:** `ClientPickerView` + +Isso evita criar um componente de sidebar completamente novo — aproveitamos `AppSidebar` com contexto simplificado, passando `sidebarOpen: false` fixo e somente o item "Início" visível (ou simplesmente omitindo a sidebar e usando um mini-sidebar dedicado). + +> **Decisão de implementação:** Criar um `ClientPickerSidebar` separado e simples (não reutilizar `AppSidebar` com mocks) — a sidebar de picker tem apenas footer recolhido com avatar/logout, sem nav items. Mais simples e sem risco de regressão na sidebar principal. + +--- + +## Mudanças por Arquivo + +### 1. `src/views/client-picker/ClientPickerView.tsx` *(novo)* + +``` +Props: + userName: string + clients: ClientOption[] + onSelectClient: (client: ClientOption) => void + +Comportamento: + - Se clients.length === 1: chama onSelectClient(clients[0]) no mount, renderiza null (ou spinner curto) + - Se clients.length === 0: exibe mensagem de "nenhum cliente associado" + link para /clients + - Se clients.length > 1: exibe grid de ClientCards + +Visual (inspirado em HomeView): + - Mesmo fundo com gradiente + dot pattern + - Saudação: "Olá, {firstName}" + data atual + - Subtítulo: "Selecione um cliente para continuar" + - Grid de ClientCard (2 col mobile, 3–4 col desktop) +``` + +### 2. `src/views/client-picker/components/ClientCard.tsx` *(novo)* + +``` +Props: + client: ClientOption + onClick: () => void + +Visual: + - Mesmo padrão de QuickAccessCard (border, rounded-xl, hover, dark mode) + - Avatar com iniciais (2 chars, bg-primary text-primary-foreground) + - Nome do cliente em font-medium + - Sem description ou ícone de categoria — foco no nome +``` + +### 3. `src/views/client-picker/index.ts` *(novo)* + +```ts +export { ClientPickerView } from "./ClientPickerView" +``` + +### 4. `src/components/ClientPickerLayout.tsx` *(novo)* + +Layout exclusivo para a rota `/`: + +``` +Header: AppHeader (reaproveitado via LayoutContext, com selectedClientId: null) +Sidebar: mini-sidebar recolhida (sem ClientPickerSidebar, sem nav) + - Apenas footer: avatar do usuário + logout + - Sem toggle de expand (sempre recolhida) +Main: ClientPickerView +``` + +Evita alterar `AppLayout` — cria uma composição paralela simples. + +### 5. `src/components/AppRoutes.tsx` *(alterar)* + +A rota `/` passa a ter um `element` real (não `null`) — mas como `AppRoutes` só declara rotas sem renderizar, nenhuma mudança aqui. A renderização condicional fica no `App.tsx` ou `AppRouter.tsx`. + +### 6. `src/components/AppRouter.tsx` *(alterar)* + +Remover o guard atual que vai para `NoClientView` quando `hasClients && !effectiveClientId`. A rota `/` sem slug agora é tratada como `ClientPickerView`, não como estado de erro. + +Mudança no guard (linha 62–64): +``` +// ANTES +if (hasClients && !effectiveClientId && view !== "clients" && view !== "profile" && view !== "admin") { + return ; +} + +// DEPOIS — removido; essa situação é válida (tela de picker) +``` + +### 7. `src/hooks/useAppOrchestrator.ts` *(alterar)* + +Remover o fallback `clients[0]`: + +```ts +// ANTES +const effectiveClient = + nav.currentClient ?? + (hasClients ? auth.clients[0] : null); + +// DEPOIS +const effectiveClient = nav.currentClient ?? null; +``` + +Também ajustar o `useEffect` de redirect (linhas 40–53): remover o `return` que bloqueia o redirect na raiz. Em vez disso, não há mais lógica de redirect automático — a rota `/` é intencional. + +### 8. `src/App.tsx` *(alterar — verificar)* + +Verificar se `App.tsx` precisa detectar a rota `/` e renderizar `ClientPickerLayout` em vez de `AppLayout`. Isso depende de como `App.tsx` está estruturado — se ele delega ao `AppRouter` via `AppLayout`, pode ser necessário adicionar um gate condicional no topo. + +Lógica: +``` +se pathname === "/" e session existe e !currentSlug → renderiza ClientPickerLayout +senão → renderiza AppLayout normal +``` + +--- + +## Fluxo Completo Após Mudança + +``` +Login → AuthContext carrega clients + → Router em "/" + → App detecta: sem slug, pathname="/" → renderiza ClientPickerLayout + → ClientPickerView: + 1 cliente? → redirect automático para /:slug + 0 clientes? → mensagem + link /clients + N clientes? → grid de ClientCards → usuário clica → navigate(/:slug) + → App volta ao fluxo normal (AppLayout + AppRouter) +``` + +--- + +## O Que NÃO Muda + +- `AppLayout`, `AppSidebar`, `AppHeader` — sem alterações +- `HomeView` — continua existindo como view pós-seleção de cliente (`/:slug`) +- `urlToView` / `viewToPath` em `useAppNavigation` — sem alterações +- Rotas existentes — sem alterações +- `NoClientView` — continua sendo usado para usuários sem nenhum cliente associado + +--- + +## Ordem de Implementação + +1. Criar `ClientCard.tsx` +2. Criar `ClientPickerView.tsx` (com lógica de 1 cliente = redirect) +3. Criar `ClientPickerView/index.ts` +4. Criar `ClientPickerLayout.tsx` (header + mini-sidebar recolhida + main) +5. Alterar `useAppOrchestrator.ts` — remover fallback `clients[0]` +6. Alterar `AppRouter.tsx` — remover guard `!effectiveClientId` +7. Alterar `App.tsx` — adicionar gate para renderizar `ClientPickerLayout` na raiz sem slug +8. Testes: `ClientPickerView.test.tsx` (redirect com 1 cliente, render com N, mensagem com 0) +9. Atualizar `docs/architecture.md` e `docs/decisions.md` + +--- + +## ADR (para `docs/decisions.md`) + +**Decisão:** Substituir o fallback implícito `clients[0]` por uma tela de seleção de cliente obrigatória na rota `/`. +**Racional:** O estado implícito causava bugs onde funcionalidades operavam com um cliente que o usuário não escolheu. A tela de picker torna a escolha explícita e elimina a ambiguidade. +**Consequências:** Usuários com múltiplos clientes veem a tela de seleção a cada login (ou ao navegar para `/`). Usuários com 1 cliente têm redirect transparente (sem fricção). diff --git a/src/App.tsx b/src/App.tsx index e858e5e..326c145 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,14 @@ import { Toaster } from "sonner"; import { useAppOrchestrator } from "@/hooks/useAppOrchestrator"; import { AppLayout } from "@/components/AppLayout"; +import { ClientPickerLayout } from "@/components/ClientPickerLayout"; import { AppModals } from "@/components/AppModals"; import { LoginView } from "@/views/login"; import { OnboardingView } from "@/views/onboarding"; export default function App() { const app = useAppOrchestrator(); + const isGlobalView = app.view === "admin" || app.view === "profile" || app.view === "clients" if (app.auth.loading) { return ( @@ -30,6 +32,24 @@ export default function App() { ); } + if (!app.clientSlug && !isGlobalView) { + return ( + app.selectClient(client.id)} + onSignOut={app.auth.signOut} + onGoToProfile={() => app.handleViewChange("profile")} + transitionTarget={app.transitionTarget} + onTransitionComplete={app.onTransitionComplete} + /> + ); + } + return ( <> diff --git a/src/components/AppRouter.tsx b/src/components/AppRouter.tsx index 6ac72cb..9734e0b 100644 --- a/src/components/AppRouter.tsx +++ b/src/components/AppRouter.tsx @@ -59,10 +59,6 @@ export function AppRouter() { return ; } - if (hasClients && !effectiveClientId && view !== "clients" && view !== "profile" && view !== "admin") { - return ; - } - return ( }> {view === "home" && ( diff --git a/src/components/ClientPickerLayout.tsx b/src/components/ClientPickerLayout.tsx new file mode 100644 index 0000000..1d2a2fc --- /dev/null +++ b/src/components/ClientPickerLayout.tsx @@ -0,0 +1,132 @@ +import { LayoutDashboard, LogOut, Sun, Moon, UserCircle } from "lucide-react" +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui" +import { ClientPickerView } from "@/views/client-picker" +import { ClientTransitionOverlay } from "@/components/ClientTransitionOverlay" +import type { ClientOption } from "@/contexts/AuthContext" + +function getInitials(email?: string) { + if (!email) return "?" + const name = email.split("@")[0] + const parts = name.split(/[._-]/) + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase() + return name.slice(0, 2).toUpperCase() +} + +interface ClientPickerLayoutProps { + userName: string + userEmail?: string + userAvatarUrl?: string | null + darkMode: boolean + onToggleDark: () => void + clients: ClientOption[] + onSelectClient: (client: ClientOption) => void + onSignOut: () => void + onGoToProfile: () => void + transitionTarget?: { id: string | null | undefined; name: string } | null + onTransitionComplete?: () => void +} + +export function ClientPickerLayout({ + userName, + userEmail, + userAvatarUrl, + darkMode, + onToggleDark, + clients, + onSelectClient, + onSignOut, + onGoToProfile, + transitionTarget, + onTransitionComplete, +}: ClientPickerLayoutProps) { + return ( +
+ {/* Header */} +
+
+
+
+ +
+

+ Run/Way +

+
+ +
+
+ +
+ {/* Mini-sidebar — sempre recolhida */} + + +
+ +
+
+ + {transitionTarget && onTransitionComplete && ( + + )} +
+ ) +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index f8590e2..7700347 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -7,6 +7,7 @@ import { DbMemberRowSchema, DbClientRowSchema, DbUserClientRowSchema } from '@/l import { getSafeRedirectUrl } from '@/lib/securityRedirect' import { toast } from 'sonner' import { useClientStore } from '@/store/useClientStore' +import { resolveAccessRole } from '@/lib/accessControl' export interface ClientOption { id: string @@ -112,7 +113,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { const allClients = (rawClients ?? []).map(c => DbClientRowSchema.parse(c)) - if (memberData.access_role !== 'admin') { + const accessRole = resolveAccessRole(memberData as Member) + + if (accessRole !== 'admin') { const { data: uc } = await supabase .from('user_clients') .select('client_id') @@ -161,7 +164,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { const currentMember = memberRef.current - if (!currentMember || currentMember.access_role === 'admin') return + if (!currentMember || resolveAccessRole(currentMember) === 'admin') return const channel = supabase .channel(`user_clients:${currentMember.id}`) @@ -308,7 +311,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { return ( diff --git a/src/hooks/useAppNavigation.ts b/src/hooks/useAppNavigation.ts index ff04b46..81d9ef7 100644 --- a/src/hooks/useAppNavigation.ts +++ b/src/hooks/useAppNavigation.ts @@ -1,5 +1,5 @@ import { useCallback } from "react" -import { useNavigate, useLocation, useParams } from "react-router-dom" +import { useNavigate, useLocation } from "react-router-dom" import type { ClientOption } from "@/contexts/AuthContext" import type { ViewType } from "@/store/useUIStore" import { clientToSlug, slugToClient } from "@/lib/clientSlug" @@ -59,7 +59,10 @@ export function urlToView(pathname: string): ViewType { export function viewToPath(view: ViewType, clientSlug: string | null): string { if (view === "profile") return "/profile" if (view === "admin") return "/admin" - if (view === "clients") return "/clients" + if (view === "clients") { + if (!clientSlug) return "/clients" + return `/${clientSlug}/client-info` + } if (!clientSlug) return "/" @@ -67,7 +70,6 @@ export function viewToPath(view: ViewType, clientSlug: string | null): string { const MAP: Partial> = { home: base, - clients: `${base}/client-info`, demandas: `${base}/tasks`, calendar: `${base}/tasks/calendar`, timeline: `${base}/tasks/timeline`, @@ -103,14 +105,17 @@ export function taskPath( export function useAppNavigation(clients: ClientOption[]) { const navigate = useNavigate() const location = useLocation() - const params = useParams<{ clientSlug?: string; taskId?: string }>() - - const currentSlug = params.clientSlug ?? null + // useParams só funciona dentro de um declarado — como App.tsx não usa AppRoutes + // como wrapper, lemos os segmentos diretamente de location.pathname + const segments = location.pathname.replace(/^\//, "").split("/") + const GLOBAL_ROUTES = new Set(["profile", "admin", "clients", ""]) + const currentSlug = GLOBAL_ROUTES.has(segments[0]) ? null : (segments[0] || null) const currentClient = currentSlug ? slugToClient(currentSlug, clients) : null const view = urlToView(location.pathname) - // taskId presente na URL (para abrir modal) - const urlTaskId = params.taskId ?? null + // taskId: presente quando pathname tem /id/:taskId + const idIdx = segments.indexOf("id") + const urlTaskId = idIdx !== -1 ? (segments[idIdx + 1] ?? null) : null const navigateTo = useCallback( (newView: ViewType, client?: ClientOption | null) => { diff --git a/src/hooks/useAppOrchestrator.ts b/src/hooks/useAppOrchestrator.ts index 826c82c..173969f 100644 --- a/src/hooks/useAppOrchestrator.ts +++ b/src/hooks/useAppOrchestrator.ts @@ -11,6 +11,8 @@ import { useTaskActions } from "@/hooks/useTaskActions"; import { useAppNavigation } from "@/hooks/useAppNavigation"; import { useClientTransition } from "@/hooks/useClientTransition"; import { useMembersQuery } from "@/hooks/useMembersQuery"; +import { useTasksQuery } from "@/hooks/useTasksQuery"; +import { useClientStore } from "@/store/useClientStore"; import { resolveNotificationRoute } from "@/lib/notifications"; import { canAccessView, resolveAccessRole } from "@/lib/accessControl"; import { clientToSlug } from "@/lib/clientSlug"; @@ -27,29 +29,22 @@ export function useAppOrchestrator() { const hasClients = auth.clients.length > 0; const nav = useAppNavigation(auth.clients); + const { selectedClientId: storedClientId, setClient: storeClient } = useClientStore(); - // O cliente efetivo vem da URL (slug) ou cai no primeiro disponível - const effectiveClient = - nav.currentClient ?? - (hasClients ? auth.clients[0] : null); - + const effectiveClient = nav.currentClient ?? null; const effectiveClientId = effectiveClient?.id ?? null; - // Sincroniza: se não há slug na URL mas há clientes, redireciona para o primeiro useEffect(() => { - if (auth.loading || !auth.session) return; - if (!hasClients) return; - if (nav.currentSlug) return; + if (effectiveClientId) storeClient(effectiveClientId); + }, [effectiveClientId, storeClient]); - // Só redireciona se estiver na raiz ou numa rota sem slug conhecida - const isGlobal = ["/profile", "/admin", "/clients"].some((p) => - window.location.pathname.startsWith(p) - ); - if (isGlobal) return; + useEffect(() => { + if (!auth.loading && !nav.currentSlug && storedClientId) { + const lastClient = auth.clients.find((c) => c.id === storedClientId); + if (lastClient) nav.navigateToClient(lastClient); + } + }, [auth.loading, nav.currentSlug, storedClientId, auth.clients, nav]); - // Na raiz "/" não redireciona — HomeView sem cliente é válido - if (window.location.pathname === "/") return; - }, [auth.loading, auth.session, hasClients, nav.currentSlug]); const { data: members = [] } = useMembersQuery(effectiveClientId); @@ -66,16 +61,25 @@ export function useAppOrchestrator() { // Modal state ainda vive no UIStore (não é URL) const isModalOpen = useUIStore((s) => s.isTaskModalOpen); - const closeTaskModal = useUIStore((s) => s.closeTaskModal); + const _closeTaskModal = useUIStore((s) => s.closeTaskModal); + const closeTaskModal = useCallback(() => { + _closeTaskModal(); + // Se o modal foi aberto via URL, limpa o taskId da URL + if (nav.urlTaskId) nav.closeTask(); + }, [_closeTaskModal, nav]); const taskActions = useTaskActions({ createTask, updateTask, deleteTask, effectiveClientId }); - // Abre modal se há taskId na URL + // Carrega task pelo ID da URL e abre o modal + const { data: tasksData } = useTasksQuery(effectiveClientId, auth.isAdmin); useEffect(() => { - if (nav.urlTaskId) { - useUIStore.getState().openTaskModal(); + if (!nav.urlTaskId || !tasksData) return; + const task = tasksData.find((t) => t.id === nav.urlTaskId); + if (task) { + taskActions.openEditTask(task); } - }, [nav.urlTaskId]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nav.urlTaskId, tasksData]); const { transitionTarget, selectClient: selectClientWithTransition, onTransitionComplete } = useClientTransition( @@ -87,9 +91,10 @@ export function useAppOrchestrator() { const selectClient = useCallback( (clientId: string | null | undefined) => { if (!clientId) return; + sidebar.openSidebar(); selectClientWithTransition(clientId); }, - [selectClientWithTransition] + [selectClientWithTransition, sidebar] ); const handleViewChange = useCallback( @@ -106,13 +111,12 @@ export function useAppOrchestrator() { [effectiveClientId, effectiveClient, accessRole, nav, sidebar] ); - // Garante que a view atual é acessível useEffect(() => { - if (!auth.loading && auth.session) { + if (!auth.loading && auth.session && nav.currentSlug) { const canAccess = canAccessView(nav.view, accessRole, effectiveClientId !== null); if (!canAccess) nav.navigateTo("clients", effectiveClient); } - }, [auth.loading, auth.session, effectiveClientId, effectiveClient, accessRole, nav.view]); + }, [auth.loading, auth.session, nav, effectiveClientId, effectiveClient, accessRole]); const handleNotificationClick = useCallback( (notification: Notification) => { diff --git a/src/hooks/useAppSidebar.ts b/src/hooks/useAppSidebar.ts index 55e6259..e43f84a 100644 --- a/src/hooks/useAppSidebar.ts +++ b/src/hooks/useAppSidebar.ts @@ -12,6 +12,11 @@ export function useAppSidebar() { const [sidebarOpen, setSidebarOpen] = useState(resolveInitialOpen); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const openSidebar = () => { + setSidebarOpen(true); + localStorage.setItem("sidebarOpen", "true"); + }; + const toggleSidebar = () => { setSidebarOpen((prev) => { localStorage.setItem("sidebarOpen", String(!prev)); @@ -25,6 +30,7 @@ export function useAppSidebar() { return { sidebarOpen, mobileSidebarOpen, + openSidebar, toggleSidebar, openMobileSidebar, closeMobileSidebar, diff --git a/src/lib/__tests__/accessControl.test.ts b/src/lib/__tests__/accessControl.test.ts index 37e88ee..84af816 100644 --- a/src/lib/__tests__/accessControl.test.ts +++ b/src/lib/__tests__/accessControl.test.ts @@ -11,8 +11,8 @@ describe('accessControl', () => { expect(resolveAccessRole(null)).toBeNull() }) - it('retorna null se access_role é undefined', () => { - expect(resolveAccessRole({ id: '1' } as never)).toBeNull() + it('retorna user por padrão se access_role é undefined', () => { + expect(resolveAccessRole({ id: '1', role: 'Designer' } as never)).toBe('user') }) it('retorna admin para access_role admin', () => { @@ -22,6 +22,18 @@ describe('accessControl', () => { it('retorna user para access_role user', () => { expect(resolveAccessRole({ id: '1', access_role: 'user' } as never)).toBe('user') }) + + it('normaliza access_role em maiúsculas', () => { + expect(resolveAccessRole({ id: '1', access_role: 'ADMIN' } as never)).toBe('admin') + }) + + it('retorna admin usando role legado', () => { + expect(resolveAccessRole({ id: '1', role: 'admin' } as never)).toBe('admin') + }) + + it('retorna admin usando role legado textual', () => { + expect(resolveAccessRole({ id: '1', role: 'Administrador' } as never)).toBe('admin') + }) }) describe('hasRolePermission', () => { @@ -71,4 +83,4 @@ describe('accessControl', () => { expect(canAccessView('admin', 'user', false)).toBe(false) }) }) -}) \ No newline at end of file +}) diff --git a/src/lib/accessControl.ts b/src/lib/accessControl.ts index 3888736..c61f453 100644 --- a/src/lib/accessControl.ts +++ b/src/lib/accessControl.ts @@ -64,8 +64,15 @@ const VIEW_RULES: Record = { } export function resolveAccessRole(member: Member | null): AccessRole | null { - if (!member?.access_role) return null - return member.access_role === 'admin' ? 'admin' : 'user' + if (!member) return null + const explicitAccessRole = member.access_role?.trim().toLowerCase() + if (explicitAccessRole === 'admin' || explicitAccessRole === 'user') return explicitAccessRole + + const legacyRole = member.role?.trim().toLowerCase() ?? '' + if (legacyRole === 'admin' || legacyRole === 'user') return legacyRole + if (legacyRole.includes('admin')) return 'admin' + + return 'user' } export function hasRolePermission(role: AccessRole | null, permission: AppPermission): boolean { diff --git a/src/views/client-picker/ClientPickerView.tsx b/src/views/client-picker/ClientPickerView.tsx new file mode 100644 index 0000000..7f9ea3c --- /dev/null +++ b/src/views/client-picker/ClientPickerView.tsx @@ -0,0 +1,67 @@ +import { useEffect } from "react" +import { ClientCard } from "./components/ClientCard" +import type { ClientOption } from "@/contexts/AuthContext" + +interface ClientPickerViewProps { + userName: string + clients: ClientOption[] + onSelectClient: (client: ClientOption) => void +} + +export function ClientPickerView({ userName, clients, onSelectClient }: ClientPickerViewProps) { + const firstName = userName.split(" ")[0] + + useEffect(() => { + if (clients.length === 1) { + onSelectClient(clients[0]) + } + }, [clients, onSelectClient]) + + if (clients.length === 1) return null + + return ( +
+ {/* Grade de fundo estilo blueprint */} +
+ +
+ {/* Cabeçalho */} +
+
+ Bem-vindo, {firstName} +
+

+ Qual projeto hoje? +

+

+ Selecione um cliente para acessar o painel de capacity planning. +

+
+ + {/* Grid de clientes */} + {clients.length === 0 ? ( +

+ Nenhum cliente associado à sua conta. +

+ ) : ( +
+ {clients.map((client) => ( + onSelectClient(client)} + /> + ))} +
+ )} +
+
+ ) +} diff --git a/src/views/client-picker/__tests__/ClientPickerView.test.tsx b/src/views/client-picker/__tests__/ClientPickerView.test.tsx new file mode 100644 index 0000000..43dd3a3 --- /dev/null +++ b/src/views/client-picker/__tests__/ClientPickerView.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ClientPickerView } from '../ClientPickerView' +import type { ClientOption } from '@/contexts/AuthContext' + +const mockClients: ClientOption[] = [ + { id: '1', name: 'Acme Corp' }, + { id: '2', name: 'Beta Inc' }, + { id: '3', name: 'Gamma Ltd' }, +] + +describe('ClientPickerView', () => { + it('exibe saudação e cards quando há múltiplos clientes', () => { + render( + + ) + expect(screen.getByText(/Bem-vindo, João/)).toBeDefined() + expect(screen.getByText('Acme Corp')).toBeDefined() + expect(screen.getByText('Beta Inc')).toBeDefined() + expect(screen.getByText('Gamma Ltd')).toBeDefined() + }) + + it('chama onSelectClient ao clicar num card', async () => { + const onSelectClient = vi.fn() + render( + + ) + await userEvent.click(screen.getByText('Beta Inc')) + expect(onSelectClient).toHaveBeenCalledWith(mockClients[1]) + }) + + it('chama onSelectClient automaticamente quando há apenas 1 cliente', () => { + const onSelectClient = vi.fn() + render( + + ) + expect(onSelectClient).toHaveBeenCalledWith(mockClients[0]) + }) + + it('renderiza null (sem grid) quando há apenas 1 cliente', () => { + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it('exibe mensagem quando não há clientes', () => { + render( + + ) + expect(screen.getByText('Nenhum cliente associado à sua conta.')).toBeDefined() + }) +}) diff --git a/src/views/client-picker/components/ClientCard.tsx b/src/views/client-picker/components/ClientCard.tsx new file mode 100644 index 0000000..0f4fe94 --- /dev/null +++ b/src/views/client-picker/components/ClientCard.tsx @@ -0,0 +1,38 @@ +import { ArrowRight } from "lucide-react" +import { cn } from "@/lib/utils" +import type { ClientOption } from "@/contexts/AuthContext" + +interface ClientCardProps { + client: ClientOption + onClick: () => void +} + +export function ClientCard({ client, onClick }: ClientCardProps) { + const initials = client.name.slice(0, 2).toUpperCase() + + return ( + + ) +} diff --git a/src/views/client-picker/index.ts b/src/views/client-picker/index.ts new file mode 100644 index 0000000..d0eccb7 --- /dev/null +++ b/src/views/client-picker/index.ts @@ -0,0 +1 @@ +export { ClientPickerView } from "./ClientPickerView" From 4199e4cdb400253f200900c754384a7eaeb17826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Fri, 24 Apr 2026 13:25:16 -0300 Subject: [PATCH 05/11] feat: :sparkles: ajustado view e acesso pra admin --- docs/architecture.md | 16 ++++++++++------ docs/decisions.md | 9 +++++++++ docs/views/admin.md | 3 ++- src/App.tsx | 2 +- src/components/AppRoutes.tsx | 6 ++++-- src/hooks/useAppNavigation.ts | 7 ++++--- src/hooks/useAppOrchestrator.ts | 11 +++++++++-- src/lib/accessControl.ts | 2 +- src/store/useClientStore.ts | 17 +++++++++++++---- src/views/client-picker/ClientPickerView.tsx | 2 +- 10 files changed, 54 insertions(+), 21 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index af6c0cb..04ae4a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,14 +121,18 @@ openTaskModal() / closeTaskModal() Cliente selecionado. **Persiste no localStorage** (`client-store`). Não persiste `undefined`. ```ts selectedClientId: string | null | undefined -setClient(id) +selectedAt: number | null // timestamp (ms) da última seleção +setClient(id) // grava id + selectedAt = Date.now() +isClientBuffValid() // true se selectedAt < 4h atrás ``` -| Valor | Significado | +| Valor de `selectedClientId` | Significado | |---|---| | `undefined` | Não inicializado — aguarda resolução da auth | | `null` | Admin vê todos os clientes (sem filtro no fetch) | | `string` | Cliente específico selecionado | +O "buff" de 4h (`CLIENT_BUFF_MS = 4 * 60 * 60 * 1000`) é verificado em `useAppOrchestrator` antes de restaurar o cliente automaticamente. Após expirar, o usuário vê o `ClientPickerView` independentemente do valor persistido. + ### `useTaskStore` Estado local mínimo para suporte a **update otimista**. O fetch de tasks foi migrado para `useTasksQuery` (TanStack Query). ```ts @@ -202,7 +206,7 @@ Relê o perfil do usuário atual (member + clients) sem reiniciar o ciclo de aut ## Leitura do clientSlug na URL -`useAppNavigation` deriva `currentSlug` diretamente de `location.pathname` em vez de `useParams`. O motivo: `App.tsx` está montado diretamente dentro de `` sem nenhum `` como ancestral — logo `useParams()` retorna sempre `{}`. A extração manual lê o primeiro segmento do pathname e descarta rotas globais conhecidas (`profile`, `admin`, `clients`, `""`). +`useAppNavigation` deriva `currentSlug` diretamente de `location.pathname` em vez de `useParams`. O motivo: `App.tsx` está montado diretamente dentro de `` sem nenhum `` como ancestral — logo `useParams()` retorna sempre `{}`. A extração manual lê o primeiro segmento do pathname e descarta rotas globais conhecidas (`profile`, `clients`, `""`). Nota: `admin` **não** é uma rota global — sua URL é `/:clientSlug/admin` e `currentSlug` é derivado normalmente. `urlTaskId` é derivado da mesma forma: busca o segmento `"id"` no pathname e retorna o próximo segmento. @@ -210,10 +214,10 @@ Relê o perfil do usuário atual (member + clients) sem reiniciar o ciclo de aut `useAppOrchestrator` integra `useClientStore` para dois comportamentos: -1. **Persistência:** sempre que `effectiveClientId` muda (URL com slug válido), grava o ID na `useClientStore` (localStorage via zustand/persist). -2. **Restauração:** ao cair em `/` sem slug (ex: pós-login), se `storedClientId` existir e corresponder a um cliente válido do usuário, redireciona automaticamente para `/:slug` — sem exibir o `ClientPickerView`. +1. **Persistência:** sempre que `effectiveClientId` muda (URL com slug válido), grava o ID na `useClientStore` (localStorage via zustand/persist) junto com `selectedAt` (timestamp da seleção). +2. **Restauração com TTL de 4h ("buff"):** ao cair em `/` sem slug (ex: pós-login), redireciona automaticamente para `/:slug` **somente se** `storedClientId` existir, corresponder a um cliente válido do usuário, **e** a seleção tiver menos de 4 horas (`isClientBuffValid()`). Após expirar, o usuário vê o `ClientPickerView` normalmente. -Usuários com apenas 1 cliente já tinham redirect automático no `ClientPickerView`. Este mecanismo cobre usuários com múltiplos clientes que já fizeram uma escolha anterior. +Usuários com apenas 1 cliente já tinham redirect automático no `ClientPickerView`. Este mecanismo cobre usuários com múltiplos clientes que já fizeram uma escolha anterior, com expiração automática para forçar re-seleção após uma sessão longa. ## Troca de Cliente diff --git a/docs/decisions.md b/docs/decisions.md index 80903c1..f1b5c05 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -154,3 +154,12 @@ Registro de decisões arquiteturais significativas do projeto Run/Way. **Decisão:** a rota `/` (sem `clientSlug`) exibe uma tela de boas-vindas (`ClientPickerView`) com cards dos clientes disponíveis, em vez de assumir `clients[0]` implicitamente; usuários com 1 cliente têm redirect transparente; `App.tsx` renderiza `ClientPickerLayout` (header + mini-sidebar recolhida) quando `!clientSlug` **Racional:** o fallback `clients[0]` causava estado ambíguo logo após o login — o usuário nunca escolheu um cliente, mas a app operava como se tivesse, gerando bugs em funcionalidades que dependem do cliente efetivo; tornar a escolha explícita elimina a ambiguidade estruturalmente **Consequências:** usuários com múltiplos clientes veem a tela de seleção ao acessar `/`; usuários com 1 cliente têm redirect automático sem fricção; o fallback `clients[0]` foi removido de `useAppOrchestrator`; o guard `!effectiveClientId` foi removido de `AppRouter` (esse estado é agora válido e tratado antes do AppLayout) + +--- + +## ADR-018: AdminView com rota escopada por cliente (`/:clientSlug/admin`) + +**Status:** Aceito (Abr 2026) +**Decisão:** a `AdminView` passa a ser acessada via `/:clientSlug/admin` em vez de `/admin` (rota global sem slug); `admin` foi removido de `GLOBAL_ROUTES` em `useAppNavigation`; a regra em `accessControl.ts` passou a ter `requiresClient: true` +**Racional:** admin gerencia dados (clientes, membros, notificações) que pertencem a uma empresa específica; ter o `clientSlug` na URL mantém consistência com o restante da aplicação, permite deep links contextuais e prepara a estrutura para um futuro multi-tenant onde cada empresa terá seu próprio escopo de admin +**Consequências:** a URL `/admin` deixa de existir (redireciona para `/` via wildcard); é necessário ter um cliente selecionado para acessar admin; `isGlobalView` em `App.tsx` não inclui mais `"admin"`, logo admin usa o `AppLayout` normal com sidebar diff --git a/docs/views/admin.md b/docs/views/admin.md index c596218..c8aa543 100644 --- a/docs/views/admin.md +++ b/docs/views/admin.md @@ -1,7 +1,8 @@ # AdminView **Ficheiro:** `src/views/admin/AdminView.tsx` -**Acesso:** exclusivo para `member.access_role === 'admin'` — verificado no componente via `useAuthContext` +**URL:** `/:clientSlug/admin` +**Acesso:** exclusivo para `member.access_role === 'admin'` — verificado no componente via `useAuthContext`; requer cliente selecionado (`requiresClient: true` em `accessControl.ts`) ## Estrutura diff --git a/src/App.tsx b/src/App.tsx index 326c145..1db50ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,7 @@ import { OnboardingView } from "@/views/onboarding"; export default function App() { const app = useAppOrchestrator(); - const isGlobalView = app.view === "admin" || app.view === "profile" || app.view === "clients" + const isGlobalView = app.view === "profile" || app.view === "clients" if (app.auth.loading) { return ( diff --git a/src/components/AppRoutes.tsx b/src/components/AppRoutes.tsx index 3b40f6a..8eb17fe 100644 --- a/src/components/AppRoutes.tsx +++ b/src/components/AppRoutes.tsx @@ -25,7 +25,7 @@ import { Routes, Route, Navigate } from "react-router-dom" * /:clientSlug/tools/export * /:clientSlug/tools/integrations * /profile → ProfileView - * /admin → AdminView + * /:clientSlug/admin → AdminView * /clients → UserClientsView (sem cliente) * / → HomeView (sem cliente selecionado) */ @@ -37,7 +37,6 @@ export function AppRoutes() { {/* Rotas globais (sem cliente) */} - {/* Rotas por cliente */} @@ -54,6 +53,9 @@ export function AppRoutes() { + {/* Admin */} + + {/* Members */} diff --git a/src/hooks/useAppNavigation.ts b/src/hooks/useAppNavigation.ts index 81d9ef7..353e45f 100644 --- a/src/hooks/useAppNavigation.ts +++ b/src/hooks/useAppNavigation.ts @@ -12,7 +12,6 @@ export function urlToView(pathname: string): ViewType { // Rotas globais (sem clientSlug) const first = segments[0] if (first === "profile") return "profile" - if (first === "admin") return "admin" if (first === "clients") return "clients" // /:clientSlug/... @@ -21,6 +20,8 @@ export function urlToView(pathname: string): ViewType { const [section, sub] = rest + if (section === "admin") return "admin" + if (section === "client-info") return "clients" if (section === "tasks") { @@ -58,7 +59,7 @@ export function urlToView(pathname: string): ViewType { export function viewToPath(view: ViewType, clientSlug: string | null): string { if (view === "profile") return "/profile" - if (view === "admin") return "/admin" + if (view === "admin") return clientSlug ? `/${clientSlug}/admin` : "/admin" if (view === "clients") { if (!clientSlug) return "/clients" return `/${clientSlug}/client-info` @@ -108,7 +109,7 @@ export function useAppNavigation(clients: ClientOption[]) { // useParams só funciona dentro de um declarado — como App.tsx não usa AppRoutes // como wrapper, lemos os segmentos diretamente de location.pathname const segments = location.pathname.replace(/^\//, "").split("/") - const GLOBAL_ROUTES = new Set(["profile", "admin", "clients", ""]) + const GLOBAL_ROUTES = new Set(["profile", "clients", ""]) const currentSlug = GLOBAL_ROUTES.has(segments[0]) ? null : (segments[0] || null) const currentClient = currentSlug ? slugToClient(currentSlug, clients) : null const view = urlToView(location.pathname) diff --git a/src/hooks/useAppOrchestrator.ts b/src/hooks/useAppOrchestrator.ts index 173969f..052ce7f 100644 --- a/src/hooks/useAppOrchestrator.ts +++ b/src/hooks/useAppOrchestrator.ts @@ -29,7 +29,7 @@ export function useAppOrchestrator() { const hasClients = auth.clients.length > 0; const nav = useAppNavigation(auth.clients); - const { selectedClientId: storedClientId, setClient: storeClient } = useClientStore(); + const { selectedClientId: storedClientId, setClient: storeClient, isClientBuffValid } = useClientStore(); const effectiveClient = nav.currentClient ?? null; const effectiveClientId = effectiveClient?.id ?? null; @@ -39,7 +39,14 @@ export function useAppOrchestrator() { }, [effectiveClientId, storeClient]); useEffect(() => { - if (!auth.loading && !nav.currentSlug && storedClientId) { + const shouldRestoreLastClient = + !auth.loading && + !nav.currentSlug && + nav.view === "home" && + storedClientId && + isClientBuffValid() + + if (shouldRestoreLastClient) { const lastClient = auth.clients.find((c) => c.id === storedClientId); if (lastClient) nav.navigateToClient(lastClient); } diff --git a/src/lib/accessControl.ts b/src/lib/accessControl.ts index c61f453..45b43ef 100644 --- a/src/lib/accessControl.ts +++ b/src/lib/accessControl.ts @@ -54,7 +54,7 @@ const VIEW_RULES: Record = { 'reports-timeline': { requiresClient: true, roles: ['admin', 'user'], permission: 'view:reports' }, 'reports-membros': { requiresClient: true, roles: ['admin', 'user'], permission: 'view:reports' }, 'reports-alertas': { requiresClient: true, roles: ['admin', 'user'], permission: 'view:reports' }, - admin: { requiresClient: false, roles: ['admin'], permission: 'view:admin' }, + admin: { requiresClient: true, roles: ['admin'], permission: 'view:admin' }, tools: { requiresClient: false, roles: ['admin', 'user'], permission: 'view:tools' }, 'tools-briefing-analyzer': { requiresClient: false, roles: ['admin', 'user'], permission: 'view:tools' }, 'tools-import': { requiresClient: false, roles: ['admin', 'user'], permission: 'view:tools' }, diff --git a/src/store/useClientStore.ts b/src/store/useClientStore.ts index 6a080a2..a3ce379 100644 --- a/src/store/useClientStore.ts +++ b/src/store/useClientStore.ts @@ -1,24 +1,33 @@ import { create } from 'zustand' import { persist, devtools } from 'zustand/middleware' +const CLIENT_BUFF_MS = 4 * 60 * 60 * 1000 // 4 horas + interface ClientState { selectedClientId: string | null | undefined + selectedAt: number | null setClient: (id: string | null | undefined) => void + isClientBuffValid: () => boolean } export const useClientStore = create()( devtools( persist( - (set) => ({ + (set, get) => ({ selectedClientId: undefined, - setClient: (id) => set({ selectedClientId: id }), + selectedAt: null, + setClient: (id) => set({ selectedClientId: id, selectedAt: id ? Date.now() : null }), + isClientBuffValid: () => { + const { selectedAt } = get() + if (!selectedAt) return false + return Date.now() - selectedAt < CLIENT_BUFF_MS + }, }), { name: 'client-store', - // Não persistir undefined — só string | null partialize: (state) => state.selectedClientId !== undefined - ? { selectedClientId: state.selectedClientId } + ? { selectedClientId: state.selectedClientId, selectedAt: state.selectedAt } : {}, } ), diff --git a/src/views/client-picker/ClientPickerView.tsx b/src/views/client-picker/ClientPickerView.tsx index 7f9ea3c..79849be 100644 --- a/src/views/client-picker/ClientPickerView.tsx +++ b/src/views/client-picker/ClientPickerView.tsx @@ -20,7 +20,7 @@ export function ClientPickerView({ userName, clients, onSelectClient }: ClientPi if (clients.length === 1) return null return ( -
+
{/* Grade de fundo estilo blueprint */}
Date: Fri, 24 Apr 2026 13:30:31 -0300 Subject: [PATCH 06/11] feat: :sparkles: Atualizado overlay transition --- src/App.tsx | 33 +++++++++++-------- src/components/ClientPickerLayout.tsx | 11 ------- .../client-picker/components/ClientCard.tsx | 2 +- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1db50ad..8650375 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { useAppOrchestrator } from "@/hooks/useAppOrchestrator"; import { AppLayout } from "@/components/AppLayout"; import { ClientPickerLayout } from "@/components/ClientPickerLayout"; import { AppModals } from "@/components/AppModals"; +import { ClientTransitionOverlay } from "@/components/ClientTransitionOverlay"; import { LoginView } from "@/views/login"; import { OnboardingView } from "@/views/onboarding"; @@ -34,19 +35,25 @@ export default function App() { if (!app.clientSlug && !isGlobalView) { return ( - app.selectClient(client.id)} - onSignOut={app.auth.signOut} - onGoToProfile={() => app.handleViewChange("profile")} - transitionTarget={app.transitionTarget} - onTransitionComplete={app.onTransitionComplete} - /> + <> + app.selectClient(client.id)} + onSignOut={app.auth.signOut} + onGoToProfile={() => app.handleViewChange("profile")} + /> + {app.transitionTarget && ( + + )} + ); } diff --git a/src/components/ClientPickerLayout.tsx b/src/components/ClientPickerLayout.tsx index 1d2a2fc..bdade52 100644 --- a/src/components/ClientPickerLayout.tsx +++ b/src/components/ClientPickerLayout.tsx @@ -8,7 +8,6 @@ import { DropdownMenuSeparator, } from "@/components/ui" import { ClientPickerView } from "@/views/client-picker" -import { ClientTransitionOverlay } from "@/components/ClientTransitionOverlay" import type { ClientOption } from "@/contexts/AuthContext" function getInitials(email?: string) { @@ -29,8 +28,6 @@ interface ClientPickerLayoutProps { onSelectClient: (client: ClientOption) => void onSignOut: () => void onGoToProfile: () => void - transitionTarget?: { id: string | null | undefined; name: string } | null - onTransitionComplete?: () => void } export function ClientPickerLayout({ @@ -43,8 +40,6 @@ export function ClientPickerLayout({ onSelectClient, onSignOut, onGoToProfile, - transitionTarget, - onTransitionComplete, }: ClientPickerLayoutProps) { return (
@@ -121,12 +116,6 @@ export function ClientPickerLayout({
- {transitionTarget && onTransitionComplete && ( - - )}
) } diff --git a/src/views/client-picker/components/ClientCard.tsx b/src/views/client-picker/components/ClientCard.tsx index 0f4fe94..4264c33 100644 --- a/src/views/client-picker/components/ClientCard.tsx +++ b/src/views/client-picker/components/ClientCard.tsx @@ -14,7 +14,7 @@ export function ClientCard({ client, onClick }: ClientCardProps) { - - -
-
-
- ); -} diff --git a/src/views/dashboard/index.ts b/src/views/dashboard/index.ts deleted file mode 100644 index 7d0da10..0000000 --- a/src/views/dashboard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as DashboardView } from './DashboardView'; diff --git a/src/views/list/ListView.tsx b/src/views/list/ListView.tsx index 38069d7..7a20549 100644 --- a/src/views/list/ListView.tsx +++ b/src/views/list/ListView.tsx @@ -5,9 +5,9 @@ import { useClients } from '@/hooks/useClients'; import { useTaskQuickActions } from '@/hooks/useTaskQuickActions'; import type { Task } from '@/lib/steps'; import { useListFilters } from './hooks/useListFilters'; -import { FilterBar } from '@/views/dashboard/components/FilterBar'; +import { FilterBar } from '@/views/planning/components/FilterBar'; import { MonthGroup } from './components/MonthGroup'; -import { useTaskFilters } from '@/views/dashboard/hooks/useTaskFilters'; +import { useTaskFilters } from '@/views/planning/hooks/useTaskFilters'; interface ListViewProps { onEdit: (task: Task) => void; diff --git a/src/views/planning/PlanningView.tsx b/src/views/planning/PlanningView.tsx new file mode 100644 index 0000000..49667e0 --- /dev/null +++ b/src/views/planning/PlanningView.tsx @@ -0,0 +1,304 @@ +import { useEffect, useMemo, useState } from 'react'; +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 { useTaskQuickActions } from '@/hooks/useTaskQuickActions'; +import { CalendarView } from '@/views/calendar'; +import TimelineView from '@/views/timeline'; +import { ListView } from '@/views/list'; +import { useTaskFilters } from './hooks/useTaskFilters'; +import { FilterBar } from './components/FilterBar'; +import { StepsLegend } from './components/StepsLegend'; +import { TasksFilters, type FiltersState } from './components/TasksFilters'; +import { StepGroup } from './components/StepGroup'; +import type { PlanningViewProps } from '@/types/props'; +import { ViewState } from '@/components/ViewState'; +import { CalendarX2, DatabaseZap, FilterX, Search, Plus } from 'lucide-react'; +import { Skeleton } from 'boneyard-js/react'; +import { STEP_TYPES_ORDER, type StepType, type Task } from '@/lib/steps'; +import { Button } from '@/components/ui/Button'; + +const PLANNING_BONES = { + name: 'planning-view', + viewportWidth: 1280, + width: 1100, + height: 560, + bones: [ + { x: 0, y: 0, w: 100, h: 48, r: 10 }, + { x: 0, y: 64, w: 100, h: 68, r: 10 }, + { x: 0, y: 148, w: 100, h: 84, r: 10 }, + { x: 0, y: 248, w: 100, h: 300, r: 12 }, + ], +}; + +const EMPTY_FILTERS: FiltersState = { + searchTerm: '', + selectedSteps: [], + selectedMemberIds: [], + selectedPeriod: '', + showOnlyBlocked: false, +}; + +const VIEW_TITLES: Record = { + calendar: { title: 'Calendário', description: 'Visualize as demandas em calendário mensal.' }, + timeline: { title: 'Linha do Tempo', description: 'Acompanhe as fases das demandas em Gantt.' }, + list: { title: 'Lista', description: 'Todas as demandas em formato de tabela.' }, + demandas: { title: 'Demandas', description: 'Visualize todas as demandas por etapa atual.' }, +}; + +const PlanningView: React.FC = ({ subview, onEdit, onDelete, onUpdateTask, onOpenNew, onExport, holidays }) => { + const { isAdmin, member } = useAuthContext(); + const { effectiveClientId } = useClients(); + const queryClient = useQueryClient(); + const dashboardRedirect = useUIStore((s) => s.dashboardRedirect); + const clearDashboardRedirect = useUIStore((s) => s.clearDashboardRedirect); + 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 { concludeTask, toggleBlock } = useTaskQuickActions(member?.auth_user_id); + + const { + filterAssignee, setFilterAssignee, + filterStatus, setFilterStatus, + filterSteps, + filterPeriodDays, setFilterPeriodDays, + viewMode, setViewMode, + hasActiveFilters, + clearFilters, toggleStepFilter, + filteredTasks, + } = useTaskFilters(tasks ?? [], subview === 'timeline', dashboardRedirect?.assigneeId ?? ''); + + const [demandasFilters, setDemandasFilters] = useState(EMPTY_FILTERS); + + useEffect(() => { + if (!dashboardRedirect) return; + clearDashboardRedirect(); + }, [dashboardRedirect, clearDashboardRedirect]); + + const hasData = tasks.length > 0 || members.length > 0; + const isLoading = tasksLoading || membersLoading; + const errorMessage = tasksError || membersError; + + const handleRetry = () => { + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: ['members'] }); + }; + + const filteredDemandasTasks = useMemo(() => { + if (subview !== 'demandas') return []; + const { searchTerm, selectedSteps, selectedMemberIds, showOnlyBlocked, selectedPeriod } = demandasFilters; + return tasks.filter(task => { + const matchSearch = + task.title.toLowerCase().includes(searchTerm.toLowerCase()) || + task.id.toLowerCase().includes(searchTerm.toLowerCase()); + + const currentStep = task.steps.find(s => s.active) ?? task.steps[0]; + const matchStep = selectedSteps.length > 0 ? selectedSteps.includes(currentStep?.type) : true; + const matchMember = selectedMemberIds.length > 0 + ? task.steps.some(s => s.assignees.some(a => selectedMemberIds.includes(a))) + : true; + const matchBlocked = showOnlyBlocked ? task.status.blocked : true; + + let matchPeriod = true; + if (selectedPeriod && currentStep?.end) { + const due = new Date(currentStep.end + 'T00:00:00'); + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() + parseInt(selectedPeriod)); + matchPeriod = due <= cutoff; + } else if (selectedPeriod && !currentStep?.end) { + matchPeriod = false; + } + + return matchSearch && matchStep && matchMember && matchBlocked && matchPeriod; + }); + }, [tasks, demandasFilters, subview]); + + const groupedDemandasTasks = useMemo(() => { + const { selectedMemberIds } = demandasFilters; + const groups = new Map(); + STEP_TYPES_ORDER.forEach(step => groups.set(step, [])); + filteredDemandasTasks.forEach(task => { + let stepsToUse = task.steps.filter(s => s.active); + if (stepsToUse.length === 0) stepsToUse = task.steps.slice(0, 1); + + if (selectedMemberIds.length > 0) { + const memberSteps = task.steps.filter(s => + s.assignees.some(a => selectedMemberIds.includes(a)) + ); + if (memberSteps.length > 0) stepsToUse = memberSteps; + } + + stepsToUse.forEach(step => { + const bucket = groups.get(step.type); + if (bucket) bucket.push(task); + }); + }); + + groups.forEach((bucket, stepType) => { + bucket.sort((a, b) => { + const endA = a.steps.find(s => s.type === stepType)?.end; + const endB = b.steps.find(s => s.type === stepType)?.end; + if (!endA && !endB) return 0; + if (!endA) return 1; + if (!endB) return -1; + return endA < endB ? -1 : endA > endB ? 1 : 0; + }); + }); + + return groups; + }, [filteredDemandasTasks, demandasFilters]); + + const hasDemandasActiveFilters = + demandasFilters.searchTerm !== '' || + demandasFilters.selectedSteps.length > 0 || + demandasFilters.selectedMemberIds.length > 0 || + demandasFilters.selectedPeriod !== '' || + demandasFilters.showOnlyBlocked; + + if (errorMessage && !hasData) { + return ( + + ); + } + + if (tasks.length === 0 && !isLoading) { + return ( + + ); + } + + const { title, description } = VIEW_TITLES[subview] ?? VIEW_TITLES.calendar; + + const showFilterBar = subview === 'calendar' || subview === 'timeline'; + + const content = ( +
+
+

{title}

+

{description}

+
+ + {showFilterBar && ( + + )} + + {showFilterBar && filteredTasks.length === 0 ? ( + + ) : subview === 'calendar' ? ( + + ) : subview === 'timeline' ? ( + + ) : subview === 'list' ? ( + onDelete(task.id)} onOpenNew={onOpenNew} onExport={onExport} /> + ) : subview === 'demandas' ? ( +
+
+ setDemandasFilters(prev => ({ ...prev, ...next }))} + onClear={() => setDemandasFilters(EMPTY_FILTERS)} + /> + +
+ + {tasks.length === 0 ? ( +
+ +

Nenhuma demanda cadastrada ainda.

+ +
+ ) : ( +
+ {([...groupedDemandasTasks.entries()] as [StepType, Task[]][]) + .filter(([stepType]) => + demandasFilters.selectedSteps.length === 0 || demandasFilters.selectedSteps.includes(stepType) + ) + .map(([stepType, stepTasks]) => ( + + ))} + {filteredDemandasTasks.length === 0 && hasDemandasActiveFilters && ( +
+

Nenhuma demanda encontrada com os filtros atuais.

+ +
+ )} +
+ )} +
+ ) : null} + + {subview !== 'demandas' && } +
+ ); + + return ( + + {content} + + ); +}; + +export default PlanningView; diff --git a/src/views/tasks/components/ActionMenu.tsx b/src/views/planning/components/ActionMenu.tsx similarity index 100% rename from src/views/tasks/components/ActionMenu.tsx rename to src/views/planning/components/ActionMenu.tsx diff --git a/src/views/dashboard/components/FilterBar.tsx b/src/views/planning/components/FilterBar.tsx similarity index 100% rename from src/views/dashboard/components/FilterBar.tsx rename to src/views/planning/components/FilterBar.tsx diff --git a/src/views/dashboard/components/MetricsBar.tsx b/src/views/planning/components/MetricsBar.tsx similarity index 100% rename from src/views/dashboard/components/MetricsBar.tsx rename to src/views/planning/components/MetricsBar.tsx diff --git a/src/views/tasks/components/StepGroup.tsx b/src/views/planning/components/StepGroup.tsx similarity index 94% rename from src/views/tasks/components/StepGroup.tsx rename to src/views/planning/components/StepGroup.tsx index aea098b..19727e8 100644 --- a/src/views/tasks/components/StepGroup.tsx +++ b/src/views/planning/components/StepGroup.tsx @@ -25,20 +25,17 @@ export function StepGroup({ stepType, tasks, members, onToggleBlock, onConclude, return (
- {/* Header da categoria */} - {/* Separator entre header e lista */} {!isEmpty && isExpanded && (
)} - {/* Lista de tarefas */} {!isEmpty && isExpanded && (
{tasks.length > VIRTUALIZE_THRESHOLD ? ( diff --git a/src/views/dashboard/components/StepsLegend.tsx b/src/views/planning/components/StepsLegend.tsx similarity index 100% rename from src/views/dashboard/components/StepsLegend.tsx rename to src/views/planning/components/StepsLegend.tsx diff --git a/src/views/tasks/components/TaskRow.tsx b/src/views/planning/components/TaskRow.tsx similarity index 100% rename from src/views/tasks/components/TaskRow.tsx rename to src/views/planning/components/TaskRow.tsx diff --git a/src/views/tasks/components/TasksFilters.tsx b/src/views/planning/components/TasksFilters.tsx similarity index 94% rename from src/views/tasks/components/TasksFilters.tsx rename to src/views/planning/components/TasksFilters.tsx index a1a25e7..cce5028 100644 --- a/src/views/tasks/components/TasksFilters.tsx +++ b/src/views/planning/components/TasksFilters.tsx @@ -112,7 +112,6 @@ export function TasksFilters({ filters, members, onChange, onClear }: TasksFilte return (
- {/* Search — grows to fill available space */}
- {/* Controls — fixed, won't collapse or shift */}
- {/* Etapa — checkbox dropdown */} ({ value: s, label: STEP_META[s].label }))} @@ -134,7 +131,6 @@ export function TasksFilters({ filters, members, onChange, onClear }: TasksFilte onToggle={v => toggleStep(v as StepType)} /> - {/* Responsável — checkbox dropdown */} } @@ -143,7 +139,6 @@ export function TasksFilters({ filters, members, onChange, onClear }: TasksFilte onToggle={toggleMember} /> - {/* Período (prazo) */}
{PERIOD_TABS.map(tab => ( @@ -161,7 +156,6 @@ export function TasksFilters({ filters, members, onChange, onClear }: TasksFilte ))}
- {/* Bloqueadas toggle */} - {/* Limpar — reserva espaço fixo para evitar layout shift */}
{hasActiveFilters && ( -
- - setFilters(prev => ({ ...prev, ...next }))} - onClear={() => setFilters(EMPTY_FILTERS)} - /> - - {/* Content */} - - {tasks.length === 0 ? ( -
- -

Nenhuma demanda cadastrada ainda.

- -
- ) : ( -
- {([...groupedTasks.entries()] as [StepType, Task[]][]) - .filter(([stepType]) => - filters.selectedSteps.length === 0 || filters.selectedSteps.includes(stepType) - ) - .map(([stepType, stepTasks]) => ( - - ))} - {filteredTasks.length === 0 && hasActiveFilters && ( -
-

Nenhuma demanda encontrada com os filtros atuais.

- -
- )} -
- )} -
-
- ); -} diff --git a/src/views/tasks/index.ts b/src/views/tasks/index.ts deleted file mode 100644 index ada9ad0..0000000 --- a/src/views/tasks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './TasksView'; From e8844142391d81d8febc9f15895dfb21823bbd42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Fri, 24 Apr 2026 14:08:23 -0300 Subject: [PATCH 08/11] feat: :sparkles: Atualizado controle e redirect de rotas --- docs/architecture.md | 8 ++++---- docs/decisions.md | 6 +++--- src/App.tsx | 14 ++++++++++++-- src/hooks/useAppOrchestrator.ts | 7 +++++++ 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 302f4cd..4076e1a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,7 +39,7 @@ src/ │ ├── useTaskStore.ts # Estado local para update otimista (applyOptimisticUpdate/clearOptimistic) │ └── useMemberStore.ts # Stub de compatibilidade (sem fetch — migrado para useMembersQuery) ├── hooks/ -│ ├── useAppOrchestrator.ts # Agrega toda a lógica de orquestração do App (cliente, views, notificações, task actions) +│ ├── useAppOrchestrator.ts # Agrega toda a lógica de orquestração do App (cliente, views, notificações, task actions); expõe cachedClient, navigateTo e navigateToClient para App.tsx coordenar redirects sem estado intermediário │ ├── useAppNavigation.ts # URL ↔ ViewType: urlToView, viewToPath, taskPath; lê clientSlug via location.pathname (não useParams) │ ├── useSupabase.ts # Mutations CRUD (createTask, updateTask, deleteTask) via TanStack Query │ ├── useTasksQuery.ts # Query hook TanStack Query para tasks @@ -76,9 +76,9 @@ AuthContext (AuthProvider) App.tsx (gates de auth + composição) ├── !session → LoginView ├── !hasClients → OnboardingView - ├── !clientSlug (rota "/") → ClientPickerLayout → ClientPickerView - │ 1 cliente: redirect automático para /:slug - │ N clientes: grid de ClientCards + ├── !effectiveClientId (sem slug, slug inválido, /clients…) e não é /profile + │ ├── cachedClient válido → useEffect: navigateTo(view, cachedClient) → redirect preservando a view (ex: /clients → /:slug/client-info); renderiza null enquanto navega + │ └── sem cache → ClientPickerLayout → ClientPickerView └── useAppOrchestrator (toda a lógica de orquestração) ├── useClientStore → selectedClientId (persist) ├── useMembersQuery → members com cache TanStack Query diff --git a/docs/decisions.md b/docs/decisions.md index f1b5c05..dd6a06d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -151,9 +151,9 @@ Registro de decisões arquiteturais significativas do projeto Run/Way. ## ADR-017: ClientPickerView como tela obrigatória de seleção de cliente **Status:** Aceito (Abr 2026) -**Decisão:** a rota `/` (sem `clientSlug`) exibe uma tela de boas-vindas (`ClientPickerView`) com cards dos clientes disponíveis, em vez de assumir `clients[0]` implicitamente; usuários com 1 cliente têm redirect transparente; `App.tsx` renderiza `ClientPickerLayout` (header + mini-sidebar recolhida) quando `!clientSlug` -**Racional:** o fallback `clients[0]` causava estado ambíguo logo após o login — o usuário nunca escolheu um cliente, mas a app operava como se tivesse, gerando bugs em funcionalidades que dependem do cliente efetivo; tornar a escolha explícita elimina a ambiguidade estruturalmente -**Consequências:** usuários com múltiplos clientes veem a tela de seleção ao acessar `/`; usuários com 1 cliente têm redirect automático sem fricção; o fallback `clients[0]` foi removido de `useAppOrchestrator`; o guard `!effectiveClientId` foi removido de `AppRouter` (esse estado é agora válido e tratado antes do AppLayout) +**Decisão:** qualquer rota sem `effectiveClientId` válido (sem slug, slug inválido, `/clients`, etc.) exibe `ClientPickerView` com cards dos clientes disponíveis — exceto `/profile`, que é genuinamente global; se houver um cliente válido em cache (`cachedClient` de `useClientStore`), o redirect ocorre automaticamente sem exibir a tela de seleção; `App.tsx` renderiza `ClientPickerLayout` quando `!effectiveClientId && !isProfileView` +**Racional:** o guard anterior (`!clientSlug`) só cobria ausência de slug, deixando rotas como `/clients` ou slugs inválidos caírem no `AppLayout` sem cliente, causando estado ambíguo; expandir o guard para `!effectiveClientId` cobre todos os casos estruturalmente; o redirect via `cachedClient` preserva a experiência de retorno sem fricção +**Consequências:** `/clients` não é mais uma rota global tratada separadamente — redireciona para o cliente em cache preservando a view (ex: `/clients` → `/:slug/client-info`) ou exibe a tela de seleção; `useAppOrchestrator` expõe `cachedClient`, `navigateTo` e `navigateToClient`; o redirect usa `useEffect` para evitar loop de re-render (`selectClient` durante render causava "Too many re-renders"); `isGlobalView` foi simplificado para `isProfileView` em `App.tsx` --- diff --git a/src/App.tsx b/src/App.tsx index 8650375..d1398d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { Toaster } from "sonner"; import { useAppOrchestrator } from "@/hooks/useAppOrchestrator"; import { AppLayout } from "@/components/AppLayout"; @@ -9,7 +10,15 @@ import { OnboardingView } from "@/views/onboarding"; export default function App() { const app = useAppOrchestrator(); - const isGlobalView = app.view === "profile" || app.view === "clients" + const isProfileView = app.view === "profile" + const needsPicker = !app.effectiveClientId && !isProfileView && !app.auth.loading + + // Quando não há cliente na URL mas há um em cache, redireciona preservando a view atual + useEffect(() => { + if (!needsPicker) return + if (!app.cachedClient) return + app.navigateTo(app.view, app.cachedClient) + }, [needsPicker, app.cachedClient, app.view, app.navigateTo]) if (app.auth.loading) { return ( @@ -33,7 +42,8 @@ export default function App() { ); } - if (!app.clientSlug && !isGlobalView) { + if (needsPicker) { + if (app.cachedClient) return null return ( <> c.id === storedClientId) ?? null) + : null + return { // auth auth, @@ -166,6 +170,7 @@ export function useAppOrchestrator() { effectiveClientId, effectiveClient, selectedClient: effectiveClient, + cachedClient, selectClient, transitionTarget, onTransitionComplete, @@ -174,6 +179,8 @@ export function useAppOrchestrator() { view: nav.view, urlTaskId: nav.urlTaskId, handleViewChange, + navigateTo: nav.navigateTo, + navigateToClient: nav.navigateToClient, clientSlug: nav.currentSlug, clientToSlug, From 1eda11fcc25db96ff8762b00b4e4353ac8728bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Fri, 24 Apr 2026 14:27:15 -0300 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20:sparkles:=20Adicionada=20pagina?= =?UTF-8?q?=C3=A7=C3=A3o=20nas=20notifica=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/hooks/notifications.md | 27 +- src/App.tsx | 3 + src/components/AppHeader.tsx | 6 + src/components/AppLayout.tsx | 5 + src/components/NotificationBell.tsx | 545 ++++++++++++++++------------ src/contexts/LayoutContext.ts | 3 + src/hooks/useNotifications.ts | 59 ++- src/lib/notifications.ts | 15 +- 8 files changed, 420 insertions(+), 243 deletions(-) diff --git a/docs/hooks/notifications.md b/docs/hooks/notifications.md index e25f490..b8373a6 100644 --- a/docs/hooks/notifications.md +++ b/docs/hooks/notifications.md @@ -23,7 +23,10 @@ useNotifications(userId?: string | null, clientIds?: string[]) unreadCount: number loading: boolean error: string | null + hasMore: boolean + loadingOlder: boolean reload: () => Promise + loadOlder: () => Promise markAsRead: (id: string) => Promise markAllAsRead: () => Promise createNotification: (title, message, type?, metadata?) => Promise @@ -33,7 +36,10 @@ useNotifications(userId?: string | null, clientIds?: string[]) ## Comportamento ### Fetch inicial -Chama `fetchNotifications(userId, clientIds)` na montagem. Evita refetch desnecessário com `loadedRef`. +Busca apenas os **últimos 7 dias** de notificações (`created_at >= hoje - 7d`). Evita refetch desnecessário com `loadedRef`. + +### Paginação para notificações antigas +`loadOlder` busca a página anterior ao cursor (`nextBeforeRef`), trazendo até 20 itens por chamada. O cursor avança para o `created_at` da notificação mais antiga retornada. Quando o retorno vier com menos de 20 itens ou vazio, `hasMore` passa a `false`. Notificações antigas ficam em `olderNotificationsRef` e sobrevivem a reloads de polling. ### Realtime Escuta `INSERT` na tabela `notifications` via Supabase channel. Aceita a notificação se: @@ -58,14 +64,18 @@ const { notifications, unreadCount, ... } = useNotifications(member?.id, allClie # fetchNotifications ```ts -fetchNotifications(userId: string, clientIds?: string[]): Promise +fetchNotifications( + userId: string, + clientIds?: string[], + options?: { after?: string; before?: string; limit?: number } +): Promise ``` -Busca as últimas 50 notificações relevantes para o usuário: +Busca notificações relevantes para o usuário: - `user_id = userId` — notificações pessoais - `user_id IS NULL AND client_id IN (clientIds)` — broadcasts dos clientes do usuário -Ordena por `created_at DESC`. +Aceita filtros de data (`after` / `before` em ISO 8601) e `limit` (padrão 50). Ordena por `created_at DESC`. --- @@ -93,13 +103,20 @@ Componente de sino com dropdown. Recebe notificações já carregadas via props | `onMarkAllAsRead` | `() => void` | Marca todas como lidas | | `onNotificationClick` | `(n) => void` | Ação ao clicar — navega dentro do cliente atual, não troca de cliente | | `reload` | `() => void` | Callback de reload (usado pelo polling) | +| `onLoadOlder` | `() => void` | Carrega a próxima página de notificações antigas | +| `hasMore` | `boolean` | Se ainda há notificações mais antigas a buscar | +| `loadingOlder` | `boolean` | Estado de loading do `onLoadOlder` | | `selectedClientId` | `string \| null` | Cliente ativo — usado apenas para filtrar a aba "Cliente atual" | ## Tabs -- **Todas** — todas as notificações do usuário +- **Todas** — notificações dos últimos 7 dias + antigas carregadas via "Ver anteriores" - **Cliente atual** — filtra por `client_id === selectedClientId` +## Paginação + +O botão **"Ver anteriores"** aparece no rodapé da aba "Todas" enquanto `hasMore = true`. Cada clique chama `onLoadOlder`, que busca a próxima página (até 20 itens) anterior ao cursor atual. O botão some quando não há mais páginas disponíveis. + --- # createNotificationForAll diff --git a/src/App.tsx b/src/App.tsx index d1398d5..a66931e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -84,6 +84,9 @@ export default function App() { onMarkAllNotificationsAsRead={app.notifications.markAllAsRead} onNotificationClick={app.handleNotificationClick} onReloadNotifications={app.notifications.reload} + onLoadOlderNotifications={app.notifications.loadOlder} + hasMoreNotifications={app.notifications.hasMore} + loadingOlderNotifications={app.notifications.loadingOlder} // sidebar sidebarOpen={app.sidebar.sidebarOpen} mobileSidebarOpen={app.sidebar.mobileSidebarOpen} diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 7f66aed..2eb12f1 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -11,6 +11,9 @@ export function AppHeader() { onMarkAllNotificationsAsRead, onNotificationClick, onReloadNotifications, + onLoadOlderNotifications, + hasMoreNotifications, + loadingOlderNotifications, selectedClientId, darkMode, onToggleDark, @@ -52,6 +55,9 @@ export function AppHeader() { onMarkAllAsRead={onMarkAllNotificationsAsRead} onNotificationClick={onNotificationClick} reload={onReloadNotifications} + onLoadOlder={onLoadOlderNotifications} + hasMore={hasMoreNotifications} + loadingOlder={loadingOlderNotifications} selectedClientId={selectedClientId ?? undefined} />
diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index bfa40b8..27ee9d3 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -22,6 +22,9 @@ interface AppLayoutProps { onMarkAllNotificationsAsRead: () => Promise onNotificationClick: (notification: Notification) => void onReloadNotifications: () => Promise + onLoadOlderNotifications: () => Promise + hasMoreNotifications: boolean + loadingOlderNotifications: boolean sidebarOpen: boolean mobileSidebarOpen: boolean onToggleSidebar: () => void @@ -55,6 +58,7 @@ export function AppLayout(props: AppLayoutProps) { notifications, unreadCount, selectedClientId, onToggleMobileSidebar, onMarkNotificationAsRead, onMarkAllNotificationsAsRead, onNotificationClick, onReloadNotifications, + onLoadOlderNotifications, hasMoreNotifications, loadingOlderNotifications, sidebarOpen, mobileSidebarOpen, onToggleSidebar, onCloseMobileSidebar, view, onViewChange, hasClients, role, userEmail, userAvatarUrl, onSignOut, @@ -73,6 +77,7 @@ export function AppLayout(props: AppLayoutProps) { onToggleMobileSidebar, onMarkNotificationAsRead, onMarkAllNotificationsAsRead, onNotificationClick, onReloadNotifications, + onLoadOlderNotifications, hasMoreNotifications, loadingOlderNotifications, }, sidebar: { sidebarOpen, mobileSidebarOpen, onToggleSidebar, onCloseMobileSidebar, diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx index 47c0c7b..ed9de56 100644 --- a/src/components/NotificationBell.tsx +++ b/src/components/NotificationBell.tsx @@ -1,7 +1,19 @@ -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' +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, + ChevronDown, + Loader2, +} from "lucide-react"; import { DropdownMenu, DropdownMenuTrigger, @@ -11,88 +23,117 @@ import { Tabs, TabsList, TabsTrigger, -} from '@/components/ui' -import { useNotificationPolling } from '@/hooks/useNotificationPolling' -import type { Notification } from '@/types/notification' +} from "@/components/ui"; +import { useNotificationPolling } from "@/hooks/useNotificationPolling"; +import type { Notification } from "@/types/notification"; interface NotificationBellProps { - notifications: Notification[] - unreadCount: number - loading?: boolean - onMarkAsRead: (notificationId: string) => void - onMarkAllAsRead: () => void - onNotificationClick: (notification: Notification) => void - reload?: () => void - selectedClientId?: string | null + notifications: Notification[]; + unreadCount: number; + loading?: boolean; + + onMarkAsRead: (notificationId: string) => void; + onMarkAllAsRead: () => void; + onNotificationClick: (notification: Notification) => void; + reload?: () => void; + onLoadOlder?: () => void; + hasMore?: boolean; + loadingOlder?: boolean; + selectedClientId?: string | null; +} + +function formatTime(dateString: string): string { + try { + const date = new Date(dateString); + return date.toLocaleTimeString("pt-BR", { + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return ""; + } } function formatDate(dateString: string): string { try { - const date = new Date(dateString) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffMins = Math.floor(diffMs / 60000) - const diffHours = Math.floor(diffMins / 60) - const diffDays = Math.floor(diffHours / 24) - - if (diffMins < 1) return 'agora' - if (diffMins < 60) return `${diffMins}m` - if (diffHours < 24) return `${diffHours}h` - if (diffDays < 7) return `${diffDays}d` - - return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }) + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return "agora"; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + + return date.toLocaleDateString("pt-BR", { + day: "2-digit", + month: "2-digit", + }); } catch { - return '' + return ""; } } function getDateGroup(dateString: string): { label: string; key: string } { - const date = new Date(dateString) - date.setHours(0, 0, 0, 0) - const now = new Date() - now.setHours(0, 0, 0, 0) - const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000) - - if (diffDays === 0) return { label: 'Hoje', key: 'today' } - if (diffDays === 1) return { label: 'Ontem', key: 'yesterday' } - if (diffDays < 7) return { label: 'Últimos 7 dias', key: 'week' } - return { label: 'Mais antigas', key: 'older' } + const date = new Date(dateString); + date.setHours(0, 0, 0, 0); + const now = new Date(); + now.setHours(0, 0, 0, 0); + const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000); + + if (diffDays === 0) return { label: "Hoje", key: "today" }; + if (diffDays === 1) return { label: "Ontem", key: "yesterday" }; + if (diffDays < 7) return { label: "Últimos 7 dias", key: "week" }; + return { label: "Mais antigas", key: "older" }; } -function groupNotificationsByDate(notifications: Notification[]): Map { - const groups = new Map() +function groupNotificationsByDate( + notifications: Notification[], +): Map { + const groups = new Map(); for (const n of notifications) { - const { key } = getDateGroup(n.created_at) - if (!groups.has(key)) groups.set(key, []) - groups.get(key)!.push(n) + const { key } = getDateGroup(n.created_at); + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(n); } - const order = ['today', 'yesterday', 'week', 'older'] - const sorted = new Map() + const order = ["today", "yesterday", "week", "older"]; + const sorted = new Map(); for (const key of order) { - if (groups.has(key)) sorted.set(key, groups.get(key)!) + if (groups.has(key)) sorted.set(key, groups.get(key)!); } - return sorted + return sorted; } function getNotificationTypeIcon(type: string): React.ReactNode { switch (type) { - case 'step_assigned': return - case 'step_unassigned': return - case 'role_changed': return - case 'client_access_granted': return - case 'client_access_revoked': return - case 'admin_broadcast': return - case 'manual': return - default: return + case "step_assigned": + return ; + case "step_unassigned": + return ; + case "role_changed": + return ; + case "client_access_granted": + return ; + case "client_access_revoked": + return ; + case "admin_broadcast": + return ; + case "manual": + return ; + default: + return ; } } function playNotificationSound() { - const audio = new Audio('/notification.mp3') - audio.volume = 0.3 - audio.play().catch(() => {}) + const audio = new Audio("/notification.mp3"); + audio.volume = 0.3; + audio.play().catch(() => {}); } function NotificationItem({ @@ -100,51 +141,55 @@ function NotificationItem({ onMarkAsRead, onClick, }: { - notification: Notification - onMarkAsRead: (id: string) => void - onClick: (n: Notification) => void + notification: Notification; + onMarkAsRead: (id: string) => void; + onClick: (n: Notification) => void; }) { - const isPersonal = !!notification.user_id + const isPersonal = !!notification.user_id; const handleClick = () => { - if (!notification.read) onMarkAsRead(notification.id) - onClick(notification) - } + if (!notification.read) onMarkAsRead(notification.id); + onClick(notification); + }; return ( { - e.preventDefault() - handleClick() + e.preventDefault(); + handleClick(); }} - className={`group flex items-start gap-3 px-3 py-3 cursor-pointer rounded-md mx-1 border border-black/5 dark:border-white/10 transition-colors ${ - notification.read - ? 'bg-background hover:bg-muted/40' - : 'bg-muted/30 hover:bg-muted/50' -}`} + className={`group relative flex items-start gap-3 px-3 py-3 cursor-pointer rounded-md m-1 border border-black/5 dark:border-white/10 transition-colors ${ + notification.read + ? "bg-background hover:bg-muted/40" + : "bg-muted/30 hover:bg-muted/50" + }`} >
{getNotificationTypeIcon(notification.type)}
-
- - {notification.title} - - - {formatDate(notification.created_at)} +
+ {formatDate(notification.created_at)} + + {formatTime(notification.created_at)}
- {notification.message} + + {notification.message} +
- - {isPersonal ? 'Para você' : 'Para todos do cliente'} + + {isPersonal ? "Para você" : "Para todos do cliente"}
@@ -152,16 +197,16 @@ function NotificationItem({ )} - ) + ); } const GROUP_LABELS: Record = { - today: 'Hoje', - yesterday: 'Ontem', - week: 'Últimos 7 dias', - older: 'Mais antigas', -} -const GROUP_ORDER = ['today', 'yesterday', 'week', 'older'] + today: "Hoje", + yesterday: "Ontem", + week: "Últimos 7 dias", + older: "Mais antigas", +}; +const GROUP_ORDER = ["today", "yesterday", "week", "older"]; export const NotificationBell = memo( function NotificationBell({ @@ -171,164 +216,202 @@ export const NotificationBell = memo( onMarkAllAsRead, onNotificationClick, reload, + onLoadOlder, + hasMore = false, + loadingOlder = false, selectedClientId, }: NotificationBellProps) { - const [open, setOpen] = useState(false) - const [activeTab, setActiveTab] = useState<'all' | 'current'>('all') - const prevIdsRef = useRef>(new Set()) - const initialOpenDone = useRef(false) - const hasInitiallyLoaded = useRef(false) - - const handleReload = useCallback(() => reload?.(), [reload]) - - useNotificationPolling({ enabled: !!reload, interval: 15000, fn: handleReload }) - - // Reload na primeira abertura - useEffect(() => { - if (open && !initialOpenDone.current && reload) { - initialOpenDone.current = true - reload() - } - }, [open, reload]) - - // Detecta novas notificações — toca som (toast já está no useNotifications via realtime) - useEffect(() => { - if (!hasInitiallyLoaded.current) { - hasInitiallyLoaded.current = true - prevIdsRef.current = new Set(notifications.map((n) => n.id)) - return - } - - const newIds = new Set() - let hasNew = false - - for (const n of notifications) { - newIds.add(n.id) - if (!prevIdsRef.current.has(n.id)) { - hasNew = true + const [open, setOpen] = useState(false); + const [activeTab, setActiveTab] = useState<"all" | "current">("all"); + const prevIdsRef = useRef>(new Set()); + const initialOpenDone = useRef(false); + const hasInitiallyLoaded = useRef(false); + + const handleReload = useCallback(() => reload?.(), [reload]); + + useNotificationPolling({ + enabled: !!reload, + interval: 15000, + fn: handleReload, + }); + + // Reload na primeira abertura + useEffect(() => { + if (open && !initialOpenDone.current && reload) { + initialOpenDone.current = true; + reload(); + } + }, [open, reload]); + + // Detecta novas notificações — toca som (toast já está no useNotifications via realtime) + useEffect(() => { + if (!hasInitiallyLoaded.current) { + hasInitiallyLoaded.current = true; + prevIdsRef.current = new Set(notifications.map((n) => n.id)); + return; + } + + const newIds = new Set(); + let hasNew = false; + + for (const n of notifications) { + newIds.add(n.id); + if (!prevIdsRef.current.has(n.id)) { + hasNew = true; + } } - } - if (hasNew) playNotificationSound() + if (hasNew) playNotificationSound(); - prevIdsRef.current = newIds - }, [notifications]) + prevIdsRef.current = newIds; + }, [notifications]); - const filteredNotifications = useMemo(() => { - if (activeTab === 'current' && selectedClientId) { - return notifications.filter((n) => n.client_id === selectedClientId) - } - return notifications - }, [notifications, activeTab, selectedClientId]) + const filteredNotifications = useMemo(() => { + if (activeTab === "current" && selectedClientId) { + return notifications.filter((n) => n.client_id === selectedClientId); + } + return notifications; + }, [notifications, activeTab, selectedClientId]); - const groupedNotifications = useMemo( - () => groupNotificationsByDate(filteredNotifications), - [filteredNotifications] - ) + const groupedNotifications = useMemo( + () => groupNotificationsByDate(filteredNotifications), + [filteredNotifications], + ); - const unreadInTab = filteredNotifications.filter((n) => !n.read).length + const unreadInTab = filteredNotifications.filter((n) => !n.read).length; - return ( - - - - - - - {/* Header */} -
-
- Notificações + return ( + + +
- - {unreadInTab > 0 && ( - - )} -
+ + + + + {/* Header */} +
+
+ Notificações + {unreadCount > 0 && ( + + {unreadCount} + + )} +
- {/* Tabs */} -
- setActiveTab(v as 'all' | 'current')}> - - 0 && ( +
+ + Marcar como lidas + + )} +
- + {/* Tabs */} +
+ setActiveTab(v as "all" | "current")} + > + + + Todas + + + Cliente atual + + + +
- {/* Lista */} -
- {filteredNotifications.length === 0 ? ( -
- - Nenhuma notificação -
- ) : ( -
- {GROUP_ORDER.map((groupKey) => { - const items = groupedNotifications.get(groupKey) - if (!items?.length) return null - - return ( -
-
- {GROUP_LABELS[groupKey]} + + + {/* Lista */} +
+ {filteredNotifications.length === 0 ? ( +
+ + Nenhuma notificação +
+ ) : ( +
+ {GROUP_ORDER.map((groupKey) => { + const items = groupedNotifications.get(groupKey); + if (!items?.length) return null; + + return ( +
+
+ {GROUP_LABELS[groupKey]} +
+
+ {items.map((notification) => ( + + ))} +
-
- {items.map((notification) => ( - - ))} + ); + })} + + {/* Botão "Ver anteriores" — só aparece na aba "Todas" */} + {activeTab === "all" && + (hasMore || loadingOlder) && + onLoadOlder && ( +
+
-
- ) - })} -
- )} -
- - - ) -}, + )} +
+ )} +
+ + + ); + }, (prev, next) => prev.unreadCount === next.unreadCount && prev.notifications.length === next.notifications.length && - prev.selectedClientId === next.selectedClientId -) + prev.hasMore === next.hasMore && + prev.loadingOlder === next.loadingOlder && + prev.selectedClientId === next.selectedClientId, +); diff --git a/src/contexts/LayoutContext.ts b/src/contexts/LayoutContext.ts index b1bfc56..b3c169b 100644 --- a/src/contexts/LayoutContext.ts +++ b/src/contexts/LayoutContext.ts @@ -17,6 +17,9 @@ interface HeaderCtx { onMarkAllNotificationsAsRead: () => Promise onNotificationClick: (notification: Notification) => void onReloadNotifications: () => Promise + onLoadOlderNotifications: () => Promise + hasMoreNotifications: boolean + loadingOlderNotifications: boolean } interface SidebarCtx { diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index bbcc624..ff54354 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -9,12 +9,29 @@ import { import type { Notification, DbNotificationRow } from '@/types/notification' import { toast } from 'sonner' +const RECENT_DAYS = 7 +const OLDER_PAGE_SIZE = 20 + +function getRecentAfterIso() { + const d = new Date() + d.setDate(d.getDate() - RECENT_DAYS) + d.setHours(0, 0, 0, 0) + return d.toISOString() +} + export function useNotifications(userId?: string | null, clientIds?: string[]) { const [notifications, setNotifications] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [hasMore, setHasMore] = useState(true) + const [loadingOlder, setLoadingOlder] = useState(false) + const loadedRef = useRef(false) const channelRef = useRef | null>(null) + // Notificações antigas carregadas via "Ver anteriores" (persistem entre reloads) + const olderNotificationsRef = useRef([]) + // Limite de data para o próximo fetch de antigas (cursor) + const nextBeforeRef = useRef(null) const unreadCount = notifications.filter((n) => !n.read).length @@ -25,8 +42,13 @@ export function useNotifications(userId?: string | null, clientIds?: string[]) { setError(null) try { - const data = await fetchNotifications(userId, clientIds) - setNotifications(data) + const after = getRecentAfterIso() + // Inicializa o cursor na primeira carga + if (nextBeforeRef.current === null) { + nextBeforeRef.current = after + } + const data = await fetchNotifications(userId, clientIds, { after }) + setNotifications([...data, ...olderNotificationsRef.current]) loadedRef.current = true } catch (e) { setError(e instanceof Error ? e.message : 'Erro ao buscar') @@ -35,6 +57,34 @@ export function useNotifications(userId?: string | null, clientIds?: string[]) { } }, [userId, clientIds?.join(',')]) // eslint-disable-line react-hooks/exhaustive-deps + const loadOlder = useCallback(async () => { + if (!userId || !hasMore || loadingOlder) return + const before = nextBeforeRef.current + if (!before) return + + setLoadingOlder(true) + try { + const data = await fetchNotifications(userId, clientIds, { + before, + limit: OLDER_PAGE_SIZE, + }) + + if (data.length === 0) { + setHasMore(false) + } else { + if (data.length < OLDER_PAGE_SIZE) setHasMore(false) + // Cursor avança para a data da notificação mais antiga retornada + nextBeforeRef.current = data[data.length - 1].created_at + olderNotificationsRef.current = [...olderNotificationsRef.current, ...data] + setNotifications((prev) => [...prev, ...data]) + } + } catch { + // falha silenciosa — botão permanece visível para tentar novamente + } finally { + setLoadingOlder(false) + } + }, [userId, clientIds?.join(','), hasMore, loadingOlder]) // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { if (!userId) { setLoading(false) @@ -53,9 +103,7 @@ export function useNotifications(userId?: string | null, clientIds?: string[]) { const handleInsert = (payload: { new: DbNotificationRow }) => { const newNotif = payload.new - // Notificação direta para este usuário — sempre aceita const isForUser = newNotif.user_id === userId - // Broadcast para cliente — aceita se for de qualquer cliente que o usuário acessa const isForClient = newNotif.user_id === null && newNotif.client_id !== null && @@ -156,7 +204,10 @@ export function useNotifications(userId?: string | null, clientIds?: string[]) { unreadCount, loading, error, + hasMore, + loadingOlder, reload: load, + loadOlder, markAsRead, markAllAsRead, createNotification, diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 06695af..d80b582 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -7,7 +7,11 @@ import { import { throttleAsync } from './throttle' import type { DbNotificationRow, Notification } from '@/types/notification' -export async function fetchNotifications(userId: string, clientIds?: string[]) { +export async function fetchNotifications( + userId: string, + clientIds?: string[], + options?: { after?: string; before?: string; limit?: number } +) { // Notificações diretas ao usuário OU broadcasts de qualquer cliente que ele acessa let orFilter = `user_id.eq.${userId}` @@ -18,12 +22,17 @@ export async function fetchNotifications(userId: string, clientIds?: string[]) { orFilter = `user_id.eq.${userId},${clientFilter}` } - const { data, error } = await supabase + let query = supabase .from('notifications') .select('*') .or(orFilter) .order('created_at', { ascending: false }) - .limit(50) + .limit(options?.limit ?? 50) + + if (options?.after) query = query.gte('created_at', options.after) + if (options?.before) query = query.lt('created_at', options.before) + + const { data, error } = await query if (error) { console.error('fetchNotifications error:', error) From f699063e215980ab9b3cfc415698f08412c24a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Fri, 24 Apr 2026 15:03:24 -0300 Subject: [PATCH 10/11] feat: :sparkles: atualizado path dos hooks --- src/App.tsx | 2 +- src/components/NotificationBell.tsx | 2 +- src/components/TaskModal.tsx | 6 +++--- src/contexts/AuthContext.tsx | 2 +- src/hooks/{ => app}/useAppNavigation.ts | 0 src/hooks/{ => app}/useAppOrchestrator.ts | 20 +++++++++---------- src/hooks/{ => app}/useAppSidebar.ts | 0 src/hooks/{ => app}/useAppTheme.ts | 0 src/hooks/{ => auth}/useAuth.ts | 0 src/hooks/{ => calendar}/useHolidays.ts | 0 .../{ => clients}/useClientTransition.ts | 0 src/hooks/{ => clients}/useClients.ts | 0 src/hooks/{ => infra}/useSupabase.ts | 2 +- src/hooks/{ => infra}/useThrottledMutation.ts | 0 src/hooks/{ => members}/useMembersQuery.ts | 0 .../useNotificationPolling.ts | 0 .../{ => notifications}/useNotifications.ts | 0 src/hooks/{ => tasks}/useTaskActions.ts | 0 src/hooks/{ => tasks}/useTaskQuickActions.ts | 2 +- src/hooks/{ => tasks}/useTasksQuery.ts | 0 src/hooks/{ => ui}/useFormState.ts | 0 src/hooks/{ => ui}/usePhaseDrag.ts | 0 src/lib/accessControl.ts | 2 +- src/lib/adminApi.ts | 2 +- src/lib/queries.ts | 2 +- src/store/useAdminStore.ts | 2 +- src/types/member.ts | 2 +- src/types/props.ts | 2 +- src/views/MembersView/MembersView.tsx | 4 ++-- .../MembersView/components/MemberCard.tsx | 2 +- src/views/admin/components/AuditLogsPanel.tsx | 2 +- src/views/admin/components/ClientsPanel.tsx | 2 +- .../NotificationsPanel/NotificationForm.tsx | 2 +- .../NotificationsPanel/NotificationsPanel.tsx | 2 +- src/views/admin/components/UsersPanel.tsx | 2 +- src/views/calendar/hooks/useCalendarDrag.ts | 2 +- src/views/list/ListView.tsx | 8 ++++---- src/views/list/components/DemandRow.tsx | 2 +- src/views/list/components/MonthGroup.tsx | 2 +- src/views/list/hooks/useListFilters.ts | 2 +- src/views/planning/PlanningView.tsx | 8 ++++---- src/views/planning/components/StepGroup.tsx | 2 +- src/views/planning/components/TaskRow.tsx | 2 +- .../planning/components/TasksFilters.tsx | 2 +- .../profile/components/AccountSection.tsx | 2 +- src/views/profile/hooks/useProfile.ts | 2 +- .../reports/components/AlertsSection.tsx | 2 +- src/views/reports/components/DemandsTable.tsx | 2 +- .../subviews/ReportsMembrosSubview.tsx | 6 +++--- src/views/reports/useReportsData.ts | 8 ++++---- src/views/reports/utils.ts | 2 +- src/views/timeline/TimelineView.tsx | 2 +- src/views/user/hooks/useUserClients.ts | 2 +- 53 files changed, 61 insertions(+), 61 deletions(-) rename src/hooks/{ => app}/useAppNavigation.ts (100%) rename src/hooks/{ => app}/useAppOrchestrator.ts (90%) rename src/hooks/{ => app}/useAppSidebar.ts (100%) rename src/hooks/{ => app}/useAppTheme.ts (100%) rename src/hooks/{ => auth}/useAuth.ts (100%) rename src/hooks/{ => calendar}/useHolidays.ts (100%) rename src/hooks/{ => clients}/useClientTransition.ts (100%) rename src/hooks/{ => clients}/useClients.ts (100%) rename src/hooks/{ => infra}/useSupabase.ts (99%) rename src/hooks/{ => infra}/useThrottledMutation.ts (100%) rename src/hooks/{ => members}/useMembersQuery.ts (100%) rename src/hooks/{ => notifications}/useNotificationPolling.ts (100%) rename src/hooks/{ => notifications}/useNotifications.ts (100%) rename src/hooks/{ => tasks}/useTaskActions.ts (100%) rename src/hooks/{ => tasks}/useTaskQuickActions.ts (95%) rename src/hooks/{ => tasks}/useTasksQuery.ts (100%) rename src/hooks/{ => ui}/useFormState.ts (100%) rename src/hooks/{ => ui}/usePhaseDrag.ts (100%) diff --git a/src/App.tsx b/src/App.tsx index a66931e..be24646 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { Toaster } from "sonner"; -import { useAppOrchestrator } from "@/hooks/useAppOrchestrator"; +import { useAppOrchestrator } from "@/hooks/app/useAppOrchestrator"; import { AppLayout } from "@/components/AppLayout"; import { ClientPickerLayout } from "@/components/ClientPickerLayout"; import { AppModals } from "@/components/AppModals"; diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx index ed9de56..4ac6b06 100644 --- a/src/components/NotificationBell.tsx +++ b/src/components/NotificationBell.tsx @@ -24,7 +24,7 @@ import { TabsList, TabsTrigger, } from "@/components/ui"; -import { useNotificationPolling } from "@/hooks/useNotificationPolling"; +import { useNotificationPolling } from "@/hooks/notifications/useNotificationPolling"; import type { Notification } from "@/types/notification"; interface NotificationBellProps { diff --git a/src/components/TaskModal.tsx b/src/components/TaskModal.tsx index 223fc0c..957e30b 100644 --- a/src/components/TaskModal.tsx +++ b/src/components/TaskModal.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { useMembersQuery } from '@/hooks/useMembersQuery'; -import { useClients } from '@/hooks/useClients'; +import { useMembersQuery } from '@/hooks/members/useMembersQuery'; +import { useClients } from '@/hooks/clients/useClients'; import { Input, Label, Button, ConfirmModal } from './ui'; import { Save, ExternalLink, Trash2, Users, AlertCircle, CheckCircle2 } from 'lucide-react'; import { @@ -12,7 +12,7 @@ import { type TaskStatus, } from '../lib/steps'; import type { TaskModalProps } from '../types/props'; -import { useFormState } from '../hooks/useFormState'; +import { useFormState } from '@/hooks/ui/useFormState'; import { isWeekendOrHoliday, getHolidayName, nextNonHolidayBusinessDay } from '../utils/holidayUtils'; const TaskModal: React.FC = ({ task, members: propMembers, onClose, onSave, onDelete, holidays }) => { diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 7700347..0316a45 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,7 +1,7 @@ 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 type { Member } from '@/hooks/infra/useSupabase' import { toSafeUiErrorMessage } from '@/lib/errorSanitizer' import { DbMemberRowSchema, DbClientRowSchema, DbUserClientRowSchema } from '@/lib/validators' import { getSafeRedirectUrl } from '@/lib/securityRedirect' diff --git a/src/hooks/useAppNavigation.ts b/src/hooks/app/useAppNavigation.ts similarity index 100% rename from src/hooks/useAppNavigation.ts rename to src/hooks/app/useAppNavigation.ts diff --git a/src/hooks/useAppOrchestrator.ts b/src/hooks/app/useAppOrchestrator.ts similarity index 90% rename from src/hooks/useAppOrchestrator.ts rename to src/hooks/app/useAppOrchestrator.ts index 9ce1c93..b6851e4 100644 --- a/src/hooks/useAppOrchestrator.ts +++ b/src/hooks/app/useAppOrchestrator.ts @@ -2,16 +2,16 @@ import { useEffect, useCallback } from "react"; import { toast } from "sonner"; import { useUIStore } from "@/store/useUIStore"; import { useAuthContext } from "@/contexts/AuthContext"; -import { useSupabase } from "@/hooks/useSupabase"; -import { useHolidays } from "@/hooks/useHolidays"; -import { useNotifications } from "@/hooks/useNotifications"; -import { useAppTheme } from "@/hooks/useAppTheme"; -import { useAppSidebar } from "@/hooks/useAppSidebar"; -import { useTaskActions } from "@/hooks/useTaskActions"; -import { useAppNavigation } from "@/hooks/useAppNavigation"; -import { useClientTransition } from "@/hooks/useClientTransition"; -import { useMembersQuery } from "@/hooks/useMembersQuery"; -import { useTasksQuery } from "@/hooks/useTasksQuery"; +import { useSupabase } from "@/hooks/infra/useSupabase"; +import { useHolidays } from "@/hooks/calendar/useHolidays"; +import { useNotifications } from "@/hooks/notifications/useNotifications"; +import { useAppTheme } from "@/hooks/app/useAppTheme"; +import { useAppSidebar } from "@/hooks/app/useAppSidebar"; +import { useTaskActions } from "@/hooks/tasks/useTaskActions"; +import { useAppNavigation } from "@/hooks/app/useAppNavigation"; +import { useClientTransition } from "@/hooks/clients/useClientTransition"; +import { useMembersQuery } from "@/hooks/members/useMembersQuery"; +import { useTasksQuery } from "@/hooks/tasks/useTasksQuery"; import { useClientStore } from "@/store/useClientStore"; import { resolveNotificationRoute } from "@/lib/notifications"; import { canAccessView, resolveAccessRole } from "@/lib/accessControl"; diff --git a/src/hooks/useAppSidebar.ts b/src/hooks/app/useAppSidebar.ts similarity index 100% rename from src/hooks/useAppSidebar.ts rename to src/hooks/app/useAppSidebar.ts diff --git a/src/hooks/useAppTheme.ts b/src/hooks/app/useAppTheme.ts similarity index 100% rename from src/hooks/useAppTheme.ts rename to src/hooks/app/useAppTheme.ts diff --git a/src/hooks/useAuth.ts b/src/hooks/auth/useAuth.ts similarity index 100% rename from src/hooks/useAuth.ts rename to src/hooks/auth/useAuth.ts diff --git a/src/hooks/useHolidays.ts b/src/hooks/calendar/useHolidays.ts similarity index 100% rename from src/hooks/useHolidays.ts rename to src/hooks/calendar/useHolidays.ts diff --git a/src/hooks/useClientTransition.ts b/src/hooks/clients/useClientTransition.ts similarity index 100% rename from src/hooks/useClientTransition.ts rename to src/hooks/clients/useClientTransition.ts diff --git a/src/hooks/useClients.ts b/src/hooks/clients/useClients.ts similarity index 100% rename from src/hooks/useClients.ts rename to src/hooks/clients/useClients.ts diff --git a/src/hooks/useSupabase.ts b/src/hooks/infra/useSupabase.ts similarity index 99% rename from src/hooks/useSupabase.ts rename to src/hooks/infra/useSupabase.ts index 3c8b8f3..48de44d 100644 --- a/src/hooks/useSupabase.ts +++ b/src/hooks/infra/useSupabase.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { useThrottledMutation } from '@/hooks/useThrottledMutation' +import { useThrottledMutation } from '@/hooks/infra/useThrottledMutation' import { supabase } from '@/lib/supabase' import type { Task, Step } from '@/lib/steps' import { toast } from 'sonner' diff --git a/src/hooks/useThrottledMutation.ts b/src/hooks/infra/useThrottledMutation.ts similarity index 100% rename from src/hooks/useThrottledMutation.ts rename to src/hooks/infra/useThrottledMutation.ts diff --git a/src/hooks/useMembersQuery.ts b/src/hooks/members/useMembersQuery.ts similarity index 100% rename from src/hooks/useMembersQuery.ts rename to src/hooks/members/useMembersQuery.ts diff --git a/src/hooks/useNotificationPolling.ts b/src/hooks/notifications/useNotificationPolling.ts similarity index 100% rename from src/hooks/useNotificationPolling.ts rename to src/hooks/notifications/useNotificationPolling.ts diff --git a/src/hooks/useNotifications.ts b/src/hooks/notifications/useNotifications.ts similarity index 100% rename from src/hooks/useNotifications.ts rename to src/hooks/notifications/useNotifications.ts diff --git a/src/hooks/useTaskActions.ts b/src/hooks/tasks/useTaskActions.ts similarity index 100% rename from src/hooks/useTaskActions.ts rename to src/hooks/tasks/useTaskActions.ts diff --git a/src/hooks/useTaskQuickActions.ts b/src/hooks/tasks/useTaskQuickActions.ts similarity index 95% rename from src/hooks/useTaskQuickActions.ts rename to src/hooks/tasks/useTaskQuickActions.ts index 0291084..4563ab0 100644 --- a/src/hooks/useTaskQuickActions.ts +++ b/src/hooks/tasks/useTaskQuickActions.ts @@ -2,7 +2,7 @@ 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 { useThrottledMutation } from '@/hooks/infra/useThrottledMutation' import type { Task } from '@/lib/steps' const THROTTLE_MS = 500 diff --git a/src/hooks/useTasksQuery.ts b/src/hooks/tasks/useTasksQuery.ts similarity index 100% rename from src/hooks/useTasksQuery.ts rename to src/hooks/tasks/useTasksQuery.ts diff --git a/src/hooks/useFormState.ts b/src/hooks/ui/useFormState.ts similarity index 100% rename from src/hooks/useFormState.ts rename to src/hooks/ui/useFormState.ts diff --git a/src/hooks/usePhaseDrag.ts b/src/hooks/ui/usePhaseDrag.ts similarity index 100% rename from src/hooks/usePhaseDrag.ts rename to src/hooks/ui/usePhaseDrag.ts diff --git a/src/lib/accessControl.ts b/src/lib/accessControl.ts index 45b43ef..463587d 100644 --- a/src/lib/accessControl.ts +++ b/src/lib/accessControl.ts @@ -1,4 +1,4 @@ -import type { Member } from '@/hooks/useSupabase' +import type { Member } from '@/hooks/infra/useSupabase' import type { ViewType } from '@/store/useUIStore' export type AccessRole = 'admin' | 'user' diff --git a/src/lib/adminApi.ts b/src/lib/adminApi.ts index da5ed6d..b766d70 100644 --- a/src/lib/adminApi.ts +++ b/src/lib/adminApi.ts @@ -1,6 +1,6 @@ import { supabase } from './supabase' import type { DbClientRow, DbAuditLogRow } from '@/types/db' -import type { Member } from '@/hooks/useSupabase' +import type { Member } from '@/hooks/infra/useSupabase' import type { Notification } from '@/types/notification' import type { AuditFilters } from '@/store/useAdminStore' diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 2d61a00..e92d252 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,6 +1,6 @@ import { supabase } from '@/lib/supabase' import type { Task, Step, StepType } from '@/lib/steps' -import type { Member } from '@/hooks/useSupabase' +import type { Member } from '@/hooks/infra/useSupabase' import type { DbTaskRow } from '@/types/db' import { DbTaskRowSchema } from '@/lib/validators' diff --git a/src/store/useAdminStore.ts b/src/store/useAdminStore.ts index 274900c..e12142c 100644 --- a/src/store/useAdminStore.ts +++ b/src/store/useAdminStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import type { DbClientRow, DbAuditLogRow } from '@/types/db' -import type { Member } from '@/hooks/useSupabase' +import type { Member } from '@/hooks/infra/useSupabase' import { adminFetchClients, adminFetchMembers, diff --git a/src/types/member.ts b/src/types/member.ts index ab80249..d020a1b 100644 --- a/src/types/member.ts +++ b/src/types/member.ts @@ -5,4 +5,4 @@ * re-exportamos aqui para acesso centralizado via `src/types`. */ -export type { Member } from '../hooks/useSupabase'; +export type { Member } from '@/hooks/infra/useSupabase'; diff --git a/src/types/props.ts b/src/types/props.ts index 1f0a682..fffa74c 100644 --- a/src/types/props.ts +++ b/src/types/props.ts @@ -6,7 +6,7 @@ */ import type { Task } from '../lib/steps'; -import type { Member } from '../hooks/useSupabase'; +import type { Member } from '@/hooks/infra/useSupabase'; import type { Holiday } from '../utils/holidayUtils'; import type { CalendarViewMode } from '../utils/dashboardUtils'; diff --git a/src/views/MembersView/MembersView.tsx b/src/views/MembersView/MembersView.tsx index d5dc7d8..c6b2ea7 100644 --- a/src/views/MembersView/MembersView.tsx +++ b/src/views/MembersView/MembersView.tsx @@ -1,7 +1,7 @@ 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 { useMembersQuery } from '@/hooks/members/useMembersQuery'; +import { useClients } from '@/hooks/clients/useClients'; import type { Member } from '@/types/member'; import HierarchyMemberCard from './components/HierarchyMemberCard'; import HierarchySkeleton from './components/HierarchySkeleton'; diff --git a/src/views/MembersView/components/MemberCard.tsx b/src/views/MembersView/components/MemberCard.tsx index a0e3a2b..e8ad492 100644 --- a/src/views/MembersView/components/MemberCard.tsx +++ b/src/views/MembersView/components/MemberCard.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui'; import { migrateLegacyTask } from '@/lib/steps'; import { STEP_META } from '@/lib/steps'; import type { Task, Step, LegacyTask } from '@/lib/steps'; -import type { Member } from '@/hooks/useSupabase'; +import type { Member } from '@/hooks/infra/useSupabase'; import { ArrowUpRight, CalendarDays } from 'lucide-react'; import MemberTaskItem from './MemberTaskItem'; diff --git a/src/views/admin/components/AuditLogsPanel.tsx b/src/views/admin/components/AuditLogsPanel.tsx index 32bb5d1..fce9cab 100644 --- a/src/views/admin/components/AuditLogsPanel.tsx +++ b/src/views/admin/components/AuditLogsPanel.tsx @@ -3,7 +3,7 @@ import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import type { DbAuditLogRow } from '@/types/db' import type { DbClientRow } from '@/types/db' -import type { Member } from '@/hooks/useSupabase' +import type { Member } from '@/hooks/infra/useSupabase' import type { AuditFilters } from '../hooks/useAdminData' interface AuditLogsPanelProps { diff --git a/src/views/admin/components/ClientsPanel.tsx b/src/views/admin/components/ClientsPanel.tsx index ebc0a99..601b1ce 100644 --- a/src/views/admin/components/ClientsPanel.tsx +++ b/src/views/admin/components/ClientsPanel.tsx @@ -14,7 +14,7 @@ import { } from '@/components/ui/Drawer' import { toast } from 'sonner' import type { DbClientRow } from '@/types/db' -import type { Member } from '@/hooks/useSupabase' +import type { Member } from '@/hooks/infra/useSupabase' import { Plus, Trash2, Users, Search, ChevronLeft, ChevronRight, Clock, Building } from 'lucide-react' interface ClientsPanelProps { diff --git a/src/views/admin/components/NotificationsPanel/NotificationForm.tsx b/src/views/admin/components/NotificationsPanel/NotificationForm.tsx index 6a6924d..4c91687 100644 --- a/src/views/admin/components/NotificationsPanel/NotificationForm.tsx +++ b/src/views/admin/components/NotificationsPanel/NotificationForm.tsx @@ -2,7 +2,7 @@ import { Send, Users, Building2, Globe } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkBreaks from 'remark-breaks' import { Button, Input, Label } from '@/components/ui' -import type { Member } from '@/hooks/useSupabase' +import type { Member } from '@/hooks/infra/useSupabase' import type { Client } from '@/types/db' import type { TargetType, UseNotificationsPanelReturn } from './useNotificationsPanel' diff --git a/src/views/admin/components/NotificationsPanel/NotificationsPanel.tsx b/src/views/admin/components/NotificationsPanel/NotificationsPanel.tsx index ec0c2bb..5c37b28 100644 --- a/src/views/admin/components/NotificationsPanel/NotificationsPanel.tsx +++ b/src/views/admin/components/NotificationsPanel/NotificationsPanel.tsx @@ -1,4 +1,4 @@ -import type { Member } from '@/hooks/useSupabase' +import type { Member } from '@/hooks/infra/useSupabase' import type { Client } from '@/types/db' import { useNotificationsPanel } from './useNotificationsPanel' import { NotificationForm } from './NotificationForm' diff --git a/src/views/admin/components/UsersPanel.tsx b/src/views/admin/components/UsersPanel.tsx index 54008cf..5090b4f 100644 --- a/src/views/admin/components/UsersPanel.tsx +++ b/src/views/admin/components/UsersPanel.tsx @@ -13,7 +13,7 @@ import { } from '@/components/ui/Drawer' import { toast } from 'sonner' import { ConfirmModal } from '@/components/ui/ConfirmModal' -import type { Member } from '@/hooks/useSupabase' +import type { Member } from '@/hooks/infra/useSupabase' import type { PendingAuthUser } from '../hooks/useAdminData' import type { DbClientRow } from '@/types/db' import { Plus, Building, Check, Search, UserCheck, ChevronLeft, ChevronRight, Key, Clock, Mail, Link2, UserX } from 'lucide-react' diff --git a/src/views/calendar/hooks/useCalendarDrag.ts b/src/views/calendar/hooks/useCalendarDrag.ts index 5dd8088..a8ae481 100644 --- a/src/views/calendar/hooks/useCalendarDrag.ts +++ b/src/views/calendar/hooks/useCalendarDrag.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { normaliseTask, type BarItem, type DragState, type Task, type Step } from '../../../utils/dashboardUtils'; -import { usePhaseDrag } from '@/hooks/usePhaseDrag'; +import { usePhaseDrag } from '@/hooks/ui/usePhaseDrag'; import type { Holiday } from '@/utils/holidayUtils'; export function useCalendarDrag(tasks: Task[], onUpdateTask: (task: Task) => void, holidays: Holiday[] = []) { diff --git a/src/views/list/ListView.tsx b/src/views/list/ListView.tsx index 7a20549..8cfe086 100644 --- a/src/views/list/ListView.tsx +++ b/src/views/list/ListView.tsx @@ -1,8 +1,8 @@ import { useAuthContext } from '@/contexts/AuthContext'; -import { useTasksQuery } from '@/hooks/useTasksQuery'; -import { useMembersQuery } from '@/hooks/useMembersQuery'; -import { useClients } from '@/hooks/useClients'; -import { useTaskQuickActions } from '@/hooks/useTaskQuickActions'; +import { useTasksQuery } from '@/hooks/tasks/useTasksQuery'; +import { useMembersQuery } from '@/hooks/members/useMembersQuery'; +import { useClients } from '@/hooks/clients/useClients'; +import { useTaskQuickActions } from '@/hooks/tasks/useTaskQuickActions'; import type { Task } from '@/lib/steps'; import { useListFilters } from './hooks/useListFilters'; import { FilterBar } from '@/views/planning/components/FilterBar'; diff --git a/src/views/list/components/DemandRow.tsx b/src/views/list/components/DemandRow.tsx index eebb68f..f6441dc 100644 --- a/src/views/list/components/DemandRow.tsx +++ b/src/views/list/components/DemandRow.tsx @@ -1,7 +1,7 @@ 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'; +import type { Member } from '@/hooks/infra/useSupabase'; interface DemandRowProps { task: Task; diff --git a/src/views/list/components/MonthGroup.tsx b/src/views/list/components/MonthGroup.tsx index a9ddf58..c0b2890 100644 --- a/src/views/list/components/MonthGroup.tsx +++ b/src/views/list/components/MonthGroup.tsx @@ -1,5 +1,5 @@ import type { Task } from '@/lib/steps'; -import type { Member } from '@/hooks/useSupabase'; +import type { Member } from '@/hooks/infra/useSupabase'; import type { NormalisedTask } from '../hooks/useListFilters'; import { DemandRow } from './DemandRow'; diff --git a/src/views/list/hooks/useListFilters.ts b/src/views/list/hooks/useListFilters.ts index 5e6808e..f0df6ad 100644 --- a/src/views/list/hooks/useListFilters.ts +++ b/src/views/list/hooks/useListFilters.ts @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import type { Task } from '@/lib/steps'; -import type { Member } from '@/hooks/useSupabase'; +import type { Member } from '@/hooks/infra/useSupabase'; export interface NormalisedTask { task: Task; diff --git a/src/views/planning/PlanningView.tsx b/src/views/planning/PlanningView.tsx index 49667e0..932febc 100644 --- a/src/views/planning/PlanningView.tsx +++ b/src/views/planning/PlanningView.tsx @@ -1,11 +1,11 @@ import { useEffect, useMemo, useState } from 'react'; import { useUIStore } from '@/store/useUIStore'; -import { useTasksQuery } from '@/hooks/useTasksQuery'; -import { useMembersQuery } from '@/hooks/useMembersQuery'; +import { useTasksQuery } from '@/hooks/tasks/useTasksQuery'; +import { useMembersQuery } from '@/hooks/members/useMembersQuery'; import { useQueryClient } from '@tanstack/react-query'; import { useAuthContext } from '@/contexts/AuthContext'; -import { useClients } from '@/hooks/useClients'; -import { useTaskQuickActions } from '@/hooks/useTaskQuickActions'; +import { useClients } from '@/hooks/clients/useClients'; +import { useTaskQuickActions } from '@/hooks/tasks/useTaskQuickActions'; import { CalendarView } from '@/views/calendar'; import TimelineView from '@/views/timeline'; import { ListView } from '@/views/list'; diff --git a/src/views/planning/components/StepGroup.tsx b/src/views/planning/components/StepGroup.tsx index 19727e8..33262c0 100644 --- a/src/views/planning/components/StepGroup.tsx +++ b/src/views/planning/components/StepGroup.tsx @@ -2,7 +2,7 @@ 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 type { Member } from '@/hooks/infra/useSupabase'; import { TaskRow } from './TaskRow'; const TASK_ROW_HEIGHT = 52; diff --git a/src/views/planning/components/TaskRow.tsx b/src/views/planning/components/TaskRow.tsx index 8c7ee32..bf0565a 100644 --- a/src/views/planning/components/TaskRow.tsx +++ b/src/views/planning/components/TaskRow.tsx @@ -1,7 +1,7 @@ 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'; +import type { Member } from '@/hooks/infra/useSupabase'; import { formatDueDate } from '../utils'; import { ActionMenu } from './ActionMenu'; diff --git a/src/views/planning/components/TasksFilters.tsx b/src/views/planning/components/TasksFilters.tsx index cce5028..47d2391 100644 --- a/src/views/planning/components/TasksFilters.tsx +++ b/src/views/planning/components/TasksFilters.tsx @@ -3,7 +3,7 @@ import { Search, User, Calendar, AlertCircle, ChevronDown } from 'lucide-react'; import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; import { STEP_META, STEP_TYPES_ORDER, type StepType } from '@/lib/steps'; -import type { Member } from '@/hooks/useSupabase'; +import type { Member } from '@/hooks/infra/useSupabase'; const PERIOD_TABS = [ { value: '', label: 'Todos' }, diff --git a/src/views/profile/components/AccountSection.tsx b/src/views/profile/components/AccountSection.tsx index 9c938d0..33757fb 100644 --- a/src/views/profile/components/AccountSection.tsx +++ b/src/views/profile/components/AccountSection.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { User, Mail, Shield, Calendar } from 'lucide-react' import { Button, Input, Label, Badge } from '@/components/ui' -import type { Member } from '@/hooks/useSupabase' +import type { Member } from '@/hooks/infra/useSupabase' import type { ProfileFormData } from '../hooks/useProfile' interface AccountSectionProps { diff --git a/src/views/profile/hooks/useProfile.ts b/src/views/profile/hooks/useProfile.ts index 560cead..5101799 100644 --- a/src/views/profile/hooks/useProfile.ts +++ b/src/views/profile/hooks/useProfile.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { supabase } from '@/lib/supabase' import { useAuthContext } from '@/contexts/AuthContext' import { DbUserPreferencesSchema } from '@/lib/validators' -import { useThrottledMutation } from '@/hooks/useThrottledMutation' +import { useThrottledMutation } from '@/hooks/infra/useThrottledMutation' export interface UserPreferences { id: string diff --git a/src/views/reports/components/AlertsSection.tsx b/src/views/reports/components/AlertsSection.tsx index 82c8254..93f77db 100644 --- a/src/views/reports/components/AlertsSection.tsx +++ b/src/views/reports/components/AlertsSection.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { AlertTriangle, AlertCircle } from 'lucide-react'; import { formatDateToBR } from '@/lib/utils'; -import type { Member } from '@/hooks/useSupabase'; +import type { Member } from '@/hooks/infra/useSupabase'; import type { EnrichedTask } from '../utils'; interface AlertsSectionProps { diff --git a/src/views/reports/components/DemandsTable.tsx b/src/views/reports/components/DemandsTable.tsx index eb58945..aacc2b7 100644 --- a/src/views/reports/components/DemandsTable.tsx +++ b/src/views/reports/components/DemandsTable.tsx @@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'; import { formatDateToBR } from '@/lib/utils'; import { STEP_META } from '@/lib/steps'; import type { StepType } from '@/lib/steps'; -import type { Member } from '@/hooks/useSupabase'; +import type { Member } from '@/hooks/infra/useSupabase'; import type { EnrichedTask } from '../utils'; import RiskBadge from './RiskBadge'; diff --git a/src/views/reports/subviews/ReportsMembrosSubview.tsx b/src/views/reports/subviews/ReportsMembrosSubview.tsx index 90fb33e..de4739c 100644 --- a/src/views/reports/subviews/ReportsMembrosSubview.tsx +++ b/src/views/reports/subviews/ReportsMembrosSubview.tsx @@ -1,9 +1,9 @@ import { useQueryClient } from '@tanstack/react-query'; -import { useTasksQuery } from '@/hooks/useTasksQuery'; -import { useMembersQuery } from '@/hooks/useMembersQuery'; +import { useTasksQuery } from '@/hooks/tasks/useTasksQuery'; +import { useMembersQuery } from '@/hooks/members/useMembersQuery'; import { useUIStore } from '@/store/useUIStore'; import { useAuthContext } from '@/contexts/AuthContext'; -import { useClients } from '@/hooks/useClients'; +import { useClients } from '@/hooks/clients/useClients'; import { ViewState } from '@/components/ViewState'; import { DatabaseZap, Users2 } from 'lucide-react'; import MemberCard from '@/views/MembersView/components/MemberCard'; diff --git a/src/views/reports/useReportsData.ts b/src/views/reports/useReportsData.ts index fb45d65..7e7cbc2 100644 --- a/src/views/reports/useReportsData.ts +++ b/src/views/reports/useReportsData.ts @@ -1,9 +1,9 @@ import { useMemo, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { useTasksQuery } from '@/hooks/useTasksQuery'; -import { useMembersQuery } from '@/hooks/useMembersQuery'; +import { useTasksQuery } from '@/hooks/tasks/useTasksQuery'; +import { useMembersQuery } from '@/hooks/members/useMembersQuery'; import { useAuthContext } from '@/contexts/AuthContext'; -import { useClients } from '@/hooks/useClients'; +import { useClients } from '@/hooks/clients/useClients'; import { todayStr, enrichTask, computeMemberLoad, calDaysBetween, calculatePercentile } from './utils'; import type { StepType } from '@/lib/steps'; @@ -12,7 +12,7 @@ export type TimeFilter = 'all' | '30d' | '90d' | '180d' | '365d'; export interface ReportsData { enriched: ReturnType[]; filteredEnriched: ReturnType[]; - members: import('@/hooks/useSupabase').Member[]; + members: import('@/hooks/infra/useSupabase').Member[]; total: number; active: number; bloqueadas: number; diff --git a/src/views/reports/utils.ts b/src/views/reports/utils.ts index df12064..a22b4ae 100644 --- a/src/views/reports/utils.ts +++ b/src/views/reports/utils.ts @@ -1,6 +1,6 @@ import { migrateLegacyTask, getCurrentStep } from '@/lib/steps'; import type { Task, Step, LegacyTask, StepType } from '@/lib/steps'; -import type { Member } from '@/hooks/useSupabase'; +import type { Member } from '@/hooks/infra/useSupabase'; // ─── Pure helpers ───────────────────────────────────────────────────────────── diff --git a/src/views/timeline/TimelineView.tsx b/src/views/timeline/TimelineView.tsx index 7a891da..fdc95f0 100644 --- a/src/views/timeline/TimelineView.tsx +++ b/src/views/timeline/TimelineView.tsx @@ -1,7 +1,7 @@ import React, { useRef } from 'react'; import { DAY_COL_W, STEP_META, getTaskStatusDisplay, getVisibleSteps } from '@/utils/dashboardUtils'; import type { TimelineViewProps } from '@/types/props'; -import { usePhaseDrag } from '@/hooks/usePhaseDrag'; +import { usePhaseDrag } from '@/hooks/ui/usePhaseDrag'; import { useTimelineDays } from './hooks/useTimelineDays'; import { useRowHeightSync } from './hooks/useRowHeightSync'; import { useHeaderHeightSync } from './hooks/useHeaderHeightSync'; diff --git a/src/views/user/hooks/useUserClients.ts b/src/views/user/hooks/useUserClients.ts index e07fdd9..efe655a 100644 --- a/src/views/user/hooks/useUserClients.ts +++ b/src/views/user/hooks/useUserClients.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react' import { supabase } from '@/lib/supabase' -import { useThrottledMutation } from '@/hooks/useThrottledMutation' +import { useThrottledMutation } from '@/hooks/infra/useThrottledMutation' import type { ClientOption } from '@/contexts/AuthContext' export function useUserClients() { From 2cb8580c37058bfb11d0878dc2f2ac66e10db009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Sim=C3=B5es?= Date: Tue, 12 May 2026 11:24:34 -0300 Subject: [PATCH 11/11] feat: :bug: ajuste de role --- docs/views/admin.md | 2 +- src/lib/accessControl.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/views/admin.md b/docs/views/admin.md index c8aa543..fba4d3c 100644 --- a/docs/views/admin.md +++ b/docs/views/admin.md @@ -2,7 +2,7 @@ **Ficheiro:** `src/views/admin/AdminView.tsx` **URL:** `/:clientSlug/admin` -**Acesso:** exclusivo para `member.access_role === 'admin'` — verificado no componente via `useAuthContext`; requer cliente selecionado (`requiresClient: true` em `accessControl.ts`) +**Acesso:** exclusivo para `member.access_role === 'admin'` — verificado no componente via `useAuthContext`; não requer cliente selecionado (`requiresClient: false` em `accessControl.ts`) ## Estrutura diff --git a/src/lib/accessControl.ts b/src/lib/accessControl.ts index 463587d..ec4c97d 100644 --- a/src/lib/accessControl.ts +++ b/src/lib/accessControl.ts @@ -54,7 +54,7 @@ const VIEW_RULES: Record = { 'reports-timeline': { requiresClient: true, roles: ['admin', 'user'], permission: 'view:reports' }, 'reports-membros': { requiresClient: true, roles: ['admin', 'user'], permission: 'view:reports' }, 'reports-alertas': { requiresClient: true, roles: ['admin', 'user'], permission: 'view:reports' }, - admin: { requiresClient: true, roles: ['admin'], permission: 'view:admin' }, + admin: { requiresClient: false, roles: ['admin'], permission: 'view:admin' }, tools: { requiresClient: false, roles: ['admin', 'user'], permission: 'view:tools' }, 'tools-briefing-analyzer': { requiresClient: false, roles: ['admin', 'user'], permission: 'view:tools' }, 'tools-import': { requiresClient: false, roles: ['admin', 'user'], permission: 'view:tools' },