diff --git a/backend/finboostplus_server/docker-compose.yml b/backend/finboostplus_server/docker-compose.yml index a8e91e3..d9e1587 100644 --- a/backend/finboostplus_server/docker-compose.yml +++ b/backend/finboostplus_server/docker-compose.yml @@ -1,41 +1,41 @@ services: - server: - build: - context: . - ports: - - 8080:8080 - env_file: - - .env - depends_on: - db: - condition: service_healthy - volumes: - - .:/build - environment: - - POSTGRES_URL=jdbc:postgresql://db:5432/finboostplus - - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/finboostplus - - SPRING_DATASOURCE_USERNAME=${POSTGRES_USERNAME} - - SPRING_DATASOURCE_PASSWORD=${POSTGRES_PASSWORD} - - SPRING_MAIL_USERNAME=${MENSAGERIE_EMAIL} - - SPRING_MAIL_PASSWORD=${MENSAGERIE_PASSWORD} - - SPRING_CLIENT_ID=${CLIENT_ID} - - SPRING_CLIENT_SECRET=${CLIENT_SECRET} - - CORS_ORIGINS=${CORS_ORIGINS} - db: - image: postgres - restart: always - volumes: - - db-data:/var/lib/postgresql/data - environment: - - POSTGRES_DB=finboostplus - - POSTGRES_USER=${POSTGRES_USERNAME} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - ports: - - 5432:5432 - healthcheck: - test: ['CMD', 'pg_isready', '-U', 'postgres'] - interval: 10s - timeout: 5s - retries: 5 + server: + build: + context: . + ports: + - 8080:8080 + env_file: + - .env + depends_on: + db: + condition: service_healthy + volumes: + - .:/build + environment: + - POSTGRES_URL=jdbc:postgresql://db:5432/finboostplus + - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/finboostplus + - SPRING_DATASOURCE_USERNAME=${POSTGRES_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${POSTGRES_PASSWORD} + - SPRING_MAIL_USERNAME=${MENSAGERIE_EMAIL} + - SPRING_MAIL_PASSWORD=${MENSAGERIE_PASSWORD} + - SPRING_CLIENT_ID=${CLIENT_ID} + - SPRING_CLIENT_SECRET=${CLIENT_SECRET} + - CORS_ORIGINS=${CORS_ORIGINS} + db: + image: postgres + restart: always + volumes: + - db-data:/var/lib/postgresql + environment: + - POSTGRES_DB=finboostplus + - POSTGRES_USER=${POSTGRES_USERNAME} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + ports: + - 5432:5432 + healthcheck: + test: ['CMD', 'pg_isready', '-U', 'postgres'] + interval: 10s + timeout: 5s + retries: 5 volumes: - db-data: + db-data: diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/repository/GroupRepository.java b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/GroupRepository.java index aff2ede..856dbe5 100644 --- a/backend/finboostplus_server/src/main/java/com/finboostplus/repository/GroupRepository.java +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/GroupRepository.java @@ -16,30 +16,36 @@ @Repository public interface GroupRepository extends JpaRepository { @Query(nativeQuery = true, value = """ - SELECT - G.ID AS GROUP_ID, - G.NAME, - G.DESCRIPTION, - GM.AUTH_LEVEL AS AUTHORITY, - G.ICON, - G.CREATED_AT, - COALESCE(SUM(UED.PARTIAL_VALUE), 0) AS PARTIAL_TOTAL, - COALESCE(SUM(E.VALUE), 0) AS TOTAL - FROM - GROUPS G - INNER JOIN GROUP_MEMBERS GM ON GM.GROUP_ID = G.ID - LEFT JOIN EXPENSES E ON E.GROUP_ID = G.ID - AND E.STATUS != 'PAID' -- aplica filtro no join! - LEFT JOIN USER_EXPENSE_DIVISIONS UED ON UED.EXPENSE_ID = E.ID - AND UED.USER_ID = :memberId - AND UED.STATUS != 'PAID' - GROUP BY - G.ID, - G.NAME, - G.DESCRIPTION, - GM.AUTH_LEVEL, - G.ICON, - G.CREATED_AT + SELECT + G.ID AS GROUP_ID, + G.NAME, + G.DESCRIPTION, + GM.AUTH_LEVEL AS AUTHORITY, + G.ICON, + G.CREATED_AT, + COALESCE(SUM(UED.PARTIAL_VALUE), 0) AS PARTIAL_TOTAL, + COALESCE(SUM(E.VALUE), 0) AS TOTAL + FROM + GROUPS G + INNER JOIN GROUP_MEMBERS GM + ON GM.GROUP_ID = G.ID + AND GM.USER_ID = :memberId + LEFT JOIN EXPENSES E + ON E.GROUP_ID = G.ID + AND E.STATUS != 'PAID' + LEFT JOIN USER_EXPENSE_DIVISIONS UED + ON UED.EXPENSE_ID = E.ID + AND UED.USER_ID = :memberId + AND UED.STATUS != 'PAID' + GROUP BY + G.ID, + G.NAME, + G.DESCRIPTION, + GM.AUTH_LEVEL, + G.ICON, + G.CREATED_AT + ORDER BY + G.CREATED_AT DESC """) Page listUserGroupsPaged(Long memberId, Pageable pageable); @@ -60,35 +66,31 @@ public interface GroupRepository extends JpaRepository { @Query(nativeQuery = true, value = """ SELECT - G.ID, - G.NAME, - G.DESCRIPTION, - G.ICON, - GM.AUTH_LEVEL AS AUTHORIZATION, - ( - SELECT - COALESCE(SUM(UED.PARTIAL_VALUE), 0) - FROM - USER_EXPENSE_DIVISIONS UED - WHERE - UED.USER_ID = :userId - AND UED.STATUS != 'PAID' - ) AS PARTIAL_TOTAL, - ( - SELECT - COALESCE(SUM(E.VALUE), 0) - FROM - EXPENSES E - WHERE - E.GROUP_ID = :groupId - AND E.STATUS != 'PAID' - ) AS TOTAL - FROM - GROUPS AS G - INNER JOIN GROUP_MEMBERS AS GM ON GM.GROUP_ID = G.ID - WHERE - GM.GROUP_ID = :groupId - AND GM.USER_ID = :userId + G.ID, + G.NAME, + G.DESCRIPTION, + G.ICON, + GM.AUTH_LEVEL AS AUTHORIZATION, + COALESCE(( + SELECT SUM(UED.PARTIAL_VALUE) + FROM USER_EXPENSE_DIVISIONS UED + INNER JOIN EXPENSES E ON E.ID = UED.EXPENSE_ID + WHERE UED.USER_ID = :userId + AND E.GROUP_ID = :groupId + AND UED.STATUS != 'PAID' + ), 0) AS PARTIAL_TOTAL, + COALESCE(( + SELECT SUM(E.VALUE) + FROM EXPENSES E + WHERE E.GROUP_ID = :groupId + AND E.STATUS != 'PAID' + ), 0) AS TOTAL + FROM GROUPS G + INNER JOIN GROUP_MEMBERS GM + ON GM.GROUP_ID = G.ID + AND GM.USER_ID = :userId + WHERE G.ID = :groupId """) GroupDetailsDTO getGroupDetails(Long groupId, Long userId); + } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9a3b36c..7569af6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,14 +1,8 @@ -import { useEffect } from 'react'; import ChartSwitcher from './components/ChartSwitcher'; import LatestExpenses from './components/LatestExpenses'; import SummaryCards from './components/SummaryCards'; -import { useAuthStore } from './context/stores/auth'; function App() { - const user = useAuthStore(store => store.user); - useEffect(() => { - console.log({ user }); - }, []); return (
diff --git a/frontend/src/components/ChartSwitcher/BalanceChart.jsx b/frontend/src/components/ChartSwitcher/BalanceChart.jsx index e7e8df2..5b81e7e 100644 --- a/frontend/src/components/ChartSwitcher/BalanceChart.jsx +++ b/frontend/src/components/ChartSwitcher/BalanceChart.jsx @@ -5,62 +5,86 @@ import { YAxis, CartesianGrid, Tooltip, - Legend, ResponsiveContainer, } from 'recharts'; -import userData from '../../mockData/user/user.data'; +import { useQuery } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { getMeExpensesByMonthly } from '../../services/me'; +import { formatBRL } from '../../utils/formatters'; export default function BalanceChart() { - const balanceData = userData.dashboard.chartsData.balanceData; + const { data: expensesResponse } = useQuery({ + queryKey: [REACTQUERY_KEYS.USER.DASHBOARD, 'balance'], + queryFn: getMeExpensesByMonthly, + }); + + const expensesByMonthly = + expensesResponse?.map(item => ({ + name: item.month, + despesas: item.total, + })) ?? []; return ( - {/* Grid */} - + {/* Grade */} + + + - {/* Eixos */} - - + formatBRL(value)} + tick={{ fontSize: 12, fill: 'var(--color-text)' }} + axisLine={false} + tickLine={false} + /> - {/* Tooltip customizado */} + {/* Tooltip com tema dinâmico */} formatBRL(value)} contentStyle={{ backgroundColor: 'var(--color-surface)', - borderColor: 'var(--color-muted)', + borderColor: 'var(--color-border)', color: 'var(--color-text)', }} - labelStyle={{ color: 'var(--color-text)', fontWeight: 500 }} - itemStyle={{ color: 'var(--color-text)' }} - /> - - {/* Legenda */} - - {/* Áreas do gráfico */} - + {/* Gradiente suave (segue o tema pelas CSS vars) */} + + + + + + + + {/* Área principal */} diff --git a/frontend/src/components/LatestExpenses/index.jsx b/frontend/src/components/LatestExpenses/index.jsx index ab0287f..72d2e7a 100644 --- a/frontend/src/components/LatestExpenses/index.jsx +++ b/frontend/src/components/LatestExpenses/index.jsx @@ -91,13 +91,17 @@ export default function LatestExpenses() { ))} - {/* 🔄 Controle de Paginação */} - + {totalPages > 1 && ( + <> + {/* 🔄 Controle de Paginação */} + + + )} )} diff --git a/frontend/src/components/Layout/DropdownMenu/index.jsx b/frontend/src/components/Layout/DropdownMenu/index.jsx index 184758b..3e15651 100644 --- a/frontend/src/components/Layout/DropdownMenu/index.jsx +++ b/frontend/src/components/Layout/DropdownMenu/index.jsx @@ -7,9 +7,7 @@ import useMeQuery from '../../../hooks/ReactQuery/Queries/useMeQuery'; export default function DropdownMenu() { const logout = useLogout(); - const { data: user, isLoading, error } = useMeQuery(); - - const firstLetter = user?.name[0]?.toUpperCase() ?? '?'; + const { data: user } = useMeQuery(); return ( - + {/* Menu dropdown */} @@ -31,7 +31,7 @@ export default function DropdownMenu() { anchor="bottom end" className="absolute mt-2 w-56 origin-top-right rounded-xl bg-surface border border-muted shadow-lg ring-1 ring-black/5 z-50 transition-opacity duration-150 ease-out - data-[closed]:opacity-0 data-[enter]:opacity-100" + data-closed:opacity-0 data-enter:opacity-100" >
{navItems.map(({ icon, label, href, isDanger }, index) => ( diff --git a/frontend/src/components/Modal/index.jsx b/frontend/src/components/Modal/index.jsx index 3077d4e..996e0bb 100644 --- a/frontend/src/components/Modal/index.jsx +++ b/frontend/src/components/Modal/index.jsx @@ -7,7 +7,6 @@ import { import { Fragment, useEffect, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import ButtonUI from '../ui/Button'; -import { customToast } from '../CustomToast'; export default function Modal({ children, @@ -27,8 +26,8 @@ export default function Modal({ if (mutation?.action?.type === 'success') { setIsOpen(false); } else if (mutation?.action?.type === 'error') { - const err = mutation.state.error; - customToast('Erro', err?.message || 'Algo deu errado', 'error'); + const err = mutation.action.error; + console.error(err); } }); diff --git a/frontend/src/components/SummaryCards/expenseUtils.js b/frontend/src/components/SummaryCards/expenseUtils.js new file mode 100644 index 0000000..87f739f --- /dev/null +++ b/frontend/src/components/SummaryCards/expenseUtils.js @@ -0,0 +1,15 @@ +export function getMonthlyExpensesSummary(expenses) { + // Soma total de todos os meses + const totalExpenses = expenses.reduce((sum, { total }) => sum + total, 0); + + // Obter o mês com maior gasto + const maxExpenseMonth = expenses.reduce( + (max, item) => (item.total > max.total ? item : max), + { month: '', total: 0 } + ); + + return { + totalExpenses, + maxExpenseMonth, + }; +} diff --git a/frontend/src/components/SummaryCards/index.jsx b/frontend/src/components/SummaryCards/index.jsx index 5f48203..fc8f826 100644 --- a/frontend/src/components/SummaryCards/index.jsx +++ b/frontend/src/components/SummaryCards/index.jsx @@ -1,48 +1,49 @@ -/* import { GrMoney } from 'react-icons/gr'; */ -import { MdCreditCardOff } from 'react-icons/md'; +import { MdCreditCardOff, MdBarChart } from 'react-icons/md'; import CardUI from '../ui/Card'; -import userData from '../../mockData/user/user.data'; import { formatBRL } from '../../utils/formatters'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { getMeExpensesByMonthly } from '../../services/me'; +import { useQuery } from '@tanstack/react-query'; +import { getMonthlyExpensesSummary } from './expenseUtils'; export default function SummaryCards() { - const current_user = userData; - /* const totalBalanceBRL = formatBRL(current_user.dashboard.totalBalance); */ - const totalMonthlySpentBRL = { - total: formatBRL(current_user.dashboard.totalMonthlySpent), - color: - current_user.dashboard.totalMonthlySpent > 0 - ? '!border-error !bg-error/10 !text-error' - : '!border-success !bg-success/10 !text-success', - }; - const isNegative = current_user.dashboard.totalBalance < 0; + const { data: expensesResponse } = useQuery({ + queryKey: [REACTQUERY_KEYS.USER.DASHBOARD, 'balance'], + queryFn: getMeExpensesByMonthly, + }); + + const summary = expensesResponse?.length + ? getMonthlyExpensesSummary(expensesResponse) + : { totalExpenses: 0, mostExpense: { month: '', total: 0 } }; return ( - - {/* Saldo Total */} - {/* -

Saldo Total

-

{totalBalanceBRL}

- -
*/} +
+ {/* Total de Gastos no Ano */} + + +
+

+ Total de Gastos no Ano +

+

+ {formatBRL(summary.totalExpenses)} +

+
+
- {/* Total Gasto */} - -

Total Gasto (Mês)

-

- {totalMonthlySpentBRL.total} -

- + {/* Mês com Maior Gasto */} + + +
+

+ Mês com Maior Gasto +

+

+ {summary.maxExpenseMonth?.month || '-'} ( + {formatBRL(summary.maxExpenseMonth?.total)}) +

+
-
+
); } diff --git a/frontend/src/components/forms/ExpenseForm/ListMembers/index.jsx b/frontend/src/components/forms/ExpenseForm/ListMembers/index.jsx index 727d86a..979b8f8 100644 --- a/frontend/src/components/forms/ExpenseForm/ListMembers/index.jsx +++ b/frontend/src/components/forms/ExpenseForm/ListMembers/index.jsx @@ -76,13 +76,13 @@ export default function ListMembers({ >
- {member.name[0].toUpperCase()} + {member.name[0]}
- + {member.name} {member.authority && ( diff --git a/frontend/src/components/forms/ExpenseForm/index.jsx b/frontend/src/components/forms/ExpenseForm/index.jsx index a4658d9..ffb2f00 100644 --- a/frontend/src/components/forms/ExpenseForm/index.jsx +++ b/frontend/src/components/forms/ExpenseForm/index.jsx @@ -70,8 +70,6 @@ export default function ExpenseForm({ groupData: group }) { ), }; - console.log('💾 Dados da despesa preparados:', expenseData); - await useExpenseMutation.mutateAsync(expenseData, { onSuccess: () => customToast( @@ -80,7 +78,6 @@ export default function ExpenseForm({ groupData: group }) { 'success' ), onError: ({ response: { data: error } }) => { - console.log({ error }); customToast(error.title, error.message, 'error'); }, onSettled: () => setIsSubmitting(false), diff --git a/frontend/src/context/stores/auth.js b/frontend/src/context/stores/auth.js index 2183636..8b0c86f 100644 --- a/frontend/src/context/stores/auth.js +++ b/frontend/src/context/stores/auth.js @@ -5,6 +5,9 @@ import { jwtDecode } from 'jwt-decode'; import { customToast } from '../../components/CustomToast'; import CookieStorage from 'zustand-persist-cookie-storage'; import { SecureLS } from '../../utils/localStorageEncryption'; +import { queryClient } from '../../libs/ReactQuery/queryClient'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { getMe } from '../../services/me'; export const CustomCookieStorage = () => { const base = CookieStorage({ expires: 7 }); // fallback padrão @@ -101,8 +104,19 @@ export const useAuthStore = create()( exp: jwtData.expires_in, }; set({ user }); - customToast('Login realizado', 'Bem-vindo!', 'success'); - /* await useGroupStore.getState().getAllGroupUser(); */ + const currentUser = await queryClient.ensureQueryData({ + queryKey: [REACTQUERY_KEYS.USER.ME], + queryFn: getMe, + }); + + customToast( + 'Login realizado com sucesso!', + currentUser + ? `Seja bem-vindo(a), ${currentUser.name}! 🚀` + : `Seja bem-vindo(a) ao FinBoost! 💰`, + 'success' + ); + return; } catch (e) { const { title, error } = e; @@ -151,24 +165,3 @@ export const useAuthStore = create()( } ) ); - -export const useAuthorityStore = create((set, get) => ({ - // Objeto que guarda a authority por grupo - authorities: {}, - - // Define a authority de um grupo - setAuthority: (groupId, authority) => { - set({ - authorities: { - ...get().authorities, - [groupId]: authority, - }, - }); - }, - - // Pega a authority de um grupo - getAuthority: groupId => get().authorities[groupId], - resetAuthority: () => { - set({ authorities: {} }); - }, -})); diff --git a/frontend/src/context/stores/theme.js b/frontend/src/context/stores/theme.js index a7c2e62..fac1649 100644 --- a/frontend/src/context/stores/theme.js +++ b/frontend/src/context/stores/theme.js @@ -6,7 +6,7 @@ const KEY = 'app_theme'; export const useThemeStore = create( persist( set => ({ - theme: 'dark', // usado apenas se não houver nada no localStorage + theme: 'light', // usado apenas se não houver nada no localStorage logo_image: '/light_mode_logo.png', toggleTheme: () => set(state => { diff --git a/frontend/src/hooks/ReactQuery/Mutations/useCreateExpenseMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useCreateExpenseMutation.js index 22d6795..d35e7f0 100644 --- a/frontend/src/hooks/ReactQuery/Mutations/useCreateExpenseMutation.js +++ b/frontend/src/hooks/ReactQuery/Mutations/useCreateExpenseMutation.js @@ -9,7 +9,6 @@ export function useCreateExpenseMutation(groupID) { mutationFn: expenseData => createExpense(groupID, expenseData), onSuccess: (_response, expenseData) => { - console.log({ expenseData, groupID }); // 1. Invalida os detalhes do Grupo ESPECÍFICO (passando o ID) queryClient.invalidateQueries({ queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail', Number(groupID)], @@ -28,6 +27,10 @@ export function useCreateExpenseMutation(groupID) { queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES, 'filters', Number(groupID)], }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'page'], + }); + // Se a chave de despesas é genérica: // queryClient.invalidateQueries({ // queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES], diff --git a/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMemberMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMemberMutation.js index 13d80e9..252dec8 100644 --- a/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMemberMutation.js +++ b/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMemberMutation.js @@ -11,12 +11,12 @@ export function useDeleteGroupMemberMutation(groupID, page, search) { mutationFn: async ({ group_id, member_id }) => { return await deleteGroupMember(group_id, member_id); }, - onSuccess: () => { + onSuccess: (_response, { group_id }) => { queryClient.invalidateQueries({ queryKey: [REACTQUERY_KEYS.MEMBERS.ALL], }); queryClient.invalidateQueries({ - queryKey: [REACTQUERY_KEYS.GROUPS.MEMBERS], + queryKey: [REACTQUERY_KEYS.GROUPS.MEMBERS, group_id], }); queryClient.invalidateQueries({ queryKey: [REACTQUERY_KEYS.GROUPS.ALL], diff --git a/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMemberRoleMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMemberRoleMutation.js index 71bce72..3bdfbe5 100644 --- a/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMemberRoleMutation.js +++ b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMemberRoleMutation.js @@ -17,8 +17,8 @@ export function useUpdateRoleGroupMemberMutation(groupID, page, search) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: search - ? [REACTQUERY_KEYS.MEMBERS.ALL, Number(groupID), page, search] - : [REACTQUERY_KEYS.MEMBERS.ALL, Number(groupID), page], + ? [REACTQUERY_KEYS.MEMBERS.ALL, 'page', Number(groupID), page, search] + : [REACTQUERY_KEYS.MEMBERS.ALL, 'page', Number(groupID), page], }); customToast( 'Despesa atualizada com sucesso', diff --git a/frontend/src/hooks/ReactQuery/Mutations/useUpdateMeMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useUpdateMeMutation.js index c5fcf2b..350fe76 100644 --- a/frontend/src/hooks/ReactQuery/Mutations/useUpdateMeMutation.js +++ b/frontend/src/hooks/ReactQuery/Mutations/useUpdateMeMutation.js @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; import { updateMe } from '../../../services/me'; +import { customToast } from '../../../components/CustomToast'; export function useUpdateMeMutation() { const queryClient = useQueryClient(); @@ -11,5 +12,8 @@ export function useUpdateMeMutation() { queryClient.setQueryData([REACTQUERY_KEYS.USER.ME], data); queryClient.invalidateQueries([REACTQUERY_KEYS.USER.ME]); }, + onError: error => { + customToast('Erro de atualização', error.message, 'error'); + }, }); } diff --git a/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js index bd48eeb..b860416 100644 --- a/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js +++ b/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js @@ -15,12 +15,22 @@ export function useUpdateStatusExpensePartialValueMutation() { ); }, onSuccess: (updatedMember, { group_id, expense_id, member_id }) => { + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.USER.EXPENSES, 'me'], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail', Number(group_id)], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'page'], + }); + queryClient.invalidateQueries({ queryKey: [ REACTQUERY_KEYS.GROUPS.EXPENSES, 'groups', Number(group_id), - expense_id, + Number(expense_id), ], }); customToast( diff --git a/frontend/src/hooks/ReactQuery/Queries/useGroupExpenseByIdQuery.js b/frontend/src/hooks/ReactQuery/Queries/useGroupExpenseByIdQuery.js index bce171b..612a14f 100644 --- a/frontend/src/hooks/ReactQuery/Queries/useGroupExpenseByIdQuery.js +++ b/frontend/src/hooks/ReactQuery/Queries/useGroupExpenseByIdQuery.js @@ -8,7 +8,7 @@ export function useGroupExpenseByIdQuery(group_id, expense_id) { REACTQUERY_KEYS.GROUPS.EXPENSES, 'groups', Number(group_id), - expense_id, + Number(expense_id), ], enabled: !!group_id && !!expense_id, queryFn: async () => await getGroupExpenseById(group_id, expense_id), diff --git a/frontend/src/hooks/ReactQuery/Queries/useMembersQuery.js b/frontend/src/hooks/ReactQuery/Queries/useMembersQuery.js index 834fb2c..eb13adb 100644 --- a/frontend/src/hooks/ReactQuery/Queries/useMembersQuery.js +++ b/frontend/src/hooks/ReactQuery/Queries/useMembersQuery.js @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; import { getGroupMembers } from '../../../services/groups'; +//Membros de um grupo específico export const useMembersQuery = (groupID, page, search) => { return useQuery({ queryKey: search diff --git a/frontend/src/hooks/useLogout.js b/frontend/src/hooks/useLogout.js index c221134..c05a81d 100644 --- a/frontend/src/hooks/useLogout.js +++ b/frontend/src/hooks/useLogout.js @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router'; -import { useAuthorityStore, useAuthStore } from '../context/stores/auth'; +import { useAuthStore } from '../context/stores/auth'; import { localStoragePersistor, queryClient, @@ -8,16 +8,11 @@ import { export function useLogout() { const navigate = useNavigate(); const resetStore = useAuthStore(state => state.reset); - const resetAuthority = useAuthorityStore(state => state.resetAuthority); - const stores = useAuthorityStore(); + const handleLogout = async (redirectTo = '/login') => { try { // Limpa estado em memória resetStore?.(); - resetAuthority?.(); - - /* MExendo na autoridade e resolvendo o erro do grupo detalhes */ - console.log(stores); // Limpa estado persistido (se existir) if (useAuthStore.persist?.clearStorage) { useAuthStore.persist.clearStorage(); diff --git a/frontend/src/loaders/expenseLoader.js b/frontend/src/loaders/expenseLoader.js new file mode 100644 index 0000000..f4eff5a --- /dev/null +++ b/frontend/src/loaders/expenseLoader.js @@ -0,0 +1,76 @@ +import { REACTQUERY_KEYS } from '../libs/ReactQuery/keys'; +import { queryClient } from '../libs/ReactQuery/queryClient'; +import { + getGroupExpenseById, + getGroupUserAuthenticatedAuthority, +} from '../services/groups'; +import { getMe } from '../services/me'; + +export async function expenseLoader({ request, params }) { + const { group_id, expense_id } = params; + + // Pega o usuário logado + const user = await queryClient.ensureQueryData({ + queryKey: [REACTQUERY_KEYS.USER.ME], + queryFn: getMe, + staleTime: Infinity, + }); + + // Pega autoridade do usuário no grupo + const authority = await queryClient.ensureQueryData({ + queryKey: [REACTQUERY_KEYS.GROUPS.ME_AUTHORITY, group_id, user.id], + queryFn: () => getGroupUserAuthenticatedAuthority(group_id, user.id), + }); + + // Declara expense fora do try + let expense; + const url = new URL(request.url); + const path = url.pathname.split(`/expenses`)[0]; + try { + expense = await queryClient.ensureQueryData({ + queryKey: [ + REACTQUERY_KEYS.GROUPS.EXPENSES, + 'groups', + Number(group_id), + Number(expense_id), + ], + queryFn: () => getGroupExpenseById(group_id, expense_id), + }); + } catch (error) { + const responseData = { + btnText: 'Voltar para a página anterior?', + message: 'Despesa não encontrada!', + path, + }; + throw new Response(JSON.stringify(responseData), { + status: 403, + statusText: 'Erro!', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + Pragma: 'no-cache', + }, + }); + } + + // Verifica se o usuário é membro da despesa + const isMember = expense.memberList.some(member => member.userId === user.id); + if (!isMember) { + const responseData = { + btnText: 'Voltar para a página anterior?', + message: 'Despesa não encontrada!', + path, + }; + throw new Response(JSON.stringify(responseData), { + status: 403, + statusText: 'Erro!', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + Pragma: 'no-cache', + }, + }); + } + + return authority; +} diff --git a/frontend/src/loaders/loginLoader.js b/frontend/src/loaders/loginLoader.js deleted file mode 100644 index c151659..0000000 --- a/frontend/src/loaders/loginLoader.js +++ /dev/null @@ -1,9 +0,0 @@ -import { redirect } from 'react-router'; -import { useAuthStore } from '../context/stores/auth'; - -export async function loginLoader() { - await useAuthStore.persist.rehydrate(); - const isAuthenticated = !!useAuthStore.getState().token; - if (!isAuthenticated) return null; - return redirect('/'); -} diff --git a/frontend/src/loaders/protectRoutersLoader.js b/frontend/src/loaders/protectRoutersLoader.js index 929a817..0da44d4 100644 --- a/frontend/src/loaders/protectRoutersLoader.js +++ b/frontend/src/loaders/protectRoutersLoader.js @@ -1,9 +1,38 @@ -import { redirect } from 'react-router'; +import { matchPath, redirect } from 'react-router'; import { useAuthStore } from '../context/stores/auth'; +import { queryClient } from '../libs/ReactQuery/queryClient'; +import { REACTQUERY_KEYS } from '../libs/ReactQuery/keys'; +import { getGroupUserAuthenticatedAuthority } from '../services/groups'; +import { getMe } from '../services/me'; -export async function protectRoutersLoader() { +export async function protectRoutersLoader({ request, params }) { + // 🔐 1. Proteção de login await useAuthStore.persist.rehydrate(); const isAuthenticated = !!useAuthStore.getState().token; - if (isAuthenticated) return null; - return redirect('/login'); + if (!isAuthenticated) return redirect('/login'); + + // 🧩 2. Verifica se é uma rota protegida específica + const { group_id } = params; + const url = new URL(request.url); + const path = url.pathname; + const match = matchPath(`/groups/${group_id}/settings/*`, path); + if (!match) return null; // rota não é settings → libera + + const user = await queryClient.ensureQueryData({ + queryKey: [REACTQUERY_KEYS.USER.ME], + queryFn: getMe, + staleTime: Infinity, + }); + + const authority = await queryClient.ensureQueryData({ + queryKey: [REACTQUERY_KEYS.GROUPS.ME_AUTHORITY, group_id, user.id], + queryFn: () => getGroupUserAuthenticatedAuthority(group_id, user.id), + staleTime: Infinity, + }); + + if (authority !== 'OWNER') { + return redirect(path.split('/settings')[0]); + } + + return null; } diff --git a/frontend/src/loaders/redirectIfAuthenticatedLoader.js b/frontend/src/loaders/redirectIfAuthenticatedLoader.js new file mode 100644 index 0000000..387b88d --- /dev/null +++ b/frontend/src/loaders/redirectIfAuthenticatedLoader.js @@ -0,0 +1,21 @@ +import { redirect } from 'react-router'; +import { useAuthStore } from '../context/stores/auth'; + +export async function redirectIfAuthenticatedLoader({ request }) { + await useAuthStore.persist.rehydrate(); + + const { token } = useAuthStore.getState(); + const isAuthenticated = !!token; + + const url = new URL(request.url); + const path = url.pathname; + const publicRoutes = ['/login', '/register']; + + const isPublicRoute = publicRoutes.includes(path); + + if (isAuthenticated && isPublicRoute) { + return redirect('/'); + } + + return null; +} diff --git a/frontend/src/loaders/registerLoader.js b/frontend/src/loaders/registerLoader.js deleted file mode 100644 index 2b1ae37..0000000 --- a/frontend/src/loaders/registerLoader.js +++ /dev/null @@ -1,9 +0,0 @@ -import { redirect } from 'react-router'; -import { useAuthStore } from '../context/stores/auth'; - -export async function registerLoader() { - await useAuthStore.persist.rehydrate(); - const isAuthenticated = !!useAuthStore.getState().token; - if (!isAuthenticated) return null; - return redirect('/'); -} diff --git a/frontend/src/pages/Expenses/ExpenseDetails/index.jsx b/frontend/src/pages/Expenses/ExpenseDetails/index.jsx index 3bbb3c6..5c565c8 100644 --- a/frontend/src/pages/Expenses/ExpenseDetails/index.jsx +++ b/frontend/src/pages/Expenses/ExpenseDetails/index.jsx @@ -4,7 +4,7 @@ import { EXPENSE_STATUS, STATUS_COLORS, } from '../../Groups/GroupDetails/statusExpense'; -import { Form, useNavigate, useParams } from 'react-router'; +import { Form, useLoaderData, useNavigate, useParams } from 'react-router'; import { formatBRL, formatDateBR } from '../../../utils/formatters'; import InputUI from '../../../components/ui/Input'; import TextareaUI from '../../../components/ui/Textarea'; @@ -19,24 +19,18 @@ import { getCurrentDate } from '../../../utils/helpers'; import { ConfirmModal } from '../../../components/Modal'; import { validateExpensePayload } from '../../../schemas/createNewExpense/updateExpenseForm'; import { customToast } from '../../../components/CustomToast'; -import { useAuthorityStore } from '../../../context/stores/auth'; - +import { MdOutlineFactCheck } from 'react-icons/md'; export default function ExpenseDetails() { - const { getAuthority } = useAuthorityStore(); const [isOpenConfirmModal, setIsOpenConfirmModal] = useState(false); const navigate = useNavigate(); const { group_id, expense_id } = useParams(); + const loaderData = useLoaderData(); + const authority = loaderData; + const canEditExpense = ['OWNER', 'ADMIN'].includes(authority); // pode editar campos + const canChangeStatus = authority === 'OWNER'; // só OWNER altera status - const userRole = getAuthority(group_id); // papel do usuário neste grupo - alert(userRole); - const canEditExpense = ['OWNER', 'ADMIN'].includes(userRole); // pode editar campos - const canChangeStatus = userRole === 'OWNER'; // só OWNER altera status - - const { - data: expense, - isLoading: isLoadingExpense, - isError, - } = useGroupExpenseByIdQuery(group_id, expense_id); + const { data: expense, isLoading: isLoadingExpense } = + useGroupExpenseByIdQuery(group_id, expense_id); const { data: categories } = useAllGroupExpenseCategoriesQuery(); const { mutateAsync, isPending: isLoading } = @@ -64,9 +58,6 @@ export default function ExpenseDetails() { } }, [expense]); - if (isLoadingExpense) - return

Carregando...

; - const color = STATUS_COLORS[expense?.status] || STATUS_COLORS.DEFAULT; function handleChange(e) { @@ -108,6 +99,10 @@ export default function ExpenseDetails() { } } + if (isLoadingExpense) { + return Carregado...; + } + return (

{expense.title}

{EXPENSE_STATUS[expense.status] || 'DESCONHECIDO'} @@ -142,7 +137,7 @@ export default function ExpenseDetails() { return (
-
+
{formatBRL(totalPago)} / {formatBRL(total)} @@ -171,8 +166,8 @@ export default function ExpenseDetails() {
-

- Criado em{' '} +

+ Criado em:{' '} {formatDateBR(expense.createdAt)} @@ -262,6 +257,7 @@ export default function ExpenseDetails() { ({ @@ -318,7 +314,18 @@ export default function ExpenseDetails() { {/* Participantes */}

-

Participantes

+
+ {/* Título Principal: Mais forte e com cor sutil */} +

+ 👥 Participantes +

+ + {/* Status da Despesa: Fonte menor e cinza sutil */} + + Status da Despesa + + +
{expense.memberList?.map(member => { @@ -352,7 +359,7 @@ export default function ExpenseDetails() { handleChangeStatusExpensePartialValue(member.userId) } disabled={isLoading || !canChangeStatus} // bloqueia se não pode alterar - className={`flex min-w-[130px] justify-center items-center gap-2 px-5 py-2 rounded-lg font-medium shadow-sm transition-all text-white ${STATUS_COLORS[member.status].bg}`} + className={`flex min-w-[130px] cursor-pointer justify-center items-center gap-2 px-5 py-2 rounded-lg font-medium shadow-sm transition-all text-white ${STATUS_COLORS[member.status].bg}`} type="button" > {isLoading ? 'ALTERANDO...' : EXPENSE_STATUS[member.status]} diff --git a/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx b/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx index 76fc5e0..e17a7c0 100644 --- a/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx +++ b/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx @@ -137,20 +137,32 @@ export default function ExpensesList({ groupID, authorization }) {
{/* 💰 Valor e informações */} -
+
+
+ {/* Identificador da despesa */} + {expense.total ? ( + + Valor total da despesa + + ) : ( + + Sua parte da despesa + + )} +
+ {/* Valor principal */} - {formatBRL(expense.partialValue || expense.total)} - - - - {expense.partialValue - ? 'Sua parte na despesa' - : 'Valor total da despesa'} + {formatBRL(expense.partialValue ?? expense.total)} - + {/* Prazo */} + Prazo final:{' '} {expense.deadlineDate ? new Date(expense.deadlineDate).toLocaleDateString( diff --git a/frontend/src/pages/Groups/GroupDetails/GroupSettings/MembersManager.jsx b/frontend/src/pages/Groups/GroupDetails/GroupSettings/MembersManager.jsx index 0a4a2f4..63f67f0 100644 --- a/frontend/src/pages/Groups/GroupDetails/GroupSettings/MembersManager.jsx +++ b/frontend/src/pages/Groups/GroupDetails/GroupSettings/MembersManager.jsx @@ -54,6 +54,15 @@ export function MembersManager({ group }) { // Confirma a troca de cargo const handleConfirmChangeRole = async () => { + if (!confirmModal.newRole) { + await handleRemoveMember(confirmModal.memberId); + setConfirmModal({ + isOpen: false, + message: '', + memberId: null, + newRole: null, + }); + } if (confirmModal.memberId && confirmModal.newRole) { await handleChangeRole(confirmModal.memberId, confirmModal.newRole); setConfirmModal({ @@ -131,13 +140,13 @@ export function MembersManager({ group }) {
{member.name[0]}

- {member.name} + {member.name} {member.authority === 'OWNER' && ( handleRemoveMember(member.id)} + onClick={() => { + setConfirmModal({ + isOpen: true, + message: `Tem certeza que deseja remover ${member.name} do grupo?`, + memberId: member.id, + newRole: null, + }); + }} className="p-2 cursor-pointer rounded-full bg-red-100/80 hover:bg-red-200 text-red-600 transition" title="Remover membro" > diff --git a/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx b/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx index d3f8243..39d07e0 100644 --- a/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx +++ b/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Link, useLoaderData, useParams } from 'react-router'; +import { Link, useParams } from 'react-router'; import { FiArrowLeft, FiEdit3, @@ -19,8 +19,6 @@ import { useGroupByIdQuery } from '../../../../hooks/ReactQuery/Queries/useGroup import { useUpdateGroupMutation } from '../../../../hooks/ReactQuery/Mutations/useUpdateGroupMutation'; export default function GroupSettings() { - const loader = useLoaderData(); - console.log({ loader }); const { group_id } = useParams(); const [isOpen, setIsOpen] = useState(false); @@ -55,8 +53,8 @@ export default function GroupSettings() { } return ( -

-
+
+
{/* Cabeçalho */}
@@ -69,7 +67,8 @@ export default function GroupSettings() { {group.name}

- Gerencie o grupo e seus membros com estilo 😎 + Defina as permissões, ajuste as regras e garanta o controle total + sobre o seu grupo.

@@ -107,7 +106,7 @@ export default function GroupSettings() {
{/* Acesso ao Gerenciamento de Membros */} -
+

Gerenciar membros do grupo @@ -123,7 +122,12 @@ export default function GroupSettings() { Abrir Gerenciador de Membros - setIsOpen(false)}> + setIsOpen(false)} + setIsOpen={setIsOpen} + autoCloseOnSuccess={false} + >

@@ -131,7 +135,7 @@ export default function GroupSettings() { {/* Botões */}
diff --git a/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js b/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js deleted file mode 100644 index 80c9b70..0000000 --- a/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js +++ /dev/null @@ -1,37 +0,0 @@ -import { useAuthorityStore } from '../../../context/stores/auth'; -import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; -import { queryClient } from '../../../libs/ReactQuery/queryClient'; -import { getGroupMembers } from '../../../services/groups'; - -export async function groupDetailsLoader({ params }) { - let userAuthority = null; - const { group_id } = params; - const { setAuthority } = useAuthorityStore.getState(); - const data = queryClient.getQueryData([REACTQUERY_KEYS.USER.ME]); - const search = data?.name; - try { - userAuthority = await queryClient.fetchQuery({ - queryKey: [REACTQUERY_KEYS.GROUPS.ME_AUTHORITY, group_id], - queryFn: async () => { - const { members } = await getGroupMembers({ - groupId: group_id, - search: search, - }); - if (members && members.length > 0) { - return members[0].authority; - } - throw new Error('Membro não encontrado no grupo.'); - }, - }); - } catch (error) { - console.error('Falha ao buscar autoridade do membro:', error); - throw new Response('Acesso negado. Você não é membro deste grupo.', { - status: 403, - statusText: 'Acesso negado', - }); - } - if (userAuthority) { - setAuthority(group_id, userAuthority); - } - return; -} diff --git a/frontend/src/pages/Groups/GroupDetails/index.jsx b/frontend/src/pages/Groups/GroupDetails/index.jsx index beb3606..eea135a 100644 --- a/frontend/src/pages/Groups/GroupDetails/index.jsx +++ b/frontend/src/pages/Groups/GroupDetails/index.jsx @@ -7,18 +7,19 @@ import BalancesList from './BalancesList'; */ import ExpensesList from './ExpensesList'; import Expenses from '../../Expenses'; -import { formatBRL } from '../../../utils/formatters'; import ModalButton from '../../../components/Modal/ModalButton'; import { CategoryIcon } from '../../../mockData/groupIcons/icons'; -import { ConfirmModal } from '../../../components/Modal'; +import Modal, { ConfirmModal } from '../../../components/Modal'; import { usePermissions } from './usePermissions'; import { useGroupByIdQuery } from '../../../hooks/ReactQuery/Queries/useGroupsQuery'; import { useDeleteGroupMutation } from '../../../hooks/ReactQuery/Mutations/useDeleteGroupMutation'; +import GroupFinancialStatus from '../GrupoFinancialStatus'; +import ButtonUI from '../../../components/ui/Button'; export default function GroupDetails() { - const navigate = useNavigate(); + const [groupModalIsOpen, setGroupModalIsOpen] = useState(false); const { group_id } = useParams(); const [isOpenModal, setIsOpenModal] = useState(false); const { data: group } = useGroupByIdQuery(group_id); @@ -104,48 +105,7 @@ export default function GroupDetails() { )} - {/* Total do grupo */} -
-

- Total de despesas -

- -
-

- {formatBRL(group?.total)} -

-

acumulado do mês

- {group?.description && ( - <> -
-
-
-

- Descrição -

-

- {group?.description} -

-
-
- - )} -
-
+ {/* Conteúdo principal */}
@@ -155,7 +115,21 @@ export default function GroupDetails() { {/* Modal de adicionar despesa */} {canCreateExpenses && ( - } /> + <> + setGroupModalIsOpen(true)} + className="fixed z-10 cursor-pointer bottom-5 right-6 bg-primary font-bold text-3xl text-white px-4 py-2 rounded-lg shadow hover:bg-primary/90 transition" + > + + + + + setGroupModalIsOpen(false)} + isOpen={groupModalIsOpen} + setIsOpen={setGroupModalIsOpen} + children={} + /> + )}
); diff --git a/frontend/src/pages/Groups/GrupoFinancialStatus.jsx b/frontend/src/pages/Groups/GrupoFinancialStatus.jsx new file mode 100644 index 0000000..cbf468a --- /dev/null +++ b/frontend/src/pages/Groups/GrupoFinancialStatus.jsx @@ -0,0 +1,97 @@ +import { TbCurrencyDollar } from 'react-icons/tb'; + +import { TbCoin, TbUserDollar } from 'react-icons/tb'; +import { formatBRL } from '../../utils/formatters'; + +export default function GroupFinancialStatus({ group, variant = 'compact' }) { + const isPositive = group?.total <= 0; + const colorClass = isPositive ? 'text-success' : 'text-error'; + const bgClass = isPositive + ? 'bg-success/5 border-success/20' + : 'bg-error/5 border-error/20'; + + if (variant === 'compact') { + return ( +
+ + + + {formatBRL(group?.total)} + + + + + + + {formatBRL(group?.partial_total)} + + +
+ ); + } + + // --- DETAILED VARIANT --- + return ( +
+

+ Total de Despesas do Grupo +

+ +
+

+ {formatBRL(group?.total)} +

+ +

+ {' '} + Sua parte: {formatBRL(group?.partial_total ?? 0)} +

+
+ {group.description && ( + + )} +
+ ); +} + +function GroupDescription({ description }) { + return ( +
+ {/* Cabeçalho */} +
+
+ +
+

+ Descrição +

+
+ + {/* Corpo */} +
+

+ {description} +

+
+
+ ); +} diff --git a/frontend/src/pages/Groups/index.jsx b/frontend/src/pages/Groups/index.jsx index 30761a4..e614cb0 100644 --- a/frontend/src/pages/Groups/index.jsx +++ b/frontend/src/pages/Groups/index.jsx @@ -4,7 +4,6 @@ import GroupFilters from '../../components/Filters/Groups'; import { useFilteredGroups } from '../../components/Filters/Groups/useFilteredGroups'; import GroupForm from '../../components/forms/GroupForm'; import CardUI from '../../components/ui/Card'; -import { formatBRL } from '../../utils/formatters'; import { CategoryIcon } from '../../mockData/groupIcons/icons'; import Pagination from '../../components/PaginationController'; import useMeQuery from '../../hooks/ReactQuery/Queries/useMeQuery'; @@ -12,6 +11,7 @@ import { useGroupsQuery } from '../../hooks/ReactQuery/Queries/useGroupsQuery'; import useGroupsWithMembers from './useGroupsWithMembers'; import ButtonUI from '../../components/ui/Button'; import Modal from '../../components/Modal'; +import GroupFinancialStatus from './GrupoFinancialStatus'; export default function Groups() { const [searchParams] = useSearchParams(); @@ -101,15 +101,15 @@ export default function Groups() { }`} > {filteredGroups.map(group => { - const membersData = membersMap[group?.id]?.members ?? []; + const membersData = membersMap[group?.groupId]?.members ?? []; const membersLength = - membersMap[group?.id]?.totalElements ?? 0; - const membersLoading = membersMap[group?.id]?.isLoading; + membersMap[group?.groupId]?.totalElements ?? 0; + const membersLoading = membersMap[group?.groupId]?.isLoading; return ( @@ -141,7 +141,7 @@ export default function Groups() { .map(({ name, themeColor }, idx) => ( - {name[0]?.toUpperCase()} + {name[0]} ))} @@ -169,16 +169,7 @@ export default function Groups() { )}
- {/* Status financeiro */} -

- Despesas: {formatBRL(group.totalExpenses)} -

+ ); diff --git a/frontend/src/pages/Groups/useGroupsWithMembers.jsx b/frontend/src/pages/Groups/useGroupsWithMembers.jsx index a20a851..9d96579 100644 --- a/frontend/src/pages/Groups/useGroupsWithMembers.jsx +++ b/frontend/src/pages/Groups/useGroupsWithMembers.jsx @@ -2,24 +2,32 @@ import { useQueries } from '@tanstack/react-query'; import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; import { getGroupMembers } from '../../services/groups'; -export default function useGroupsWithMembers(groups) { - // Cria queries dinâmicas de membros (cacheadas) +//resumo dos membros na lista paginada +export default function useGroupsWithMembers(groups = []) { const membersQueries = useQueries({ - queries: groups.map(group => ({ - queryKey: [REACTQUERY_KEYS.GROUPS.MEMBERS, group.id], - queryFn: () => getGroupMembers({ groupId: group.id, size: 4 }), - enabled: !!group.id, - staleTime: Infinity, // mantém o cache fresco indefinidamente + queries: groups.map(({ groupId }) => ({ + queryKey: [REACTQUERY_KEYS.GROUPS.MEMBERS, groupId], + queryFn: () => getGroupMembers({ groupId, size: 4 }), + enabled: !!groupId, + staleTime: Infinity, })), }); - // Cria um map: { [groupId]: members } + const membersMap = {}; + membersQueries.forEach((q, i) => { - if (q.data) - membersMap[groups[i].id] = { - totalElements: q.data.totalElements, - members: q.data.members, - }; + const groupId = groups[i]?.groupId; + if (!groupId) return; + + membersMap[groupId] = q.data + ? { + totalElements: q.data.totalElements, + members: q.data.members, + } + : { + totalElements: 0, + members: [], + }; }); return membersMap; diff --git a/frontend/src/pages/Notfound/index.jsx b/frontend/src/pages/Notfound/index.jsx index 7d8c0d4..21bd9be 100644 --- a/frontend/src/pages/Notfound/index.jsx +++ b/frontend/src/pages/Notfound/index.jsx @@ -1,33 +1,48 @@ import { FaPiggyBank, FaArrowLeft } from 'react-icons/fa'; -import { Link, useRouteError } from 'react-router'; +import { Link, useRouteError, isRouteErrorResponse } from 'react-router'; export default function NotFound() { const error = useRouteError(); - console.log(error); + + // Valores padrão + let status = 404; + let statusText = 'Página não encontrada'; + let message = + 'Verifique o link ou volte para o painel principal do FinBoost+.'; + let path = '/'; + let btnText = 'Voltar ao início'; + + // Se for um erro do React Router (throw new Response) + if (isRouteErrorResponse(error)) { + status = error.status; + statusText = error.statusText ?? statusText; + const data = error.data ?? {}; // <-- não fazer JSON.parse + message = data.message ?? message; + path = data.path ?? path; + btnText = data.btnText ?? btnText; + } else if (error instanceof Error) { + // Erro padrão do JS + message = message; + } + return (

- {error ? error.status : '404'} + {status}

-

- {error ? error.statusText : 'Página não encontrada'} -

+

{statusText}

- {error - ? error.data - : 'Parece que essa página, grupo ou despesa não existe mais.'} -
- Verifique o link ou volte para o painel principal do FinBoost+. + {message}

- Voltar ao início + {btnText}
diff --git a/frontend/src/pages/Profile/ActiveGroups/index.jsx b/frontend/src/pages/Profile/ActiveGroups/index.jsx index b16d4db..5c90298 100644 --- a/frontend/src/pages/Profile/ActiveGroups/index.jsx +++ b/frontend/src/pages/Profile/ActiveGroups/index.jsx @@ -48,8 +48,8 @@ export default function ActiveGroups() {
{groupsToShow.map(group => ( Editar perfil -

+

Atualize suas informações pessoais e personalize seu avatar.

@@ -119,10 +116,10 @@ export default function ProfileContent({ setIsModalOpen }) {
{/* Avatar Preview */}
- {userData.name[0]?.toUpperCase() || '?'} + {userData?.name[0] || '?'}
diff --git a/frontend/src/pages/Profile/index.jsx b/frontend/src/pages/Profile/index.jsx index 623f8e7..b3309fb 100644 --- a/frontend/src/pages/Profile/index.jsx +++ b/frontend/src/pages/Profile/index.jsx @@ -19,12 +19,23 @@ import Modal from '../../components/Modal'; import useMeQuery from '../../hooks/ReactQuery/Queries/useMeQuery'; import { useMeDashboardQuery } from '../../hooks/ReactQuery/Queries/useMeDashboardStatsQuery'; import { useGroupsQuery } from '../../hooks/ReactQuery/Queries/useGroupsQuery'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { getMeExpensesByMonthly } from '../../services/me'; +import { useQuery } from '@tanstack/react-query'; +import { getMonthlyExpensesSummary } from '../../components/SummaryCards/expenseUtils'; export default function Profile() { const { data: user } = useMeQuery(); const { data: dashboard } = useMeDashboardQuery(); const { data } = useGroupsQuery(); - console.log({ data }); + const { data: expensesResponse } = useQuery({ + queryKey: [REACTQUERY_KEYS.USER.DASHBOARD, 'balance'], + queryFn: getMeExpensesByMonthly, + }); + + const summary = expensesResponse?.length + ? getMonthlyExpensesSummary(expensesResponse) + : { totalExpenses: 0 }; const groupsLength = data?.groupsLength; const [isModalOpen, setIsModalOpen] = useState(false); const current_user = userData; @@ -36,12 +47,12 @@ export default function Profile() {
- {user?.name[0].toUpperCase()} + {user?.name[0]}
-

{user?.name}

+

{user?.name}

{user?.email}

@@ -59,8 +70,8 @@ export default function Profile() { } /> diff --git a/frontend/src/routes/protectRouterGroupLoader.js b/frontend/src/routes/protectRouterGroupLoader.js deleted file mode 100644 index a9911e4..0000000 --- a/frontend/src/routes/protectRouterGroupLoader.js +++ /dev/null @@ -1,93 +0,0 @@ -import { queryClient } from '../libs/ReactQuery/queryClient'; -import { REACTQUERY_KEYS } from '../libs/ReactQuery/keys'; -import { getGroupMembers } from '../services/groups'; -import { getMe } from '../services/me'; - -// Níveis de permissão necessários para acessar esta rota -const ROLES = ['OWNER']; - -export async function protectRouterGroupLoader({ params }) { - // --- 0. Declaração da variável userAuthority --- - let userAuthority = null; - - // --- 1. Obtenção Segura de Dados e Conversão de Tipo --- - let userData = queryClient.getQueryData([REACTQUERY_KEYS.USER.ME]); - - const rawGroupId = params.group_id; - const groupId = Number(rawGroupId); // Garante que é um número - - // Tenta buscar o usuário autenticado se não estiver no cache - if (!userData) { - try { - userData = await queryClient.fetchQuery({ - queryKey: [REACTQUERY_KEYS.USER.ME], - queryFn: getMe, - }); - } catch (error) { - console.error('Falha ao buscar usuário autenticado:', error); - throw new Response('Usuário não autenticado', { - status: 401, - statusText: 'Não autorizado', - }); - } - } - - // Garante que o objeto 'user' é extraído corretamente - const user = userData.user || userData; - const search = user.name; - - // Checagem básica de validade - if (!user || !user.id || isNaN(groupId)) { - console.error('Dados de usuário ou ID do grupo inválidos após busca.'); - throw new Response('Dados inválidos', { - status: 400, - statusText: 'Bad Request', - }); - } - - // --- 2. Tenta ler a autoridade do cache (ou buscar) --- - userAuthority = queryClient.getQueryData([ - REACTQUERY_KEYS.GROUPS.ME_AUTHORITY, - groupId, - ]); - - if (!userAuthority) { - try { - userAuthority = await queryClient.fetchQuery({ - queryKey: [REACTQUERY_KEYS.GROUPS.ME_AUTHORITY, groupId], - queryFn: async () => { - const { members } = await getGroupMembers({ - groupId: groupId, - search: search, - }); - if (members && members.length > 0) { - return members[0].authority; - } - throw new Error('Membro não encontrado no grupo.'); - }, - }); - } catch (error) { - console.error('Falha ao buscar autoridade do membro:', error); - throw new Response('Acesso negado. Você não é membro deste grupo.', { - status: 403, - statusText: 'Acesso negado', - }); - } - } - - // --- 3. Lógica de Proteção CORRETA usando some() --- - const hasPermission = ROLES.some(role => role === userAuthority); - - if (hasPermission) { - // ✅ SUCESSO: O usuário tem permissão. Retorna os dados necessários para a rota. - return; - } - - // ❌ FALHA: O usuário NÃO tem permissão (Role insuficiente). - console.warn('Acesso negado. Nível de autorização insuficiente.'); - - throw new Response( - 'Acesso negado. Você não tem a permissão necessária para esta página.', - { status: 403, statusText: 'Acesso negado' } - ); -} diff --git a/frontend/src/routes/routes.jsx b/frontend/src/routes/routes.jsx index b9336fe..ba7c60a 100644 --- a/frontend/src/routes/routes.jsx +++ b/frontend/src/routes/routes.jsx @@ -2,52 +2,47 @@ import { createBrowserRouter } from 'react-router'; import { lazy } from 'react'; import { loginAction } from '../actions/loginAction'; import { registerAction } from '../actions/registerAction'; -import { loginLoader } from '../loaders/loginLoader'; -import { registerLoader } from '../loaders/registerLoader'; import { protectRoutersLoader } from '../loaders/protectRoutersLoader'; // Layout e App carregados normalmente import Layout from '../components/Layout'; import App from '../App'; import ExpenseDetails from '../pages/Expenses/ExpenseDetails'; -import { protectRouterGroupLoader } from './protectRouterGroupLoader'; -import { groupDetailsLoader } from '../pages/Groups/GroupDetails/groupDetailsLoader'; +import { redirectIfAuthenticatedLoader } from '../loaders/redirectIfAuthenticatedLoader'; +import NotFound from '../pages/Notfound'; +import { expenseLoader } from '../loaders/expenseLoader'; const Login = lazy(() => import('../pages/Login')); const Register = lazy(() => import('../pages/Register')); -const Dashboard = lazy(() => import('../pages/Dashboard')); const Groups = lazy(() => import('../pages/Groups')); const GroupDetails = lazy(() => import('../pages/Groups/GroupDetails')); const Profile = lazy(() => import('../pages/Profile')); const GroupSettings = lazy( () => import('../pages/Groups/GroupDetails/GroupSettings') ); -const NotFound = lazy(() => import('../pages/Notfound')); // Export router diretamente (não como função) export const appRouter = createBrowserRouter([ { element: , errorElement: , // <-- Captura qualquer erro de rota + loader: redirectIfAuthenticatedLoader, children: [ { path: '/login', element: , action: loginAction, - loader: loginLoader, }, { path: '/register', element: , action: registerAction, - loader: registerLoader, }, { path: '/', loader: protectRoutersLoader, children: [ { index: true, element: }, - { path: 'dashboard', element: }, { path: 'groups', children: [ @@ -55,20 +50,15 @@ export const appRouter = createBrowserRouter([ { path: ':group_id', element: , - loader: groupDetailsLoader, }, { path: ':group_id/expenses/:expense_id', element: , - loader: groupDetailsLoader, + loader: expenseLoader, }, { path: ':group_id/settings', element: , - loader: async () => { - await protectRouterGroupLoader(); - await groupDetailsLoader(); - }, }, ], }, diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index 66627e4..a271ab8 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -16,7 +16,6 @@ export const login = ({ username, password }) => { config ) .then(({ data }) => { - console.log(data); return { value: data }; }) .catch(error => { diff --git a/frontend/src/services/groups.js b/frontend/src/services/groups.js index b5785db..d6a8bd8 100644 --- a/frontend/src/services/groups.js +++ b/frontend/src/services/groups.js @@ -129,7 +129,7 @@ export const getGroupExpenseById = async (group_id, expense_id) => { const response = await apiApplication.get( `/groups/${group_id}/expenses/${expense_id}` ); - console.log(response); + return response.data; } catch (error) { console.error(error); @@ -157,7 +157,6 @@ export const updatePartialValueExpenseStatus = async ( }; export const updateExpenseDetails = async (group_id, expense_id, data) => { - console.log({ group_id, expense_id, data }); try { const response = await apiApplication.put( `/groups/${group_id}/expenses/${expense_id}`, @@ -205,3 +204,14 @@ export const updateRoleGroupMember = async (group_id, member_id, newRole) => { throw error; } }; + +export const getGroupUserAuthenticatedAuthority = async (group_id, userId) => { + try { + const response = await apiApplication.get( + `/groups/${group_id}/members/${userId}/authority` + ); + return response.data.authority; + } catch (error) { + throw error; + } +}; diff --git a/frontend/src/services/me.js b/frontend/src/services/me.js index 98854a2..e9ec253 100644 --- a/frontend/src/services/me.js +++ b/frontend/src/services/me.js @@ -72,3 +72,12 @@ export const getMeDashboardStats = async () => { throw new Error('Erro ao obter dados das despesas'); } }; + +export const getMeExpensesByMonthly = async () => { + try { + const response = await apiApplication.get('user/me/monthly-expenses'); + return response.data; + } catch (error) { + throw error; + } +};