- {/* 🔎 Busca + Status */}
-
- {/* Campo de busca */}
-
-
- setSearch(e.target.value)}
- className="pl-9 w-full h-10 rounded-lg border border-border/50 bg-surface-light focus:ring-2 focus:ring-primary/40 transition-all"
- />
-
-
- {/* Select de status */}
-
- setStatus(e.target.value)}
- className="w-full sm:w-[160px] h-10 rounded-lg border border-border/50 bg-surface-light text-sm focus:ring-2 focus:ring-primary/40 transition-all cursor-pointer"
- >
- {[
- { label: 'Todas', value: 'ALL' },
- { label: 'Pendentes', value: 'PENDING' },
- { label: 'Pagas', value: 'PAID' },
- ].map(({ label, value }) => (
-
- ))}
-
-
+
+
+
+ {!allMembers ? (
+ <>
+
+
+
+ >
+ ) : (
+
+ )}
+
- {/* 🎚️ Filtro extra */}
-
-
-
+ {canViewAllExpenses && (
+
+
+
+ )}
- {/* 🧾 Lista */}
+ {/* 📜 Lista */}
{isLoading ? (
-
Carregando despesas...
- ) : filteredExpenses.length > 0 ? (
+
+ Carregando despesas...
+
+ ) : expensesLength > 0 ? (
) : (
- Nenhuma despesa encontrada.
+ Nenhuma despesa encontrada para os filtros selecionados.
)}
+
+ {/* 📄 Paginação */}
+ {totalPages > 1 && (
+
+
setPage(p => (p + 1 < totalPages ? p + 1 : p))}
+ onPrev={() => setPage(p => Math.max(p - 1, 0))}
+ />
+
+ )}
+
+ {/* 🪟 Modal */}
+
setIsOpenModal(false)}
+ setIsOpen={setIsOpenModal}
+ >
+
+ Detalhes da despesa selecionada.
+
+
);
}
diff --git a/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js b/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js
deleted file mode 100644
index 9da71a6..0000000
--- a/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys';
-import { queryClient } from '../../../libs/ReactQuery/queryClient';
-import { getGroupById, getGroupMembers } from '../../../services/groups';
-
-export async function groupDetailsLoader({ params }) {
- const { group_id } = params;
-
- // 🔹 1. Tenta pegar o grupo do cache
- let group = queryClient.getQueryData([
- REACTQUERY_KEYS.GROUPS.DETAILS,
- group_id,
- ]);
-
- // 🔹 2. Se não tiver, busca e cacheia
- if (!group) {
- group = await queryClient.fetchQuery({
- queryKey: [REACTQUERY_KEYS.GROUPS.DETAILS, group_id],
- queryFn: () => getGroupById(group_id),
- });
- }
-
- // 🔹 3. Tenta pegar os membros do cache
- let members = queryClient.getQueryData([
- REACTQUERY_KEYS.GROUPS.MEMBERS,
- group_id,
- ]);
-
- // 🔹 4. Se não tiver no cache, busca e adiciona
- if (!members) {
- members = await queryClient.fetchQuery({
- queryKey: [REACTQUERY_KEYS.GROUPS.MEMBERS, group_id],
- queryFn: () => getGroupMembers(group_id),
- staleTime: Infinity,
- });
- }
-
- // 🔹 5. Combina grupo + membros antes de retornar
- const groupWithMembers = {
- ...group,
- members: members?.members || members || [],
- };
- console.log({ groupWithMembers });
- return groupWithMembers;
-}
diff --git a/frontend/src/pages/Groups/GroupDetails/index.jsx b/frontend/src/pages/Groups/GroupDetails/index.jsx
index 7e7141f..1e51a24 100644
--- a/frontend/src/pages/Groups/GroupDetails/index.jsx
+++ b/frontend/src/pages/Groups/GroupDetails/index.jsx
@@ -1,9 +1,4 @@
-import {
- useLoaderData,
- useLocation,
- useNavigate,
- useParams,
-} from 'react-router';
+import { useNavigate, useParams } from 'react-router';
import { useState } from 'react';
import { FiSettings, FiEdit2, FiTrash2 } from 'react-icons/fi';
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react';
@@ -17,34 +12,21 @@ import ModalButton from '../../../components/Modal/ModalButton';
import { CategoryIcon } from '../../../mockData/groupIcons/icons';
import { ConfirmModal } from '../../../components/Modal';
-import { useDeleteGroupMutation } from '../../../hooks/ReactQuery/useDeleteGroupMutation';
import { customToast } from '../../../components/CustomToast';
-import { ROLES } from '../../../utils/constants';
-function useQueryParams() {
- const { search } = useLocation(); // ex: "?page=0&size=10"
- return new URLSearchParams(search);
-}
+import { usePermissions } from './usePermissions';
+import { useGroupByIdQuery } from '../../../hooks/ReactQuery/Queries/useGroupsQuery';
+import { useDeleteGroupMutation } from '../../../hooks/ReactQuery/Mutations/useDeleteGroupMutation';
export default function GroupDetails() {
- const [isOpenModal, setIsOpenModal] = useState(false);
- const group = useLoaderData();
- const navigate = useNavigate();
const { group_id } = useParams();
+ const [isOpenModal, setIsOpenModal] = useState(false);
+ const { data: group } = useGroupByIdQuery(group_id);
+ const { authorization } = group;
+ const { canEditGroupInfo, canDeleteGroup, canCreateExpenses } =
+ usePermissions(authorization);
const deleteGroup = useDeleteGroupMutation(group_id);
- /* const [showBalances, setShowBalances] = useState(true); */
-
- // classes reutilizáveis
- /* const baseBtn =
- 'cursor-pointer font-medium py-2 px-4 sm:px-6 text-sm sm:text-base rounded-full transition duration-200 focus:outline-none focus:ring-2 focus:ring-opacity-50';
- const activeBtn =
- 'bg-primary text-white hover:bg-primary/90 focus:ring-primary';
- const inactiveBtn =
- 'bg-neutral text-text hover:bg-neutral/80 focus:ring-muted'; */
- /* if (isLoading) return
Carregando grupo...
;
- if (isError) return
Erro ao carregar grupo.
;
- if (!group) return
Grupo não encontrado.
; */
return (
setIsOpenModal(false)}
onConfirm={() => {
- deleteGroup.mutate(undefined, {
- onSuccess: () => {
- navigate('/groups');
- customToast(
- 'Exclusão',
- 'Grupo excluído com sucesso',
- 'success'
- );
- },
- onError: () => {
- customToast('Erro', 'Erro ao tentar excluir o grupo', 'error');
- },
- });
+ deleteGroup.mutate();
}}
confirmLabel="Excluir"
message={`Tem certeza de que deseja excluir o grupo "${group?.name}"? Essa ação não pode ser desfeita.`}
@@ -90,7 +60,7 @@ export default function GroupDetails() {
{/* Menu de ações */}
- {group?.authorization !== ROLES.user && (
+ {canEditGroupInfo && canDeleteGroup && (
diff --git a/frontend/src/pages/Groups/GroupDetails/statusExpense.js b/frontend/src/pages/Groups/GroupDetails/statusExpense.js
new file mode 100644
index 0000000..d16ebb1
--- /dev/null
+++ b/frontend/src/pages/Groups/GroupDetails/statusExpense.js
@@ -0,0 +1,5 @@
+export const EXPENSE_STATUS = {
+ PENDING: 'PENDENTE',
+ UNPAID: 'NÃO PAGO',
+ PAID: 'PAGO',
+};
diff --git a/frontend/src/pages/Groups/GroupDetails/usePermissions.js b/frontend/src/pages/Groups/GroupDetails/usePermissions.js
new file mode 100644
index 0000000..690ce08
--- /dev/null
+++ b/frontend/src/pages/Groups/GroupDetails/usePermissions.js
@@ -0,0 +1,31 @@
+import { hasPermission } from '../../../utils/constants';
+
+export function usePermissions(role) {
+ if (!role) {
+ return {
+ canViewOwnExpenses: false,
+ canViewAllExpenses: false,
+ canCreateExpenses: false,
+ canEditExpenses: false,
+ canEditExpenseValue: false,
+ canDeleteExpenses: false,
+ canManageMembers: false,
+ canEditGroupInfo: false,
+ canDeleteGroup: false,
+ canGenerateReports: false,
+ };
+ }
+
+ return {
+ canViewOwnExpenses: hasPermission(role, 'VIEW_OWN_EXPENSES'),
+ canViewAllExpenses: hasPermission(role, 'VIEW_ALL_EXPENSES'),
+ canCreateExpenses: hasPermission(role, 'CREATE_EXPENSES'),
+ canEditExpenses: hasPermission(role, 'EDIT_EXPENSES'),
+ canEditExpenseValue: hasPermission(role, 'EDIT_EXPENSE_VALUE'),
+ canDeleteExpenses: hasPermission(role, 'DELETE_EXPENSES'),
+ canManageMembers: hasPermission(role, 'MANAGE_MEMBERS'),
+ canEditGroupInfo: hasPermission(role, 'EDIT_GROUP_INFO'),
+ canDeleteGroup: hasPermission(role, 'DELETE_GROUP'),
+ canGenerateReports: hasPermission(role, 'GENERATE_REPORTS'),
+ };
+}
diff --git a/frontend/src/pages/Groups/index.jsx b/frontend/src/pages/Groups/index.jsx
index 36db378..c8a1ebb 100644
--- a/frontend/src/pages/Groups/index.jsx
+++ b/frontend/src/pages/Groups/index.jsx
@@ -1,49 +1,28 @@
import { useState, useMemo } from 'react';
-import { Link } from 'react-router';
+import { Link, useSearchParams } from 'react-router';
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 ModalButton from '../../components/Modal/ModalButton';
-import { useGroupsQuery } from '../../hooks/ReactQuery/useGroupsQuery';
-import useMeQuery from '../../hooks/ReactQuery/useMeQuery';
import { CategoryIcon } from '../../mockData/groupIcons/icons';
-import { useQueries } from '@tanstack/react-query';
-import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys';
-import { getGroupMembers } from '../../services/groups';
import Pagination from '../../components/PaginationController';
+import useMeQuery from '../../hooks/ReactQuery/Queries/useMeQuery';
+import { useGroupsQuery } from '../../hooks/ReactQuery/Queries/useGroupsQuery';
+import useGroupsWithMembers from './useGroupsWithMembers';
export default function Groups() {
+ const [searchParams] = useSearchParams();
+ const createModal = searchParams.has('create');
const { data: user } = useMeQuery();
const [page, setPage] = useState(0);
const membersLengthToShow = 4;
- const { data, isLoading } = useGroupsQuery(page);
- const groups = data?.groups;
- const totalPages = data?.totalPages;
- const groupsLength = data?.groupsLength;
- console.log({ data });
- // Cria queries dinâmicas de membros (cacheadas)
- const membersQueries = useQueries({
- queries: groups?.map(group => ({
- queryKey: [REACTQUERY_KEYS.GROUPS.MEMBERS, group.id],
- queryFn: () => getGroupMembers(group.id),
- enabled: !!group.id,
- staleTime: Infinity, // mantém o cache fresco indefinidamente
- })),
- });
-
- // 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,
- };
- });
-
- console.log({ membersMap });
+ const {
+ data: { groups, totalPages, groupsLength },
+ isLoading,
+ } = useGroupsQuery(page);
+ const membersMap = useGroupsWithMembers(groups);
// Filtros
const [filters, setFilters] = useState({
@@ -73,6 +52,7 @@ export default function Groups() {
{title}
}
className="bg-primary text-white px-4 py-2 rounded-lg shadow hover:bg-primary/90 transition"
/>
diff --git a/frontend/src/pages/Groups/useGroupsWithMembers.jsx b/frontend/src/pages/Groups/useGroupsWithMembers.jsx
new file mode 100644
index 0000000..a20a851
--- /dev/null
+++ b/frontend/src/pages/Groups/useGroupsWithMembers.jsx
@@ -0,0 +1,26 @@
+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)
+ 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
+ })),
+ });
+ // 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,
+ };
+ });
+
+ return membersMap;
+}
diff --git a/frontend/src/pages/Profile/ActiveGroups/index.jsx b/frontend/src/pages/Profile/ActiveGroups/index.jsx
index eac4a61..ec6f63b 100644
--- a/frontend/src/pages/Profile/ActiveGroups/index.jsx
+++ b/frontend/src/pages/Profile/ActiveGroups/index.jsx
@@ -1,36 +1,76 @@
-import { useGroupsQuery } from '../../../hooks/ReactQuery/useGroupsQuery';
import { CategoryIcon } from '../../../mockData/groupIcons/icons';
+import { Link } from 'react-router';
+import { FiUsers } from 'react-icons/fi';
+import { useGroupsQuery } from '../../../hooks/ReactQuery/Queries/useGroupsQuery';
export default function ActiveGroups() {
- const { data: groups } = useGroupsQuery();
+ const { data, isLoading } = useGroupsQuery();
const groupLimit = 4;
- const groupsToShow = groups?.content.slice(0, groupLimit);
- const hasMoreGroups = groups?.totalElements > groupLimit;
+
+ if (isLoading) {
+ return (
+
+
+ Carregando grupos...
+
+ );
+ }
+
+ const groups = data?.groups ?? [];
+ const groupsToShow = groups.slice(0, groupLimit);
+ const hasMoreGroups = groups.length > groupLimit;
+
+ if (groups.length === 0) {
+ return (
+
+
+
+ Você ainda não faz parte de nenhum grupo.
+
+
+ Criar grupo
+
+
+ );
+ }
return (
-
+
);
}
diff --git a/frontend/src/pages/Profile/ProfileContent/index.jsx b/frontend/src/pages/Profile/ProfileContent/index.jsx
index c57f9ee..9085978 100644
--- a/frontend/src/pages/Profile/ProfileContent/index.jsx
+++ b/frontend/src/pages/Profile/ProfileContent/index.jsx
@@ -6,10 +6,11 @@ import { avatarBackgroundColors } from '../../../mockData/colorsPallete/colors';
import { Form } from 'react-router';
import { customToast } from '../../../components/CustomToast';
import { useLogout } from '../../../hooks/useLogout';
-import useMeQuery from '../../../hooks/ReactQuery/useMeQuery';
-import { useUpdateMeMutation } from '../../../hooks/ReactQuery/useUpdateMeMutation';
+
import { meDataSchema } from '../../../schemas/me/request';
import { FaLock, FaUnlock } from 'react-icons/fa';
+import useMeQuery from '../../../hooks/ReactQuery/Queries/useMeQuery';
+import { useUpdateMeMutation } from '../../../hooks/ReactQuery/Mutations/useUpdateMeMutation';
export default function ProfileContent({ setIsModalOpen }) {
const { data: user, isLoading } = useMeQuery();
diff --git a/frontend/src/pages/Profile/index.jsx b/frontend/src/pages/Profile/index.jsx
index 1bb9de9..fac5cb2 100644
--- a/frontend/src/pages/Profile/index.jsx
+++ b/frontend/src/pages/Profile/index.jsx
@@ -16,15 +16,15 @@ import ProfileContent from './ProfileContent';
import { Link } from 'react-router';
import Modal from '../../components/Modal';
-import useMeQuery from '../../hooks/ReactQuery/useMeQuery';
-import { useGroupsQuery } from '../../hooks/ReactQuery/useGroupsQuery';
-import { useMeDashboardQuery } from '../../hooks/ReactQuery/useMeDashboardStatsQuery';
+import useMeQuery from '../../hooks/ReactQuery/Queries/useMeQuery';
+import { useMeDashboardQuery } from '../../hooks/ReactQuery/Queries/useMeDashboardStatsQuery';
+import { useGroupsQuery } from '../../hooks/ReactQuery/Queries/useGroupsQuery';
export default function Profile() {
const { data: user } = useMeQuery();
const { data: dashboard } = useMeDashboardQuery();
- const { data: groups } = useGroupsQuery();
- const groupsLength = groups?.content.length;
+ const { data } = useGroupsQuery();
+ const groupsLength = data?.groupsLength;
const [isModalOpen, setIsModalOpen] = useState(false);
const current_user = userData;
diff --git a/frontend/src/routes/routes.jsx b/frontend/src/routes/routes.jsx
index d5d5c7c..47fcce4 100644
--- a/frontend/src/routes/routes.jsx
+++ b/frontend/src/routes/routes.jsx
@@ -7,7 +7,6 @@ import { groupSettingsLoader } from '../pages/Groups/GroupDetails/GroupSettings/
import { loginLoader } from '../loaders/loginLoader';
import { registerLoader } from '../loaders/registerLoader';
import { protectRoutersLoader } from '../loaders/protectRoutersLoader';
-import { groupDetailsLoader } from '../pages/Groups/GroupDetails/groupDetailsLoader';
// Layout e App carregados normalmente
import Layout from '../components/Layout';
@@ -55,7 +54,6 @@ export const appRouter = createBrowserRouter([
{
path: ':group_id',
element:
,
- loader: groupDetailsLoader,
},
{
path: ':group_id/settings',
@@ -64,6 +62,7 @@ export const appRouter = createBrowserRouter([
},
],
},
+
{ path: 'profile', element:
},
],
},
diff --git a/frontend/src/services/groups.js b/frontend/src/services/groups.js
index 9b96bcd..1efeb62 100644
--- a/frontend/src/services/groups.js
+++ b/frontend/src/services/groups.js
@@ -16,32 +16,22 @@ export const getGroups = async (page = 0, size = 6) => {
}
};
-export const getGroupMembers = async (groupId, size = 4) => {
- try {
- const {
- data: { content: members, totalElements },
- } = await apiApplication.get(`groups/${groupId}/members?size=${size}`);
- console.log({ members });
- return { members, totalElements };
- } catch (error) {
- throw error;
- }
-};
+//Obtém os membros de um grupo
+export const getGroupMembers = async ({ groupId, page, size, search } = {}) => {
+ const query = new URLSearchParams();
+ if (page) query.append('page', page);
+ if (size) query.append('size', size);
+ if (search) query.append('search', search);
-export const getMembers = async (groupId, page, search) => {
- try {
- const query = new URLSearchParams({ page: page.toString() });
- if (search) query.append('search', search);
- const response = await apiApplication.get(
- `/groups/${groupId}/members?${query.toString()}`
- );
- return {
- totalPages: response.data.totalPages,
- members: response.data.content,
- };
- } catch (error) {
- throw error;
- }
+ const { data } = await apiApplication.get(
+ `/groups/${groupId}/members?${query.toString()}`
+ );
+
+ return {
+ members: data.content,
+ totalPages: data.totalPages,
+ totalElements: data.totalElements,
+ };
};
// Cria um novo grupo
@@ -55,6 +45,7 @@ export const createGroup = async group => {
}
};
+//Obter um grupo por id
export const getGroupById = async id => {
try {
const response = await apiApplication
@@ -72,7 +63,7 @@ export const getGroupById = async id => {
throw new Error('Erro ao obter grupo - id:', id);
}
};
-
+//Obter todas as categorias de despesa de um grupo
export const getAllGroupsExpenseCategories = async () => {
try {
const response = await apiApplication.get('/groups/expenses/categories');
@@ -92,6 +83,52 @@ export const updateGroup = async (id, group) => {
// Remove um grupo pelo id
export const deleteGroup = async id => {
- const response = await apiApplication.delete(`/groups/${id}`);
- return response.data;
+ try {
+ const response = await apiApplication.delete(`/groups/${id}`);
+ return response.data;
+ } catch (error) {
+ throw error;
+ }
+};
+
+export const getGroupExpenses = async ({ groupID, page, filter = null }) => {
+ try {
+ const query = new URLSearchParams({ size: 6, page: page.toString() });
+ if (filter !== null) {
+ if (filter) {
+ Object.entries(filter).forEach(([key, value]) => {
+ if (value !== null && value !== undefined && value !== '') {
+ query.append(key, value.toString());
+ }
+ });
+ }
+ }
+
+ const response = await apiApplication.get(
+ `/groups/${groupID}/expenses?${query.toString()}`
+ );
+ return {
+ expenses: response.data.content,
+ totalPages: response.data.totalPages,
+ expensesLength: response.data.totalElements,
+ };
+ } catch (error) {
+ throw error;
+ }
+};
+
+export const getGroupExpenseById = async (group_id, expense_id) => {
+ try {
+ const response = await apiApplication.get(
+ `/groups/${group_id}/expenses/${expense_id}`
+ );
+ return response.data;
+ } catch (error) {
+ console.error(error);
+ throw new Error(
+ 'Erro ao obter uma despesa específica de um grupo - id da despesa e grupo:',
+ expense_id,
+ group_id
+ );
+ }
};
diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js
index 27ced65..da7db19 100644
--- a/frontend/src/utils/constants.js
+++ b/frontend/src/utils/constants.js
@@ -1,5 +1,29 @@
-export const ROLES = {
+const ROLES = {
user: 'USER',
admin: 'ADMIN',
owner: 'OWNER',
};
+
+const PERMISSIONS = {
+ // 🧾 Despesas
+ VIEW_OWN_EXPENSES: [ROLES.user, ROLES.admin, ROLES.owner],
+ VIEW_ALL_EXPENSES: [ROLES.admin, ROLES.owner],
+ EDIT_EXPENSES: [ROLES.admin, ROLES.owner],
+ EDIT_EXPENSE_VALUE: [ROLES.owner],
+ DELETE_EXPENSES: [ROLES.admin, ROLES.owner],
+ CREATE_EXPENSES: [ROLES.admin, ROLES.owner],
+
+ // 👥 Membros
+ MANAGE_MEMBERS: [ROLES.admin, ROLES.owner],
+
+ // 🏠 Grupo
+ EDIT_GROUP_INFO: [ROLES.owner],
+ DELETE_GROUP: [ROLES.owner],
+
+ // 📊 Extras
+ GENERATE_REPORTS: [ROLES.owner],
+};
+
+export function hasPermission(role, action) {
+ return PERMISSIONS[action].includes(role);
+}