diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1b1f385..9a3b36c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,36 +2,13 @@ import { useEffect } from 'react'; import ChartSwitcher from './components/ChartSwitcher'; import LatestExpenses from './components/LatestExpenses'; import SummaryCards from './components/SummaryCards'; -import { getMe } from './services/me'; -import { useLoaderData, useNavigate } from 'react-router'; -import { REACTQUERY_KEYS } from './libs/ReactQuery/keys'; -import { useQuery } from '@tanstack/react-query'; import { useAuthStore } from './context/stores/auth'; function App() { - /* const store = useAuthStore(); - - const loaderData = useLoaderData(); */ - /* const { data } = useQuery({ - queryKey: REACTQUERY_KEYS.USER.ME, - enabled: !storeUser.user, - - initialData: () => { - const { user } = storeUser; - if (Object.entries(user).length > 0) { - storeUser.setUser(user); - return user; - } else { - logout(); - } - }, - queryFn: getMe, - }); */ - - /* useEffect(() => { - console.log(store); - }, []); */ - + const user = useAuthStore(store => store.user); + useEffect(() => { + console.log({ user }); + }, []); return (
diff --git a/frontend/src/actions/groupAction.js b/frontend/src/actions/groupAction.js index 8c072ca..e69de29 100644 --- a/frontend/src/actions/groupAction.js +++ b/frontend/src/actions/groupAction.js @@ -1,43 +0,0 @@ -// src/routes/loginAction.js -import { createGroupFormSchema } from '../schemas/createGroup/form'; -import { z } from 'zod'; -import { createGroup } from '../services/groups'; -import { customToast } from '../components/CustomToast'; -import { queryClient } from '../libs/ReactQuery/queryClient'; -import { REACTQUERY_KEYS } from '../libs/ReactQuery/keys'; - -export const groupAction = async ({ request }) => { - const form = await request.formData(); - const formData = Object.fromEntries(form); - const { success, error, data } = createGroupFormSchema.safeParse(formData); - - if (!success) { - const errors = {}; - if (error instanceof z.ZodError) { - error.issues.forEach(issue => { - const fieldName = issue.path[0]; - const title = - createGroupFormSchema.shape[fieldName]?.description || fieldName; - - return (errors[fieldName] = { - title, - message: issue.message, - }); - }); - } else { - errors.global = 'Ocorreu um erro inesperado.'; - } - - return { success, errors, value: data }; - } - - try { - await createGroup(data); - queryClient.invalidateQueries([REACTQUERY_KEYS.GROUPS.ALL]); - customToast('Grupo criado', 'Grupo criado com sucesso', 'success'); - return { modalClose: true }; - } catch (error) { - customToast('Error', error.message, 'error'); - return { modalClose: false }; - } -}; diff --git a/frontend/src/components/ChartSwitcher/ExpenseChart.jsx b/frontend/src/components/ChartSwitcher/ExpenseChart.jsx index db2411b..1bcb4a6 100644 --- a/frontend/src/components/ChartSwitcher/ExpenseChart.jsx +++ b/frontend/src/components/ChartSwitcher/ExpenseChart.jsx @@ -1,4 +1,4 @@ -import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; +import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'; import { avatarBackgroundColors } from '../../mockData/colorsPallete/colors'; import { useMeDashboardQuery } from '../../hooks/ReactQuery/Queries/useMeDashboardStatsQuery'; diff --git a/frontend/src/components/LatestExpenses/index.jsx b/frontend/src/components/LatestExpenses/index.jsx index 78e1221..ab0287f 100644 --- a/frontend/src/components/LatestExpenses/index.jsx +++ b/frontend/src/components/LatestExpenses/index.jsx @@ -51,7 +51,7 @@ export default function LatestExpenses() {
  • diff --git a/frontend/src/components/Layout/Header.jsx b/frontend/src/components/Layout/Header.jsx index 5f539d5..78bbb47 100644 --- a/frontend/src/components/Layout/Header.jsx +++ b/frontend/src/components/Layout/Header.jsx @@ -1,11 +1,9 @@ import { useAuthStore } from '../../context/stores/auth'; -import { useLogout } from '../../hooks/useLogout'; import LogoImage from '../Logo'; import DropdownMenu from './DropdownMenu'; import { Menu, MenuItem } from '@headlessui/react'; export default function Header() { - const logout = useLogout(); const isAuthenticated = useAuthStore(state => !!state.token); return ( @@ -15,7 +13,7 @@ export default function Header() { - + {isAuthenticated && } ); diff --git a/frontend/src/components/Layout/index.jsx b/frontend/src/components/Layout/index.jsx index 86126c6..3bc56ac 100644 --- a/frontend/src/components/Layout/index.jsx +++ b/frontend/src/components/Layout/index.jsx @@ -13,7 +13,7 @@ export default function Layout() {
    -
    +
    }> diff --git a/frontend/src/components/Modal/index.jsx b/frontend/src/components/Modal/index.jsx index 3857c07..3077d4e 100644 --- a/frontend/src/components/Modal/index.jsx +++ b/frontend/src/components/Modal/index.jsx @@ -5,40 +5,43 @@ import { TransitionChild, } from '@headlessui/react'; import { Fragment, useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import ButtonUI from '../ui/Button'; -import { useActionData, useRevalidator } from 'react-router'; import { customToast } from '../CustomToast'; -export default function Modal({ children, isOpen, setIsOpen, fnClose }) { +export default function Modal({ + children, + isOpen, + setIsOpen, + fnClose, + autoCloseOnSuccess = true, // 👈 opcional +}) { const [showConfirm, setShowConfirm] = useState(false); - const actionData = useActionData(); - const revalidator = useRevalidator(); + const queryClient = useQueryClient(); + // 🔍 Observa mutations do React Query useEffect(() => { - if (actionData?.modalClose) { - setIsOpen(false); - return; - } - if (actionData?.errors) { - const nameField = actionData.errors?.name; - customToast(nameField.title, nameField.message, 'error'); - } else if (actionData?.success) { - const { data: notification } = actionData; - customToast(notification.title, notification.message, 'success'); - setIsOpen(false); - } - revalidator.revalidate(); - }, [actionData]); + if (!autoCloseOnSuccess || !isOpen) return; + + const unsubscribe = queryClient.getMutationCache().subscribe(mutation => { + 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'); + } + }); + + return () => unsubscribe?.(); + }, [autoCloseOnSuccess, isOpen, queryClient]); - // 🚫 não fecha o modal direto — só abre o de confirmação function handleCloseAttempt() { setShowConfirm(true); } - // ✅ agora sim, fecha de fato function handleConfirmClose() { setShowConfirm(false); - fnClose(); // fecha o modal principal + fnClose?.(); } function handleCancelClose() { @@ -47,7 +50,6 @@ export default function Modal({ children, isOpen, setIsOpen, fnClose }) { return ( <> - {/* Modal principal */}
    @@ -90,6 +92,7 @@ export default function Modal({ children, isOpen, setIsOpen, fnClose }) { > x +
    {children}
    @@ -97,7 +100,6 @@ export default function Modal({ children, isOpen, setIsOpen, fnClose }) {
    - {/* Modal de confirmação reutilizável */} { setCheckedMembers(prev => prev.includes(id) ? prev.filter(m => m !== id) : [...prev, id] ); }; - // Debounce para busca useEffect(() => { const handler = setTimeout(() => { setSearch(inputValue.trim()); setPage(0); }, 500); - return () => clearTimeout(handler); - }, [inputValue]); // 👈 apenas inputValue aqui + }, [inputValue]); - // Reseta seleção ao mudar de página ou busca useEffect(() => { setCheckedMembers([]); }, [page, search]); + // 🧠 Ordena a lista: OWNER → ADMIN → USER + const sortedMembers = [...(groupMembers || [])].sort((a, b) => { + const order = { OWNER: 0, ADMIN: 1, USER: 2 }; + return order[a.authority] - order[b.authority]; + }); + return (
    {/* Campo de busca */} @@ -59,25 +61,19 @@ export default function ListMembers({

    Carregando membros...

    - ) : filteredMembers.length > 0 ? ( - filteredMembers.map(member => { + ) : sortedMembers.length > 0 ? ( + sortedMembers.map(member => { const isSelected = checkedMembers.includes(member.id); - return (
    toggleMember(member.id)} - className={` - group flex items-center justify-between gap-3 p-3 rounded-xl cursor-pointer - transition-all duration-200 border - ${ - isSelected - ? 'bg-primary/10 border-primary/40 shadow-sm' - : 'border-transparent hover:bg-primary/5 hover:border-primary/20' - } - `} + className={`group flex items-center justify-between gap-3 p-3 rounded-xl cursor-pointer transition-all duration-200 border ${ + isSelected + ? 'bg-primary/10 border-primary/40 shadow-sm' + : 'border-transparent hover:bg-primary/5 hover:border-primary/20' + }`} > - {/* Avatar + nome */}
    {member.name} - {member.role && ( - - {member.role} + {member.authority && ( + + {member.authority === 'OWNER' + ? 'Dono' + : member.authority === 'ADMIN' + ? 'Administrador' + : 'Membro'} )}
    - {/* Rótulo de seleção */} {isSelected && ( Selecionado @@ -113,7 +120,7 @@ export default function ListMembers({ )}
    - {/* Paginação simples */} + {/* Paginação */} {totalPages > 1 && ( setPage(prev => Math.min(prev + 1, totalPages - 1))} @@ -123,16 +130,14 @@ export default function ListMembers({ /> )} - {/* Botões de ação */} + {/* Botões */}
    - onConfirm( - filteredMembers.filter(m => checkedMembers.includes(m.id)) - ) + onConfirm(sortedMembers.filter(m => checkedMembers.includes(m.id))) } className={`px-6 py-2 rounded-lg font-semibold shadow-sm text-white transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-surface dark:focus:ring-offset-surface-dark bg-primary hover:bg-primary/90 active:bg-primary-dark cursor-pointer ${ checkedMembers.length === 0 diff --git a/frontend/src/components/forms/ExpenseForm/index.jsx b/frontend/src/components/forms/ExpenseForm/index.jsx index 4c85fcd..a4658d9 100644 --- a/frontend/src/components/forms/ExpenseForm/index.jsx +++ b/frontend/src/components/forms/ExpenseForm/index.jsx @@ -8,27 +8,16 @@ import { customToast } from '../../CustomToast'; import Modal from '../../Modal'; import ListMembers from './ListMembers'; import { FiUserPlus, FiX } from 'react-icons/fi'; -import useMeQuery from '../../../hooks/ReactQuery/Queries/useMeQuery'; + import { useCreateExpenseMutation } from '../../../hooks/ReactQuery/Mutations/useCreateExpenseMutation'; import { useMembersQuery } from '../../../hooks/ReactQuery/Queries/useMembersQuery'; export default function ExpenseForm({ groupData: group }) { const [search, setSearch] = useState(''); // termo final para API - const { data: user } = useMeQuery(); const useExpenseMutation = useCreateExpenseMutation(group?.id); const { data: { members: groupMembers = [], totalPages } = {}, isLoading } = useMembersQuery(group?.id, 0, search); - const excludeSelf = true; // 🔧 se quiser incluir o próprio usuário, basta trocar para false - - // 🔍 Aplica filtro de exclusão do próprio usuário - const filteredMembers = useMemo(() => { - if (!groupMembers?.length) return []; - return excludeSelf - ? groupMembers.filter(m => m.id !== user?.id) - : groupMembers; - }, [groupMembers, user, excludeSelf]); - const { setMembers, amount, divisionAmount, reset, getRemainingDifference } = useFormExpense(); @@ -76,7 +65,7 @@ export default function ExpenseForm({ groupData: group }) { expenseDivision: Object.entries(divisionAmount).map( ([memberId, val]) => ({ id: Number(memberId), - value: val.float, + value: Number(val.float.toFixed(2)), }) ), }; @@ -133,11 +122,11 @@ export default function ExpenseForm({ groupData: group }) { aria-label="Formulário para adicionar nova despesa" > {/* Campos principais */} - + {/* Seleção de membros */}
    - {selectedMembers.length === 0 && filteredMembers.length > 0 && ( + {selectedMembers.length === 0 && groupMembers.length > 0 && ( setShowMemberModal(true)} @@ -202,7 +191,7 @@ export default function ExpenseForm({ groupData: group }) { {/* Modal de seleção */} setShowMemberModal(false)}> { setSelectedMembers(selected); diff --git a/frontend/src/components/forms/GroupForm.jsx b/frontend/src/components/forms/GroupForm.jsx index 4a7a606..189e8f6 100644 --- a/frontend/src/components/forms/GroupForm.jsx +++ b/frontend/src/components/forms/GroupForm.jsx @@ -1,29 +1,64 @@ import { useState } from 'react'; -import { Form, useNavigation, useActionData } from 'react-router'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import InputUI from '../ui/Input'; import ButtonUI from '../ui/Button'; import TextareaUI from '../ui/Textarea'; import { categoryIcons } from '../../mockData/groupIcons/icons'; +import { createGroup } from '../../services/groups'; +import { Form } from 'react-router'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { customToast } from '../CustomToast'; -export default function GroupForm() { - const navigation = useNavigation(); - const actionData = useActionData(); - const isSubmitting = navigation.state === 'submitting'; - const values = actionData?.values || {}; +export default function GroupForm({ page }) { + const queryClient = useQueryClient(); - const [groupName, setGroupName] = useState(values.name || ''); - const [selectedIcon, setSelectedIcon] = useState(values.icon || 'outros'); + const [groupName, setGroupName] = useState(''); + const [description, setDescription] = useState(''); + const [selectedIcon, setSelectedIcon] = useState('outros'); - const isDisabled = isSubmitting || groupName.trim() === ''; + const { mutateAsync, isPending } = useMutation({ + mutationFn: createGroup, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'page', page], + }); + setGroupName(''); + setDescription(''); + setSelectedIcon('outros'); + customToast( + 'Cadastro de um novo grupo', + 'Grupo cadastrado com sucesso', + 'success' + ); + }, + onError: () => { + customToast( + 'Cadstro de um novo grupo', + 'Erro ao cadastrar um novo grupo', + 'error' + ); + }, + }); + + const isDisabled = isPending || groupName.trim() === ''; - // Pega o ícone do objeto ou fallback "outros" const entry = categoryIcons[selectedIcon] || categoryIcons.outros; const IconComponent = entry.icon; const iconColor = entry.color; + const handleSubmit = async e => { + e.preventDefault(); + + mutateAsync({ + name: groupName, + description, + icon: selectedIcon, + }); + }; + return (
    {/* Título */} @@ -36,19 +71,17 @@ export default function GroupForm() {

    - {/* Nome + Preview do ícone */} + {/* Nome + Ícone preview */}
    - {/* Preview do ícone */}
    - {/* Campo de texto */} setGroupName(e.target.value)} placeholder="Ex: Família, Viagem Cancun..." - className="flex-1 px-4 py-2 rounded-lg bg-neutral dark:bg-neutral-dark border border-zinc-300 dark:border-surface-dark text-text dark:text-text-dark placeholder:text-muted dark:placeholder:text-muted-dark focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all duration-200" + className="flex-1" />
    @@ -71,17 +104,16 @@ export default function GroupForm() { id="description" name="description" rows={4} - defaultValue={values.description || ''} + value={description} + onChange={e => setDescription(e.target.value)} + className="w-full min-h-[100px] rounded-lg border border-border bg-background px-3 py-2 text-text shadow-sm transition-all duration-200 focus:border-primary focus:ring-2 focus:ring-primary/30 placeholder:text-muted resize-none" placeholder="Opcional: Adicione uma descrição para o grupo..." - className="px-4 py-2 rounded-lg bg-neutral dark:bg-neutral-dark border border-zinc-300 dark:border-surface-dark text-text dark:text-text-dark placeholder:text-muted dark:placeholder:text-muted-dark focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-none transition-all duration-200" />
    - {/* Seleção de ícone */} + {/* Ícones */}
    - -
    {Object.keys(categoryIcons).map(key => { const { icon: Icon, color } = categoryIcons[key]; @@ -110,18 +142,18 @@ export default function GroupForm() { )}
    - {/* Botão submit */} -
    + {/* Botão */} +
    - {isSubmitting ? 'Criando...' : 'Criar grupo'} + {isPending ? 'Criando...' : 'Criar grupo'}
    diff --git a/frontend/src/context/stores/auth.js b/frontend/src/context/stores/auth.js index 6195f6d..2183636 100644 --- a/frontend/src/context/stores/auth.js +++ b/frontend/src/context/stores/auth.js @@ -151,3 +151,24 @@ 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/hooks/ReactQuery/Mutations/useCreateExpenseMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useCreateExpenseMutation.js index cb0437e..22d6795 100644 --- a/frontend/src/hooks/ReactQuery/Mutations/useCreateExpenseMutation.js +++ b/frontend/src/hooks/ReactQuery/Mutations/useCreateExpenseMutation.js @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { createExpense } from '../../../services/expenses'; import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { createExpense } from '../../../services/expenses'; export function useCreateExpenseMutation(groupID) { const queryClient = useQueryClient(); @@ -8,24 +8,30 @@ export function useCreateExpenseMutation(groupID) { return useMutation({ mutationFn: expenseData => createExpense(groupID, expenseData), - onSuccess: () => { - // 🔄 Invalida TODAS as queries de despesas desse grupo + onSuccess: (_response, expenseData) => { + console.log({ expenseData, groupID }); + // 1. Invalida os detalhes do Grupo ESPECÍFICO (passando o ID) queryClient.invalidateQueries({ - predicate: query => { - const [key, params] = query.queryKey; - - return ( - key === REACTQUERY_KEYS.GROUPS.EXPENSES && - typeof params === 'object' && - params?.groupID === groupID - ); - }, + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail', Number(groupID)], }); - // 🔄 Atualiza listagem geral de grupos + // OU: + // 1. Invalida QUALQUER detalhe de grupo + // queryClient.invalidateQueries({ + // queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail'], + // }); + + // 2. Invalida as queries de EXPENSES com 'filters' + // Certifique-se de que a queryKey de leitura para despesas está correta. + // Se ela usa o groupID, inclua o ID aqui: queryClient.invalidateQueries({ - queryKey: [REACTQUERY_KEYS.GROUPS.ALL], + queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES, 'filters', Number(groupID)], }); + + // Se a chave de despesas é genérica: + // queryClient.invalidateQueries({ + // queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES], + // }); }, }); } diff --git a/frontend/src/hooks/ReactQuery/Mutations/useDeleteExpenseByIdMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useDeleteExpenseByIdMutation.js new file mode 100644 index 0000000..91b1573 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useDeleteExpenseByIdMutation.js @@ -0,0 +1,42 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; +import { deleteGroupExpense } from '../../../services/groups'; + +export function useDeleteExpenseByIdMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ group_id, expense_id }) => { + return await deleteGroupExpense(group_id, expense_id); + }, + onSuccess: (_data, { group_id }) => { + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES], + }); + 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'], + }); + + customToast( + 'Despesa removida', + 'A despesa foi excluída com sucesso do grupo.', + 'success' + ); + }, + onError: () => { + customToast( + 'Erro ao excluir despesa', + 'Não foi possível excluir a despesa. Tente novamente.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMemberMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMemberMutation.js new file mode 100644 index 0000000..13d80e9 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMemberMutation.js @@ -0,0 +1,34 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; +import { deleteGroupMember } from '../../../services/groups'; + +export function useDeleteGroupMemberMutation(groupID, page, search) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ group_id, member_id }) => { + return await deleteGroupMember(group_id, member_id); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.MEMBERS.ALL], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.MEMBERS], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL], + }); + customToast('Membro removido', 'Membro removido com sucesso.', 'success'); + }, + onError: () => { + customToast( + 'Erro ao excluir o membro do grupo', + 'Não foi possível excluir o membro do grupo. Tente novamente.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMutation.js index 2bf8573..da3d735 100644 --- a/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMutation.js +++ b/frontend/src/hooks/ReactQuery/Mutations/useDeleteGroupMutation.js @@ -13,16 +13,15 @@ export function useDeleteGroupMutation(id) { onSuccess: () => { // Invalida a lista de grupos para forçar um refetch queryClient.invalidateQueries({ - predicate: query => - query.queryKey[0] === REACTQUERY_KEYS.GROUPS.ALL && - query.queryKey[1]?.groupID === id, // só o id importa + queryKey: [REACTQUERY_KEYS.GROUPS.ALL], }); queryClient.invalidateQueries({ - predicate: query => - query.queryKey[0] === REACTQUERY_KEYS.GROUPS.EXPENSES && - query.queryKey[1]?.groupID === id, // só o id importa + queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES], }); - queryClient.invalidateQueries([REACTQUERY_KEYS.GROUPS.MEMBERS, id]); + queryClient.invalidateQueries([ + REACTQUERY_KEYS.GROUPS.MEMBERS, + Number(id), + ]); // Opcional: remover manualmente do cache local queryClient.setQueryData([REACTQUERY_KEYS.GROUPS.ALL], oldData => { diff --git a/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupExpenseByIdMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupExpenseByIdMutation.js index e347379..872579c 100644 --- a/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupExpenseByIdMutation.js +++ b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupExpenseByIdMutation.js @@ -1,3 +1,44 @@ -export default function useUpdateGroupExpenseByIdMutation() { - return; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { updateExpenseDetails } from '../../../services/groups'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; + +export function useUpdateGroupExpenseByIdMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + // 🔧 Recebe os parâmetros e o payload com os novos dados + mutationFn: async ({ group_id, expense_id, data }) => { + return await updateExpenseDetails(group_id, expense_id, data); + }, + + // ✅ Atualiza o cache após sucesso + onSuccess: (_response, { group_id, expense_id }) => { + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.USER.EXPENSES, 'me'], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail', Number(group_id)], + }); + + customToast( + 'Despesa atualizada com sucesso', + 'As informações da despesa foram salvas corretamente.', + 'success' + ); + }, + + // ⚠️ Exibe mensagem de erro, caso algo dê errado + onError: error => { + console.error('Erro ao atualizar despesa:', error); + customToast( + 'Erro ao atualizar despesa', + 'Não foi possível salvar as alterações. Tente novamente.', + 'error' + ); + }, + }); } diff --git a/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMemberRoleMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMemberRoleMutation.js new file mode 100644 index 0000000..71bce72 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMemberRoleMutation.js @@ -0,0 +1,40 @@ +updateRoleGroupMember; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { updateRoleGroupMember } from '../../../services/groups'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; + +export function useUpdateRoleGroupMemberMutation(groupID, page, search) { + const queryClient = useQueryClient(); + + return useMutation({ + // 🔧 Recebe os parâmetros e o payload com os novos dados + mutationFn: async ({ group_id, member_id, newRole }) => { + return await updateRoleGroupMember(group_id, member_id, newRole); + }, + + // ✅ Atualiza o cache após sucesso + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: search + ? [REACTQUERY_KEYS.MEMBERS.ALL, Number(groupID), page, search] + : [REACTQUERY_KEYS.MEMBERS.ALL, Number(groupID), page], + }); + customToast( + 'Despesa atualizada com sucesso', + 'As informações da despesa foram salvas corretamente.', + 'success' + ); + }, + + // ⚠️ Exibe mensagem de erro, caso algo dê errado + onError: error => { + console.error('Erro ao definir um novo cargo para o membro:', error); + customToast( + 'Erro ao atualizar membro para um novo cargo', + 'Não foi possível salvar as alterações. Tente novamente.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMutation.js new file mode 100644 index 0000000..1c571a5 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useUpdateGroupMutation.js @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { updateGroup } from '../../../services/groups'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; + +export function useUpdateGroupMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + // 🔧 Recebe os parâmetros e o payload com os novos dados + mutationFn: async ({ group_id, data }) => { + return await updateGroup(group_id, data); + }, + + // ✅ Atualiza o cache após sucesso + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL], + }); + + customToast( + 'Grupo atualizado!', + 'As informações do grupo foram alteradas com sucesso.', + 'success' + ); + }, + + // ⚠️ Exibe mensagem de erro, caso algo dê errado + onError: error => { + console.error('Erro ao atualizar dados do grupo:', error); + customToast( + 'Erro ao atualizar grupo', + 'Não foi possível salvar as alterações. Tente novamente.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js new file mode 100644 index 0000000..bd48eeb --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { updatePartialValueExpenseStatus } from '../../../services/groups'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; + +export function useUpdateStatusExpensePartialValueMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ group_id, expense_id, member_id }) => { + return await updatePartialValueExpenseStatus( + group_id, + expense_id, + member_id + ); + }, + onSuccess: (updatedMember, { group_id, expense_id, member_id }) => { + queryClient.invalidateQueries({ + queryKey: [ + REACTQUERY_KEYS.GROUPS.EXPENSES, + 'groups', + Number(group_id), + expense_id, + ], + }); + customToast( + 'Status atualizado', + 'O status da despesa do participante foi alterado com sucesso.', + 'success' + ); + }, + onError: () => { + customToast( + 'Erro ao atualizar', + 'Não foi possível alterar o status da despesa. Tente novamente.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Queries/useGroupExpenseByIdQuery.js b/frontend/src/hooks/ReactQuery/Queries/useGroupExpenseByIdQuery.js index 89bc767..bce171b 100644 --- a/frontend/src/hooks/ReactQuery/Queries/useGroupExpenseByIdQuery.js +++ b/frontend/src/hooks/ReactQuery/Queries/useGroupExpenseByIdQuery.js @@ -4,22 +4,15 @@ import { getGroupExpenseById } from '../../../services/groups'; export function useGroupExpenseByIdQuery(group_id, expense_id) { return useQuery({ - queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES, { group_id, expense_id }], + queryKey: [ + REACTQUERY_KEYS.GROUPS.EXPENSES, + 'groups', + Number(group_id), + expense_id, + ], enabled: !!group_id && !!expense_id, queryFn: async () => await getGroupExpenseById(group_id, expense_id), staleTime: Infinity, - placeholderData: { - expenseId: null, - title: '', - description: '', - groupId: null, - groupName: '', - status: 'PENDING', - total: 0, - categoryId: null, - categoryName: '', - createdAt: new Date().toISOString(), - memberList: [], - }, + placeholderData: previousData => previousData ?? [], }); } diff --git a/frontend/src/hooks/ReactQuery/Queries/useGroupExpensesQuery.js b/frontend/src/hooks/ReactQuery/Queries/useGroupExpensesQuery.js index 8a2ad91..6519da1 100644 --- a/frontend/src/hooks/ReactQuery/Queries/useGroupExpensesQuery.js +++ b/frontend/src/hooks/ReactQuery/Queries/useGroupExpensesQuery.js @@ -14,23 +14,20 @@ export function useGroupExpensesQuery(groupID, page, filter) { // forçando o TanStack Query a fazer uma nova busca (refetch). queryKey: [ REACTQUERY_KEYS.GROUPS.EXPENSES, - { - groupID, - page, - status, // Chave do filtro 1 - allGroupMembersExpenses, // Chave do filtro 2 (se existir) - }, + 'filters', + Number(groupID), + page, + status, // Chave do filtro 1 + allGroupMembersExpenses, // Chave do filtro 2 (se existir) + , ], + // 3. A queryFn continua usando o objeto 'filter' completo para a chamada de API queryFn: async () => await getGroupExpenses({ groupID, page, filter }), - + staleTime: Infinity, // Configurações mantidas enabled: !!groupID, - staleTime: Infinity, - placeholderData: { - expenses: [], - totalPages: 0, - expensesLength: 0, - }, + /* staleTime: Infinity, */ + placeholderData: previousData => previousData, }); } diff --git a/frontend/src/hooks/ReactQuery/Queries/useGroupsQuery.js b/frontend/src/hooks/ReactQuery/Queries/useGroupsQuery.js index 2993e5d..9271015 100644 --- a/frontend/src/hooks/ReactQuery/Queries/useGroupsQuery.js +++ b/frontend/src/hooks/ReactQuery/Queries/useGroupsQuery.js @@ -2,9 +2,9 @@ import { useQuery } from '@tanstack/react-query'; import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; import { getGroupById, getGroups } from '../../../services/groups'; -export function useGroupsQuery(page) { +export function useGroupsQuery(page = 0) { return useQuery({ - queryKey: [REACTQUERY_KEYS.GROUPS.ALL, { page }], + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'page', page], queryFn: async () => await getGroups(page), staleTime: Infinity, placeholderData: { @@ -17,7 +17,7 @@ export function useGroupsQuery(page) { export function useGroupByIdQuery(id) { return useQuery({ - queryKey: [REACTQUERY_KEYS.GROUPS.ALL, id], + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail', Number(id)], enabled: !!id, queryFn: async () => await getGroupById(id), staleTime: Infinity, diff --git a/frontend/src/hooks/ReactQuery/Queries/useMeExpensesQuery.js b/frontend/src/hooks/ReactQuery/Queries/useMeExpensesQuery.js index 012cb6a..73a401f 100644 --- a/frontend/src/hooks/ReactQuery/Queries/useMeExpensesQuery.js +++ b/frontend/src/hooks/ReactQuery/Queries/useMeExpensesQuery.js @@ -7,9 +7,9 @@ export default function useMeExpensesQuery(page) { const { data: user } = useMeQuery(); const userId = user?.id; return useQuery({ - queryKey: [REACTQUERY_KEYS.USER.EXPENSES, { userId, page }], + queryKey: [REACTQUERY_KEYS.USER.EXPENSES, 'me', Number(userId), page], queryFn: async () => await getMeExpenses(page), enabled: !!userId, - placeholderData: { expenses: [], totalPages: 0, expensesLength: 0 }, + placeholderData: previousData => previousData, }); } diff --git a/frontend/src/hooks/ReactQuery/Queries/useMembersQuery.js b/frontend/src/hooks/ReactQuery/Queries/useMembersQuery.js index 1ce4603..834fb2c 100644 --- a/frontend/src/hooks/ReactQuery/Queries/useMembersQuery.js +++ b/frontend/src/hooks/ReactQuery/Queries/useMembersQuery.js @@ -6,8 +6,8 @@ import { getGroupMembers } from '../../../services/groups'; export const useMembersQuery = (groupID, page, search) => { return useQuery({ queryKey: search - ? [REACTQUERY_KEYS.MEMBERS.ALL, groupID, page, search] - : [REACTQUERY_KEYS.MEMBERS.ALL, groupID, page], + ? [REACTQUERY_KEYS.MEMBERS.ALL, 'page', Number(groupID), page, search] + : [REACTQUERY_KEYS.MEMBERS.ALL, 'page', Number(groupID), page], queryFn: () => getGroupMembers({ groupId: groupID, page, search }), placeholderData: [], enabled: !!groupID, diff --git a/frontend/src/hooks/useLogout.js b/frontend/src/hooks/useLogout.js index a059b66..c221134 100644 --- a/frontend/src/hooks/useLogout.js +++ b/frontend/src/hooks/useLogout.js @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router'; -import { useAuthStore } from '../context/stores/auth'; +import { useAuthorityStore, useAuthStore } from '../context/stores/auth'; import { localStoragePersistor, queryClient, @@ -8,12 +8,16 @@ 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/libs/ReactQuery/keys.js b/frontend/src/libs/ReactQuery/keys.js index db5d289..9f0ba4b 100644 --- a/frontend/src/libs/ReactQuery/keys.js +++ b/frontend/src/libs/ReactQuery/keys.js @@ -8,6 +8,7 @@ export const REACTQUERY_KEYS = { ALL: 'allGroups', MEMBERS: 'members', EXPENSES: 'groupExpenses', + ME_AUTHORITY: 'authority', }, EXPENSES: { CATEGORIES: 'allCategories', diff --git a/frontend/src/libs/ReactQuery/queryClient.js b/frontend/src/libs/ReactQuery/queryClient.js index 0ca175c..27db5f0 100644 --- a/frontend/src/libs/ReactQuery/queryClient.js +++ b/frontend/src/libs/ReactQuery/queryClient.js @@ -7,7 +7,7 @@ const refetchInterval = 180000; // 3 minutos export const queryClient = new QueryClient({ defaultOptions: { queries: { - retry: 1, + retry: false, refetchInterval, refetchOnWindowFocus: true, refetchOnReconnect: true, diff --git a/frontend/src/pages/Expenses/ExpenseDetails/index.jsx b/frontend/src/pages/Expenses/ExpenseDetails/index.jsx new file mode 100644 index 0000000..3bbb3c6 --- /dev/null +++ b/frontend/src/pages/Expenses/ExpenseDetails/index.jsx @@ -0,0 +1,367 @@ +import { useState, useEffect } from 'react'; +import { TbPigMoney } from 'react-icons/tb'; +import { + EXPENSE_STATUS, + STATUS_COLORS, +} from '../../Groups/GroupDetails/statusExpense'; +import { Form, useNavigate, useParams } from 'react-router'; +import { formatBRL, formatDateBR } from '../../../utils/formatters'; +import InputUI from '../../../components/ui/Input'; +import TextareaUI from '../../../components/ui/Textarea'; +import ButtonUI from '../../../components/ui/Button'; +import SelectUI from '../../../components/ui/Select'; +import { useGroupExpenseByIdQuery } from '../../../hooks/ReactQuery/Queries/useGroupExpenseByIdQuery'; +import { useAllGroupExpenseCategoriesQuery } from '../../../hooks/ReactQuery/Queries/useAllGroupExpenseCategoriesQuery'; +import { useUpdateStatusExpensePartialValueMutation } from '../../../hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation'; +import { useUpdateGroupExpenseByIdMutation } from '../../../hooks/ReactQuery/Mutations/useUpdateGroupExpenseByIdMutation'; +import { useDeleteExpenseByIdMutation } from '../../../hooks/ReactQuery/Mutations/useDeleteExpenseByIdMutation'; +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'; + +export default function ExpenseDetails() { + const { getAuthority } = useAuthorityStore(); + const [isOpenConfirmModal, setIsOpenConfirmModal] = useState(false); + const navigate = useNavigate(); + const { group_id, expense_id } = useParams(); + + 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: categories } = useAllGroupExpenseCategoriesQuery(); + const { mutateAsync, isPending: isLoading } = + useUpdateStatusExpensePartialValueMutation(); + const { mutateAsync: updateExpense, isPending } = + useUpdateGroupExpenseByIdMutation(); + const { mutateAsync: deleteExpense, isPending: isDeleting } = + useDeleteExpenseByIdMutation(); + + const [formData, setFormData] = useState({ + title: '', + description: '', + categoryId: '', + deadlineDate: '', + }); + + useEffect(() => { + if (expense) { + setFormData({ + title: expense.title ?? '', + description: expense.description ?? '', + categoryId: expense.categoryId ?? '', + deadlineDate: expense.deadlineDate ?? '', + }); + } + }, [expense]); + + if (isLoadingExpense) + return

    Carregando...

    ; + + const color = STATUS_COLORS[expense?.status] || STATUS_COLORS.DEFAULT; + + function handleChange(e) { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + } + + async function handleSubmit(e) { + e.preventDefault(); + if (!canEditExpense) return; // bloqueia edição + + try { + const data = { + title: formData.title, + description: formData.description, + deadlineDate: formData.deadlineDate, + categoryId: formData.categoryId, + }; + const validation = validateExpensePayload(data); + if (!validation.success) { + Object.entries(validation.errors).forEach(([title, message]) => + customToast(title, message, 'error') + ); + return; + } + await updateExpense({ group_id, expense_id, data }); + } catch (error) { + console.error(error); + } + } + + async function handleChangeStatusExpensePartialValue(member_id) { + if (!canChangeStatus) return; // bloqueia alteração de status + try { + const data = { group_id, expense_id, member_id }; + await mutateAsync(data); + } catch (error) { + console.error(error); + } + } + + return ( +
    + {/* Cabeçalho */} +
    +
    +
    + +
    + +
    +
    +

    {expense.title}

    + + {EXPENSE_STATUS[expense.status] || 'DESCONHECIDO'} + +
    + + {/* Progresso de pagamento */} + {expense.memberList?.length > 0 && + (() => { + const totalPago = expense.memberList + .filter(m => m.status === 'PAID') + .reduce((acc, m) => acc + m.partialValue, 0); + const total = expense.total || 0; + const porcentagem = total > 0 ? (totalPago / total) * 100 : 0; + const allPaid = totalPago >= total; + + return ( +
    +
    + + {formatBRL(totalPago)} / {formatBRL(total)} + + + {allPaid + ? 'Pago totalmente ✅' + : `${Math.floor(porcentagem)}% pago`} + +
    + +
    +
    +
    +
    + ); + })()} +
    +
    + +

    + Criado em{' '} + + {formatDateBR(expense.createdAt)} + +

    +
    + + {/* Informações */} +
    +

    + Informações da Despesa +

    + +
    +
    +

    Grupo

    +

    + {expense.groupName} +

    +
    + +
    +

    Total

    +

    + {formatBRL(expense.total)} +

    +
    + +
    +

    Categoria Atual

    +

    + {expense.categoryName} +

    +
    +
    +
    + + {/* Campos editáveis */} + {canEditExpense && ( +
    +

    + ✏️ Editar informações da despesa +

    + +
    + + + + +
    +
    + + + +
    +
    + ({ + value: c.id, + label: c.name, + }))} + > + {categories.map(({ id, name }) => ( + + ))} + +
    +
    + +
    + + {isPending ? 'Enviando...' : 'Salvar alterações'} + + + setIsOpenConfirmModal(true)} + className="px-6 py-2 font-semibold rounded-lg shadow-md bg-error text-white hover:bg-red-500 transition-all hover:scale-[1.02] active:scale-95 cursor-pointer" + > + {isDeleting ? 'Excluindo...' : 'Excluir despesa'} + + + setIsOpenConfirmModal(false)} + onConfirm={async () => { + try { + await deleteExpense( + { group_id, expense_id }, + { onSuccess: () => navigate(`/groups/${group_id}`) } + ); + } catch (error) { + console.error(error); + } + }} + cancelLabel="Voltar" + confirmLabel="Excluir" + message="Tem certeza que deseja excluir esta despesa? Essa ação não pode ser desfeita." + /> +
    +
    +
    + )} + + {/* Participantes */} +
    +

    Participantes

    + +
    + {expense.memberList?.map(member => { + const status = member.status; + const statusMap = { + PAID: { label: 'Pago' }, + PENDING: { label: 'Aguardando pagamento' }, + UNPAID: { label: 'Não pago' }, + }; + const currentStatus = statusMap[status] || statusMap.UNPAID; + + return ( +
    +
    +

    {member.userName}

    +

    + {formatBRL(member.partialValue)} —{' '} + + {currentStatus.label} + +

    +
    + + + 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}`} + 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 03a7d73..76fc5e0 100644 --- a/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx +++ b/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx @@ -5,19 +5,17 @@ import { Link } from 'react-router'; import SelectUI from '../../../components/ui/Select'; import CheckboxUI from '../../../components/ui/Checkbox'; -import ButtonUI from '../../../components/ui/Button'; + import Pagination from '../../../components/PaginationController'; -import Modal from '../../../components/Modal'; import { usePermissions } from './usePermissions'; import { useGroupExpensesQuery } from '../../../hooks/ReactQuery/Queries/useGroupExpensesQuery'; -import { EXPENSE_STATUS } from './statusExpense'; +import { EXPENSE_STATUS, STATUS_COLORS } from './statusExpense'; export default function ExpensesList({ groupID, authorization }) { const [page, setPage] = useState(0); const [status, setStatus] = useState('PENDING'); const [allMembers, setAllMembers] = useState(false); - const [isOpenModal, setIsOpenModal] = useState(false); const { canViewAllExpenses } = usePermissions(authorization); @@ -58,14 +56,27 @@ export default function ExpensesList({ groupID, authorization }) { aria-labelledby="expenses-heading" > {/* 🧭 Cabeçalho */} -
    -

    - - Despesas do Grupo -

    +
    +
    +
    + +
    + +
    +

    + Despesas do Grupo +

    +

    + {allMembers ? 'De todos os membros' : 'Suas despesas'} +

    +
    +
    + + {/* Espaço opcional para botões, filtros, etc. */} + {/* Nova despesa */}
    {/* 🔍 Filtros */} @@ -74,19 +85,12 @@ export default function ExpensesList({ groupID, authorization }) { - {!allMembers ? ( - <> - - - - - ) : ( - - )} + + +
    @@ -109,26 +113,22 @@ export default function ExpensesList({ groupID, authorization }) { ) : expensesLength > 0 ? (
      {expenses.map(expense => { - const COLOR_STATUS = - expense.status === 'PAID' - ? 'bg-success text-green-900' - : expense.status === 'PENDING' - ? 'bg-warning text-yellow-900' - : expense.status === 'UNPAID' - ? 'bg-error text-red-900' - : 'text-text'; + const color = + STATUS_COLORS[expense.status] || STATUS_COLORS.DEFAULT; + return (
    • - setIsOpenModal(true)} - className="relative flex w-full justify-between items-center px-4 py-3 rounded-xl bg-muted/20 hover:bg-muted/40 transition-colors border border-border/10 cursor-pointer" + + {/* 🏷️ Título e status */}
      {expense.title} - {expense.status !== null && ( + {expense.status && ( {EXPENSE_STATUS[expense.status]} @@ -136,12 +136,20 @@ export default function ExpensesList({ groupID, authorization }) {
      + {/* 💰 Valor e informações */}
      {formatBRL(expense.partialValue || expense.total)} + + + {expense.partialValue + ? 'Sua parte na despesa' + : 'Valor total da despesa'} + + Prazo final:{' '} {expense.deadlineDate @@ -151,7 +159,7 @@ export default function ExpensesList({ groupID, authorization }) { : 'Sem prazo'}
      -
      +
    • ); })} @@ -173,17 +181,6 @@ export default function ExpensesList({ groupID, authorization }) { />
    )} - - {/* 🪟 Modal */} - setIsOpenModal(false)} - setIsOpen={setIsOpenModal} - > -
    - Detalhes da despesa selecionada. -
    -
    ); } diff --git a/frontend/src/pages/Groups/GroupDetails/GroupSettings/MembersManager.jsx b/frontend/src/pages/Groups/GroupDetails/GroupSettings/MembersManager.jsx new file mode 100644 index 0000000..0a4a2f4 --- /dev/null +++ b/frontend/src/pages/Groups/GroupDetails/GroupSettings/MembersManager.jsx @@ -0,0 +1,232 @@ +import { FiTrash2, FiChevronDown, FiUser, FiStar } from 'react-icons/fi'; +import { BiSolidCrown } from 'react-icons/bi'; +import { Menu, MenuButton, MenuItems } from '@headlessui/react'; +import { useState, useDeferredValue } from 'react'; +import InputUI from '../../../../components/ui/Input'; +import ButtonUI from '../../../../components/ui/Button'; +import Pagination from '../../../../components/PaginationController'; +import { useMembersQuery } from '../../../../hooks/ReactQuery/Queries/useMembersQuery'; +import { ConfirmModal } from '../../../../components/Modal'; +import { useUpdateRoleGroupMemberMutation } from '../../../../hooks/ReactQuery/Mutations/useUpdateGroupMemberRoleMutation'; +import { useDeleteGroupMemberMutation } from '../../../../hooks/ReactQuery/Mutations/useDeleteGroupMemberMutation'; + +export function MembersManager({ group }) { + const [confirmModal, setConfirmModal] = useState({ + isOpen: false, + message: '', + memberId: null, + newRole: null, + }); + + const [page, setPage] = useState(0); + const [search, setSearch] = useState(''); + const { mutateAsync } = useUpdateRoleGroupMemberMutation( + group.id, + page, + search + ); + const { mutateAsync: deleteMutateAsync } = useDeleteGroupMemberMutation(); + const deferredSearch = useDeferredValue(search); + + const { data, isLoading } = useMembersQuery(group?.id, page, deferredSearch); + + // Extrai dados da query + const membersRaw = data?.members ?? []; + const totalPages = data?.totalPages ?? 0; + const size = data?.size ?? 0; + + // ✅ Coloca o dono do grupo no topo da lista + const members = [...membersRaw].sort((a, b) => { + if (a.authority === 'OWNER') return -1; + if (b.authority === 'OWNER') return 1; + return 0; + }); + + // Atualiza o cargo do membro no servidor + const handleChangeRole = async (memberId, newRole) => { + try { + const payload = { group_id: group?.id, member_id: memberId, newRole }; + await mutateAsync(payload); + } catch (err) { + console.error(err); + } + }; + + // Confirma a troca de cargo + const handleConfirmChangeRole = async () => { + if (confirmModal.memberId && confirmModal.newRole) { + await handleChangeRole(confirmModal.memberId, confirmModal.newRole); + setConfirmModal({ + isOpen: false, + message: '', + memberId: null, + newRole: null, + }); + } + }; + + // Remove membro + const handleRemoveMember = async memberId => { + try { + const payload = { group_id: group.id, member_id: memberId }; + await deleteMutateAsync(payload); + } catch (err) { + console.error(err); + } + }; + + // Paginação + const handleNextPage = () => { + if (page + 1 < totalPages) setPage(prev => prev + 1); + }; + const handlePrevPage = () => { + if (page > 0) setPage(prev => prev - 1); + }; + + if (isLoading) { + return
    Carregando...
    ; + } + + return ( +
    +
    +

    Gerenciar membros

    +

    + Promova, rebaixe ou remova membros do grupo. +

    +
    + + setSearch(e.target.value)} + className="mb-6 rounded-xl focus:ring-2 focus:ring-primary/40" + /> + + {members.length === 0 ? ( +

    Nenhum membro encontrado.

    + ) : ( +
      + {members.map(member => { + const roleOptions = { + OWNER: [ + { value: 'ADMIN', label: 'Administrador', icon: }, + { value: 'USER', label: 'Membro', icon: }, + ], + ADMIN: [ + { value: 'OWNER', label: 'Dono', icon: }, + { value: 'USER', label: 'Membro', icon: }, + ], + USER: [ + { value: 'OWNER', label: 'Dono', icon: }, + { value: 'ADMIN', label: 'Administrador', icon: }, + ], + }[member.authority]; + + return ( +
    • +
      +
      + {member.name[0]} +
      +
      +

      + {member.name} + {member.authority === 'OWNER' && ( + + )} +

      +

      + {member.authority === 'OWNER' + ? 'Dono' + : member.authority === 'ADMIN' + ? 'Administrador' + : 'Membro'} +

      +
      +
      + +
      + + + + + + + {roleOptions.map(option => ( + + setConfirmModal({ + isOpen: true, + message: `Tem certeza que deseja definir ${member.name} como ${option.label}?`, + memberId: member.id, + newRole: option.value, + }) + } + className="w-full cursor-pointer text-left px-4 py-2 text-sm hover:bg-white/10 flex items-center gap-2" + > + {option.icon} {option.label} + + ))} + + + + {member.authority !== 'OWNER' && ( + handleRemoveMember(member.id)} + className="p-2 cursor-pointer rounded-full bg-red-100/80 hover:bg-red-200 text-red-600 transition" + title="Remover membro" + > + + + )} +
      +
    • + ); + })} +
    + )} + + {members.length > size && ( +
    + +
    + )} + + {/* Modal de confirmação */} + + setConfirmModal({ + isOpen: false, + message: '', + memberId: null, + newRole: null, + }) + } + onConfirm={handleConfirmChangeRole} + cancelLabel="Não" + confirmLabel="Sim" + message={confirmModal.message} + /> +
    + ); +} diff --git a/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx b/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx index 4c9e0ea..d3f8243 100644 --- a/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx +++ b/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx @@ -1,222 +1,171 @@ +import { useState } from 'react'; +import { Link, useLoaderData, useParams } from 'react-router'; import { FiArrowLeft, + FiEdit3, FiFileText, FiSave, FiSettings, FiUserPlus, FiUsers, } from 'react-icons/fi'; -import { useLoaderData } from 'react-router'; + import InputUI from '../../../../components/ui/Input'; -import { Menu, MenuItem } from '@headlessui/react'; import TextareaUI from '../../../../components/ui/Textarea'; import ButtonUI from '../../../../components/ui/Button'; - -import { useDeferredValue, useMemo, useState } from 'react'; import Modal from '../../../../components/Modal'; +import { MembersManager } from './MembersManager'; +import { useGroupByIdQuery } from '../../../../hooks/ReactQuery/Queries/useGroupsQuery'; +import { useUpdateGroupMutation } from '../../../../hooks/ReactQuery/Mutations/useUpdateGroupMutation'; export default function GroupSettings() { - const group = useLoaderData(); + const loader = useLoaderData(); + console.log({ loader }); + const { group_id } = useParams(); + const [isOpen, setIsOpen] = useState(false); + + // Query que busca o grupo pelo ID + const { data: group, isLoading } = useGroupByIdQuery(group_id); + + // Mutation para atualizar o grupo + const { mutateAsync: updateGroup, isPending } = useUpdateGroupMutation(); + + // Handler de envio do formulário + const handleSubmit = async e => { + e.preventDefault(); + const formData = new FormData(e.target); + const payload = { + name: formData.get('name'), + description: formData.get('description'), + }; + + try { + await updateGroup({ group_id, data: payload }); + } catch (error) { + console.error('Erro ao atualizar grupo:', error); + } + }; + + if (isLoading || !group) { + return ( +
    + Carregando informações do grupo... +
    + ); + } return ( -
    -
    - {/* Título */} -
    -
    - -
    -

    - Configurações de{' '} - {group.name} -

    -

    - Gerencie o nome, descrição e membros deste grupo. -

    +
    +
    + {/* Cabeçalho */} +
    +
    +
    +
    +
    +

    + Configurações de{' '} + {group.name} +

    +

    + Gerencie o grupo e seus membros com estilo 😎 +

    - {/* Nome e descrição */} -
    -
    - - + {/* Informações básicas */} +
    + -
    -
    - - + + + -
    -
    - - - - {/* Botões finais */} -
    - - } + > + + +
    + + {/* Acesso ao Gerenciamento de Membros */} +
    + +

    + Gerenciar membros do grupo +

    +

    + Adicione, remova ou altere as permissões dos membros do seu grupo. +

    + setIsOpen(true)} + className="flex cursor-pointer items-center justify-center gap-2 mt-4 px-5 py-3 rounded-xl bg-primary text-white hover:bg-primary/90 hover:scale-[1.03] transition-all shadow-md" + > + + Abrir Gerenciador de Membros + + + setIsOpen(false)}> + + +
    + + {/* Botões */} +
    + + + Voltar + + + - - Retornar - - - - -
    -
    - Salvar -
    - + {isPending ? 'Salvando...' : 'Salvar'} +
    +
    +
    ); } -function MembersInfo({ group }) { - const [isOpen, setIsOpen] = useState(false); - return ( -
    -
    -

    - - Visualizar membros ({group.members.length}) -

    - setIsOpen(true)} - className="flex cursor-pointer items-center gap-1 text-white bg-primary w-10 h-10 justify-center rounded-full font-semibold hover:text-secondary transition disabled:opacity-50 disabled:cursor-not-allowed" - type="button" - > -
    - -
    -
    -
    - - setIsOpen(false)} - isOpen={isOpen} - children={} - /> -
    - ); -} - -function ListMembers({ group }) { - const [searchMember, setSearchMember] = useState(''); - const deferredSearch = useDeferredValue(searchMember); - - const filteredMembers = useMemo(() => { - const query = deferredSearch.toLowerCase().trim(); - return group.members.filter(member => - member.name.toLowerCase().includes(query) - ); - }, [deferredSearch, group.members]); - +/* ---------- SUBCOMPONENTES ---------- */ +function FieldSection({ id, label, icon, children }) { return ( -
    - {/* Título e subtítulo */} -
    -

    - Membros do grupo ({group.members.length}) -

    -

    - Visualize todos os membros e seus cargos no grupo. -

    -
    - - {/* Campo de busca */} -
    - setSearchMember(target.value)} - aria-label="Buscar membro pelo nome" - /> -
    - - {/* Lista de membros */} - {filteredMembers.length > 0 ? ( -
      - {filteredMembers.map(member => ( -
    • - {/* Membro */} -
      - {/* Avatar */} - - - {/* Nome + Cargo */} -
      -

      - {member.name} -

      - - {member.isAdmin ? 'Administrador' : 'Membro'} - -
      -
      - - {/* Ações futuras (opcional) */} - {/* */} -
    • - ))} -
    - ) : ( -

    Nenhum membro encontrado.

    - )} -
    +
    + + {children} +
    ); } diff --git a/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js b/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js new file mode 100644 index 0000000..80c9b70 --- /dev/null +++ b/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js @@ -0,0 +1,37 @@ +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 1e51a24..beb3606 100644 --- a/frontend/src/pages/Groups/GroupDetails/index.jsx +++ b/frontend/src/pages/Groups/GroupDetails/index.jsx @@ -12,21 +12,24 @@ import ModalButton from '../../../components/Modal/ModalButton'; import { CategoryIcon } from '../../../mockData/groupIcons/icons'; import { ConfirmModal } from '../../../components/Modal'; -import { customToast } from '../../../components/CustomToast'; import { usePermissions } from './usePermissions'; import { useGroupByIdQuery } from '../../../hooks/ReactQuery/Queries/useGroupsQuery'; import { useDeleteGroupMutation } from '../../../hooks/ReactQuery/Mutations/useDeleteGroupMutation'; export default function GroupDetails() { + 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); - + /* if (isError) { + return navigate('/404', { replace: true }); + } */ return (
    )}
    - - {/* Botões: Saldos / Despesas */} - {/*
    - setShowBalances(true)} - title="Saldos" - className={`${baseBtn} ${showBalances ? activeBtn : inactiveBtn}`} - aria-label="Ver saldos entre os membros" - /> - setShowBalances(false)} - title="Despesas" - className={`${baseBtn} ${ - !showBalances ? activeBtn : inactiveBtn - }`} - aria-label="Ver despesas recentes do grupo" - /> -
    */} {/* Conteúdo principal */}
    - {/* {showBalances ? ( - - ) : ( - - )} */}
    diff --git a/frontend/src/pages/Groups/GroupDetails/statusExpense.js b/frontend/src/pages/Groups/GroupDetails/statusExpense.js index d16ebb1..0898ec9 100644 --- a/frontend/src/pages/Groups/GroupDetails/statusExpense.js +++ b/frontend/src/pages/Groups/GroupDetails/statusExpense.js @@ -3,3 +3,22 @@ export const EXPENSE_STATUS = { UNPAID: 'NÃO PAGO', PAID: 'PAGO', }; + +export const STATUS_COLORS = { + PAID: { + bg: 'bg-success text-green-900', + text: 'text-green-500', + }, + PENDING: { + bg: 'bg-warning text-yellow-900', + text: 'text-yellow-500', + }, + UNPAID: { + bg: 'bg-error text-red-900', + text: 'text-red-500', + }, + DEFAULT: { + bg: 'text-text', + text: 'text-text', + }, +}; diff --git a/frontend/src/pages/Groups/index.jsx b/frontend/src/pages/Groups/index.jsx index c8a1ebb..30761a4 100644 --- a/frontend/src/pages/Groups/index.jsx +++ b/frontend/src/pages/Groups/index.jsx @@ -5,26 +5,33 @@ import { useFilteredGroups } from '../../components/Filters/Groups/useFilteredGr import GroupForm from '../../components/forms/GroupForm'; import CardUI from '../../components/ui/Card'; import { formatBRL } from '../../utils/formatters'; -import ModalButton from '../../components/Modal/ModalButton'; import { CategoryIcon } from '../../mockData/groupIcons/icons'; import Pagination from '../../components/PaginationController'; import useMeQuery from '../../hooks/ReactQuery/Queries/useMeQuery'; import { useGroupsQuery } from '../../hooks/ReactQuery/Queries/useGroupsQuery'; import useGroupsWithMembers from './useGroupsWithMembers'; +import ButtonUI from '../../components/ui/Button'; +import Modal from '../../components/Modal'; export default function Groups() { const [searchParams] = useSearchParams(); const createModal = searchParams.has('create'); + const [groupModalIsOpen, setGroupModalIsOpen] = useState( + createModal ?? false + ); const { data: user } = useMeQuery(); const [page, setPage] = useState(0); const membersLengthToShow = 4; + const { data: { groups, totalPages, groupsLength }, isLoading, + isFetching, } = useGroupsQuery(page); + const membersMap = useGroupsWithMembers(groups); - // Filtros + // filtros locais const [filters, setFilters] = useState({ search: '', onlyOwner: false, @@ -41,20 +48,35 @@ export default function Groups() { return `Meus grupos (${totalGroups})`; }, [filters, totalFiltered, totalGroups]); - if (isLoading) return
    Carregando grupos...
    ; + if (isLoading) + return
    Carregando grupos...
    ; return (
    {/* Cabeçalho */}
    -

    +

    {title} + {isFetching && ( + + (atualizando...) + + )}

    - } - className="bg-primary text-white px-4 py-2 rounded-lg shadow hover:bg-primary/90 transition" + + 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={} />
    @@ -67,112 +89,138 @@ export default function Groups() { /> {/* Lista de grupos */} - {filteredGroups.length > 0 ? ( - <> -
    - {filteredGroups.map((group, index) => { - const membersData = membersMap[group?.id]?.members ?? []; - const membersLength = - membersMap[group?.id]?.totalElements ?? []; - const membersLoading = membersMap[group?.id]?.isLoading; - return ( - - + {filteredGroups.length > 0 ? ( + <> +
    + {filteredGroups.map(group => { + const membersData = membersMap[group?.id]?.members ?? []; + const membersLength = + membersMap[group?.id]?.totalElements ?? 0; + const membersLoading = membersMap[group?.id]?.isLoading; + + return ( + - {/* Nome + Ícone */} -
    - - - -

    {group.name}

    -
    - - {/* Quantidade de membros */} -

    - {membersLoading - ? 'Carregando membros...' - : `${membersLength} membro${ - membersLength > 1 ? 's' : '' - }`} -

    - - {/* Avatares */} -
    - {!membersLoading && membersData.length > 0 && ( - <> - {membersData - .slice(0, membersLengthToShow) - .map(({ name, themeColor }, idx) => ( + + {/* Nome + Ícone */} +
    + + + +

    {group.name}

    +
    + + {/* Quantidade de membros */} +

    + {membersLoading + ? 'Carregando membros...' + : `${membersLength} membro${membersLength > 1 ? 's' : ''}`} +

    + + {/* Avatares */} +
    + {!membersLoading && membersData.length > 0 && ( + <> + {membersData + .slice(0, membersLengthToShow) + .map(({ name, themeColor }, idx) => ( + + {name[0]?.toUpperCase()} + + ))} + + {membersLength > membersLengthToShow && ( - {name[0]?.toUpperCase()} + +{membersLength - membersLengthToShow} - ))} - - {membersLength > membersLengthToShow && ( - 0 && 'ml-2'} text-sm w-10 h-10 rounded-full flex items-center justify-center absolute border-2 border-surface shadow-md bg-gray-400`} - style={{ - left: `${membersLengthToShow * 1.4}rem`, // posição logo após os avatares visíveis - zIndex: 0, - }} - title={`${membersData.length - membersLengthToShow} membros adicionais`} - > - +{membersData.length - membersLengthToShow} - - )} - - )} -
    - - {/* Status financeiro */} -

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

    -
    - - ); - })} -
    + )} + + )} +
    + + {/* Status financeiro */} +

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

    +
    + + ); + })} +
    - {/* Paginação */} - setPage(prev => Math.min(prev + 1, totalPages - 1))} - onPrev={() => setPage(prev => Math.max(prev - 1, 0))} - /> - - ) : ( -
    -

    Nenhum grupo encontrado

    -

    - Tente ajustar os filtros ou criar um novo grupo. -

    -
    - )} + {totalPages > 1 && ( + <> + {/* Paginação */} + + setPage(prev => Math.min(prev + 1, totalPages - 1)) + } + onPrev={() => setPage(prev => Math.max(prev - 1, 0))} + /> + + )} + + ) : ( +
    + {filters.search ? ( + <> +

    Nenhum grupo encontrado

    +

    + Tente buscar por outro nome ou limpar o filtro. +

    + + ) : ( + <> +

    + Você ainda não tem grupos +

    +

    + Crie um novo grupo para começar. +

    + + )} +
    + )} +
    ); diff --git a/frontend/src/pages/Notfound/index.jsx b/frontend/src/pages/Notfound/index.jsx index 4e23111..7d8c0d4 100644 --- a/frontend/src/pages/Notfound/index.jsx +++ b/frontend/src/pages/Notfound/index.jsx @@ -1,17 +1,23 @@ import { FaPiggyBank, FaArrowLeft } from 'react-icons/fa'; -import { Link } from 'react-router'; +import { Link, useRouteError } from 'react-router'; export default function NotFound() { + const error = useRouteError(); + console.log(error); return (

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

    -

    Página não encontrada

    +

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

    - Parece que essa página, grupo ou despesa não existe mais. + {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+.

    diff --git a/frontend/src/pages/Profile/ActiveGroups/index.jsx b/frontend/src/pages/Profile/ActiveGroups/index.jsx index ec6f63b..b16d4db 100644 --- a/frontend/src/pages/Profile/ActiveGroups/index.jsx +++ b/frontend/src/pages/Profile/ActiveGroups/index.jsx @@ -4,7 +4,11 @@ import { FiUsers } from 'react-icons/fi'; import { useGroupsQuery } from '../../../hooks/ReactQuery/Queries/useGroupsQuery'; export default function ActiveGroups() { - const { data, isLoading } = useGroupsQuery(); + const { + data: { groups, groupsLength }, + isLoading, + } = useGroupsQuery(); + const groupLimit = 4; if (isLoading) { @@ -16,9 +20,8 @@ export default function ActiveGroups() { ); } - const groups = data?.groups ?? []; const groupsToShow = groups.slice(0, groupLimit); - const hasMoreGroups = groups.length > groupLimit; + const hasMoreGroups = groupsLength > groupLimit; if (groups.length === 0) { return ( @@ -68,7 +71,7 @@ export default function ActiveGroups() { transition-colors" aria-label="Ver todos os grupos" > - +{groups.length - groupLimit} mais + +{groupsLength - groupLimit} mais )} diff --git a/frontend/src/pages/Profile/index.jsx b/frontend/src/pages/Profile/index.jsx index fac5cb2..623f8e7 100644 --- a/frontend/src/pages/Profile/index.jsx +++ b/frontend/src/pages/Profile/index.jsx @@ -24,6 +24,7 @@ export default function Profile() { const { data: user } = useMeQuery(); const { data: dashboard } = useMeDashboardQuery(); const { data } = useGroupsQuery(); + console.log({ data }); const groupsLength = data?.groupsLength; const [isModalOpen, setIsModalOpen] = useState(false); const current_user = userData; @@ -99,6 +100,7 @@ export default function Profile() { setIsModalOpen(false)} isOpen={isModalOpen} + setIsOpen={setIsModalOpen} children={ { + 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 47fcce4..b9336fe 100644 --- a/frontend/src/routes/routes.jsx +++ b/frontend/src/routes/routes.jsx @@ -1,9 +1,7 @@ import { createBrowserRouter } from 'react-router'; import { lazy } from 'react'; -import { groupAction } from '../actions/groupAction'; import { loginAction } from '../actions/loginAction'; import { registerAction } from '../actions/registerAction'; -import { groupSettingsLoader } from '../pages/Groups/GroupDetails/GroupSettings/groupSettingsLoader'; import { loginLoader } from '../loaders/loginLoader'; import { registerLoader } from '../loaders/registerLoader'; import { protectRoutersLoader } from '../loaders/protectRoutersLoader'; @@ -11,6 +9,9 @@ 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'; const Login = lazy(() => import('../pages/Login')); const Register = lazy(() => import('../pages/Register')); @@ -50,15 +51,24 @@ export const appRouter = createBrowserRouter([ { path: 'groups', children: [ - { index: true, element: , action: groupAction }, + { index: true, element: }, { path: ':group_id', element: , + loader: groupDetailsLoader, + }, + { + path: ':group_id/expenses/:expense_id', + element: , + loader: groupDetailsLoader, }, { path: ':group_id/settings', element: , - loader: groupSettingsLoader, + loader: async () => { + await protectRouterGroupLoader(); + await groupDetailsLoader(); + }, }, ], }, diff --git a/frontend/src/schemas/createNewExpense/updateExpenseForm.js b/frontend/src/schemas/createNewExpense/updateExpenseForm.js new file mode 100644 index 0000000..d898c85 --- /dev/null +++ b/frontend/src/schemas/createNewExpense/updateExpenseForm.js @@ -0,0 +1,59 @@ +import { z } from 'zod'; // ✅ precisa ser importado assim (com chaves) + +const ExpenseSchema = z.object({ + title: z + .string() + .min(3, 'Título deve ter pelo menos 3 caracteres') + .max(100, 'Título deve ter no máximo 100 caracteres'), + description: z + .string() + .max(2000, 'Descrição deve ter no máximo 2000 caracteres') + .optional(), + + // ✅ Validação e conversão de data + deadlineDate: z + .preprocess( + val => { + if (!val) return undefined; // permite undefined/null + return val instanceof Date ? val : new Date(val); + }, + z + .date({ invalid_type_error: 'Data inválida' }) + .refine(d => !isNaN(d.getTime()), { message: 'Data inválida' }) + ) + .optional(), + + // ✅ categoryId: pode ser número positivo ou string numérica + categoryId: z + .union([ + z + .string() + .regex(/^\d+$/, 'categoryId numérico inválido') + .transform(n => Number(n)), + z.number().int().positive(), + ]) + .refine(val => !!val, { message: 'categoryId obrigatório' }), +}); + +/** + * Valida e normaliza os dados do formulário. + * Retorna: + * { success: true, data } → se estiver válido + * { success: false, errors } → se houver erros + */ +export function validateExpensePayload(payload) { + const result = ExpenseSchema.safeParse(payload); + + if (result.success) { + return { success: true, data: result.data }; + } + + // Transforma erros do Zod em um objeto { campo: mensagem } + const errors = {}; + for (const issue of result.error.issues) { + const field = issue.path.join('.') || '_form'; + if (!errors[field]) errors[field] = issue.message; + } + + return { success: false, errors }; +} diff --git a/frontend/src/services/groups.js b/frontend/src/services/groups.js index 1efeb62..b5785db 100644 --- a/frontend/src/services/groups.js +++ b/frontend/src/services/groups.js @@ -1,11 +1,15 @@ import { apiApplication } from './api'; -// Busca todos os grupos -export const getGroups = async (page = 0, size = 6) => { +export const getGroups = async (page, size) => { try { - const response = await apiApplication.get( - `/groups?page=${page}&size=${size}` - ); + const params = new URLSearchParams(); + + // adiciona parâmetros apenas se existirem + if (page !== undefined) params.append('page', page); + if (size !== undefined) params.append('size', size); + + const response = await apiApplication.get(`/groups?${params.toString()}`); + return { groups: response.data.content, totalPages: response.data.totalPages, @@ -31,6 +35,7 @@ export const getGroupMembers = async ({ groupId, page, size, search } = {}) => { members: data.content, totalPages: data.totalPages, totalElements: data.totalElements, + size: data.size, }; }; @@ -48,16 +53,15 @@ export const createGroup = async group => { //Obter um grupo por id export const getGroupById = async id => { try { - const response = await apiApplication - .get(`/groups/${id}`) - .then(async ({ data: group }) => { + const response = await apiApplication.get(`/groups/${id}`); + /* .then(async ({ data: group }) => { const { data: { content: members }, } = await apiApplication.get(`groups/${group.id}/members`); group.members = members; return group; - }); - return response; + }); */ + return response.data; } catch (error) { console.error(error); throw new Error('Erro ao obter grupo - id:', id); @@ -76,9 +80,12 @@ export const getAllGroupsExpenseCategories = async () => { // Atualiza um grupo existente pelo id export const updateGroup = async (id, group) => { - const response = await api.put(`/groups/${id}`, group); - - return response.data; + try { + const response = await apiApplication.put(`/groups/${id}`, group); + return response.data; + } catch (error) { + throw error; + } }; // Remove um grupo pelo id @@ -122,6 +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); @@ -132,3 +140,68 @@ export const getGroupExpenseById = async (group_id, expense_id) => { ); } }; + +export const updatePartialValueExpenseStatus = async ( + group_id, + expense_id, + member_id +) => { + try { + const response = await apiApplication.patch( + `/groups/${group_id}/expenses/${expense_id}/member/${member_id}` + ); + return response.data; + } catch (error) { + throw error; + } +}; + +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}`, + data + ); + return response.data; + } catch (error) { + throw error; + } +}; + +export const deleteGroupExpense = async (group_id, expense_id) => { + try { + const response = await apiApplication.delete( + `/groups/${group_id}/expenses/${expense_id}` + ); + return response.data; + } catch (error) { + throw error; + } +}; +export const deleteGroupMember = async (group_id, member_id) => { + try { + const response = await apiApplication.delete( + `/groups/${group_id}/members/${member_id}` + ); + return response.data; + } catch (error) { + throw error; + } +}; + +export const updateRoleGroupMember = async (group_id, member_id, newRole) => { + try { + const data = { + setAuthority: newRole, + }; + const response = await apiApplication.put( + `/groups/${group_id}/members/${member_id}/transfer-ownership`, + data + ); + + return response.data; + } catch (error) { + throw error; + } +};