From 7aac906f2d7a76edbce20c3ac88a75f84b45ee18 Mon Sep 17 00:00:00 2001 From: Cleyton Jesus Date: Mon, 10 Nov 2025 10:19:54 -0300 Subject: [PATCH] alterando o front e corrigindo erro no back --- .../repository/ExpenseRepository.java | 48 ++--- frontend/src/App.jsx | 2 +- .../components/ChartSwitcher/BalanceChart.jsx | 46 +++-- .../components/ChartSwitcher/ExpenseChart.jsx | 106 +++++++---- .../src/components/ChartSwitcher/index.jsx | 95 +++++----- .../src/components/LatestExpenses/index.jsx | 2 +- frontend/src/components/Modal/ModalButton.jsx | 42 ----- frontend/src/components/Modal/index.jsx | 6 +- .../src/components/SummaryCards/index.jsx | 2 +- .../Mutations/addMemberGroupMutation.js | 43 +++++ .../Mutations/useDeleteExpenseByIdMutation.js | 5 +- .../Mutations/useDeleteMeGroupByIdMutation.js | 45 +++++ ...UpdateStatusExpensePartialValueMutation.js | 8 + frontend/src/loaders/expenseLoader.js | 59 +----- frontend/src/loaders/protectRoutersLoader.js | 27 ++- .../pages/Expenses/ExpenseDetails/index.jsx | 138 +++++++------- .../pages/Groups/GroupDetails/AddMember.jsx | 97 ++++++++++ .../Groups/GroupDetails/ExpensesList.jsx | 4 +- .../GroupDetails/GroupSettings/index.jsx | 98 ++++++---- .../src/pages/Groups/GroupDetails/index.jsx | 169 ++++++++++++++---- .../Groups/GroupDetails/usePermissions.js | 4 + .../src/pages/Groups/GrupoFinancialStatus.jsx | 10 +- frontend/src/pages/Groups/index.jsx | 4 +- frontend/src/pages/Notfound/index.jsx | 37 +--- .../src/pages/Profile/ActiveGroups/index.jsx | 47 ++--- frontend/src/services/groups.js | 27 ++- frontend/src/utils/constants.js | 4 +- frontend/src/utils/errors.js | 115 ++++++++++++ frontend/src/utils/formatters.js | 39 +++- 29 files changed, 888 insertions(+), 441 deletions(-) delete mode 100644 frontend/src/components/Modal/ModalButton.jsx create mode 100644 frontend/src/hooks/ReactQuery/Mutations/addMemberGroupMutation.js create mode 100644 frontend/src/hooks/ReactQuery/Mutations/useDeleteMeGroupByIdMutation.js create mode 100644 frontend/src/pages/Groups/GroupDetails/AddMember.jsx create mode 100644 frontend/src/utils/errors.js diff --git a/backend/finboostplus_server/src/main/java/com/finboostplus/repository/ExpenseRepository.java b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/ExpenseRepository.java index a3d91c36..76e3be5f 100644 --- a/backend/finboostplus_server/src/main/java/com/finboostplus/repository/ExpenseRepository.java +++ b/backend/finboostplus_server/src/main/java/com/finboostplus/repository/ExpenseRepository.java @@ -45,38 +45,24 @@ public interface ExpenseRepository extends JpaRepository { Page getAllUserExpenses(Long userId, Pageable pageable); @Query(nativeQuery = true, value = """ - WITH - MESES AS ( - SELECT - GENERATE_SERIES(1, 12) AS MES_NUM + WITH MESES AS ( + SELECT GENERATE_SERIES(1, 12) AS MES_NUM ) - SELECT - TO_CHAR(TO_DATE(M.MES_NUM::TEXT, 'MM'), 'FMMonth') AS MONTH, - COALESCE(SUM(UED.PARTIAL_VALUE), 0) AS TOTAL - FROM - MESES M - LEFT JOIN EXPENSES E ON EXTRACT( - MONTH - FROM - E.DEADLINE_DATE - ) = M.MES_NUM - AND EXTRACT( - YEAR - FROM - E.DEADLINE_DATE - ) = EXTRACT( - YEAR - FROM - CURRENT_DATE - ) - LEFT JOIN USER_EXPENSE_DIVISIONS UED ON E.ID = UED.EXPENSE_ID - AND UED.USER_ID = 1 - GROUP BY - M.MES_NUM - ORDER BY - M.MES_NUM - """) - List getUserMonthlyExpenses(Long userId); + SELECT + TO_CHAR(TO_DATE(M.MES_NUM::TEXT, 'MM'), 'TMMonth') AS month, + COALESCE(SUM(UED.partial_value), 0) AS total + FROM MESES M + LEFT JOIN expenses E + ON EXTRACT(MONTH FROM E.deadline_date) = M.MES_NUM + AND EXTRACT(YEAR FROM E.deadline_date) = EXTRACT(YEAR FROM CURRENT_DATE) + LEFT JOIN user_expense_divisions UED + ON E.id = UED.expense_id + AND UED.user_id = :userId + GROUP BY M.MES_NUM + ORDER BY M.MES_NUM + """) + List getUserMonthlyExpenses(@Param("userId") Long userId); + // @Query(nativeQuery = true, value = """ // SELECT expenses.id, expenses.title, expenses.description, // categories.name as categoryName, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7569af60..ec34225a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,7 +4,7 @@ import SummaryCards from './components/SummaryCards'; function App() { return ( -
+
diff --git a/frontend/src/components/ChartSwitcher/BalanceChart.jsx b/frontend/src/components/ChartSwitcher/BalanceChart.jsx index 5b81e7e2..ed7e30ae 100644 --- a/frontend/src/components/ChartSwitcher/BalanceChart.jsx +++ b/frontend/src/components/ChartSwitcher/BalanceChart.jsx @@ -25,14 +25,19 @@ export default function BalanceChart() { })) ?? []; return ( - + - {/* Grade */} + {/* Linhas de grade */} + {/* Eixo X (meses) */} + {/* Eixo Y (valores) */} formatBRL(value)} tick={{ fontSize: 12, fill: 'var(--color-text)' }} axisLine={false} tickLine={false} + width={80} /> - {/* Tooltip com tema dinâmico */} + {/* Tooltip estilizado */} formatBRL(value)} - contentStyle={{ - backgroundColor: 'var(--color-surface)', - borderColor: 'var(--color-border)', - color: 'var(--color-text)', + content={({ active, payload, label }) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+

+ Despesas:{' '} + + {formatBRL(payload[0].value)} + +

+
+ ); + } + return null; }} - labelStyle={{ - color: 'var(--color-text-muted)', - fontWeight: 500, - }} - itemStyle={{ color: 'var(--color-text)' }} /> - {/* Gradiente suave (segue o tema pelas CSS vars) */} + {/* Gradiente suave (mantém o tema dinâmico) */}
diff --git a/frontend/src/components/ChartSwitcher/ExpenseChart.jsx b/frontend/src/components/ChartSwitcher/ExpenseChart.jsx index 1bcb4a6b..702964f7 100644 --- a/frontend/src/components/ChartSwitcher/ExpenseChart.jsx +++ b/frontend/src/components/ChartSwitcher/ExpenseChart.jsx @@ -1,47 +1,85 @@ -import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'; import { avatarBackgroundColors } from '../../mockData/colorsPallete/colors'; import { useMeDashboardQuery } from '../../hooks/ReactQuery/Queries/useMeDashboardStatsQuery'; +import { formatBRL } from '../../utils/formatters'; export default function ExpenseQuantityDonutChart() { const { data: expenseData } = useMeDashboardQuery(); const total = expenseData?.reduce((sum, item) => sum + item.quantity, 0) || 0; return ( -
- - - - `${name}: ${ - expenseData.find(c => c.category === name)?.quantity ?? 0 - }` - } - > - {expenseData?.map((_, index) => ( - +
+ + + { + if (active && payload?.length) { + const { name, value } = payload[0]; + const percent = ((value / total) * 100).toFixed(1); + return ( +
+

{name}

+

+ Quantidade:{' '} + + {formatBRL(value, { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + style: 'decimal', + })} + +

+

+ {percent}% do total +

+
+ ); } - /> - ))} - -
-
+ return null; + }} + /> - {/* 💬 Centro do gráfico */} -
-

Total de despesas

-

{total}

+ + {expenseData?.map((_, index) => ( + + ))} + + + + + {/* Total centralizado */} +
+ + {formatBRL(total, { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + style: 'decimal', + })} + + + Total + +
); diff --git a/frontend/src/components/ChartSwitcher/index.jsx b/frontend/src/components/ChartSwitcher/index.jsx index 566b7c9d..f3176972 100644 --- a/frontend/src/components/ChartSwitcher/index.jsx +++ b/frontend/src/components/ChartSwitcher/index.jsx @@ -1,61 +1,61 @@ import { useState } from 'react'; -import { FaChartLine, FaWallet } from 'react-icons/fa'; +import { FaChartLine, FaChartPie } from 'react-icons/fa'; import BalanceChart from './BalanceChart'; import ExpenseChart from './ExpenseChart'; +import ButtonUI from '../ui/Button'; export default function ChartSwitcher() { const [activeChart, setActiveChart] = useState('balance'); - const toggleChart = () => { - setActiveChart(prev => (prev === 'balance' ? 'expense' : 'balance')); - }; - - const buttonLabel = - activeChart === 'balance' ? 'Gastos Mensais' : 'Saldo Geral'; - const ButtonIcon = activeChart === 'balance' ? FaWallet : FaChartLine; - return ( -
+
{/* Cabeçalho */} -
-

+
+

{activeChart === 'balance' - ? 'Visualização Financeira' - : 'Distribuição das Despesas por Categoria'} + ? `Como Você Gastou em (${new Date().getFullYear()})` + : 'Despesas por Categoria (Geral)'}

- {/* Botão para alternar gráfico */} - + {/* Alternador */} +
+ {/* Indicador animado */} +
+ {/* Opção: Gastos Mensais */} + setActiveChart('balance')} + className={`relative z-10 w-1/2 flex items-center gap-2 px-4 py-1.5 rounded-full cursor-pointer transition-colors duration-300 ${ + activeChart === 'balance' + ? 'text-white' + : 'text-muted-foreground hover:text-text' + }`} + > + + Resumo Anual + + {/* Opção: Saldo Geral */} + setActiveChart('expense')} + className={`relative z-10 w-1/2 flex items-center gap-2 px-4 py-0.5 rounded-full cursor-pointer transition-colors duration-300 ${ + activeChart === 'expense' + ? 'text-white' + : 'text-muted-foreground hover:text-text' + }`} + > + + Divisão de Gastos + +
- -

- ); -} -function GraphicArea({ activeChart }) { - return ( - <> - {/* Área do gráfico */} -
- {/* ✨ Transição suave entre os gráficos */} + {/* Área dos gráficos */} +
@@ -63,18 +63,13 @@ function GraphicArea({ activeChart }) {
- - {/* Fallback caso não haja dados */} - {!activeChart && ( -

Nenhum gráfico selecionado.

- )}
- +
); } diff --git a/frontend/src/components/LatestExpenses/index.jsx b/frontend/src/components/LatestExpenses/index.jsx index 72d2e7a4..d72efb6c 100644 --- a/frontend/src/components/LatestExpenses/index.jsx +++ b/frontend/src/components/LatestExpenses/index.jsx @@ -31,7 +31,7 @@ export default function LatestExpenses() { return (

- setIsOpenModal(false)} - children={modalChildren} - isOpen={isOpenModal} - setIsOpen={setIsOpenModal} - /> - - setIsOpenModal(true)} - className=" - fixed - bottom-5 right-6 - w-12 h-12 - rounded-full - bg-primary - text-white - text-3xl - font-extrabold - flex items-center justify-center - leading-none - opacity-60 hover:opacity-100 - shadow-lg - transition-opacity - cursor-pointer - focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-60 - " - > - + - - - ); -} diff --git a/frontend/src/components/Modal/index.jsx b/frontend/src/components/Modal/index.jsx index 996e0bb6..5b58a11c 100644 --- a/frontend/src/components/Modal/index.jsx +++ b/frontend/src/components/Modal/index.jsx @@ -78,14 +78,14 @@ export default function Modal({ > diff --git a/frontend/src/components/SummaryCards/index.jsx b/frontend/src/components/SummaryCards/index.jsx index fc8f8260..06a2adbd 100644 --- a/frontend/src/components/SummaryCards/index.jsx +++ b/frontend/src/components/SummaryCards/index.jsx @@ -17,7 +17,7 @@ export default function SummaryCards() { : { totalExpenses: 0, mostExpense: { month: '', total: 0 } }; return ( -
+
{/* Total de Gastos no Ano */} diff --git a/frontend/src/hooks/ReactQuery/Mutations/addMemberGroupMutation.js b/frontend/src/hooks/ReactQuery/Mutations/addMemberGroupMutation.js new file mode 100644 index 00000000..5072f0f7 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/addMemberGroupMutation.js @@ -0,0 +1,43 @@ +import { useMutation } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { queryClient } from '../../../libs/ReactQuery/queryClient'; +import { addMemberGroup } from '../../../services/groups'; +import { customToast } from '../../../components/CustomToast'; + +export function addMemberGroupMutation(group_id) { + return useMutation({ + mutationFn: data => addMemberGroup(group_id, data), + onSuccess: response => { + // Invalida a lista de grupos para forçar um refetch + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.EXPENSES], + }); + queryClient.invalidateQueries([ + REACTQUERY_KEYS.GROUPS.MEMBERS, + Number(group_id), + ]); + + // Opcional: remover manualmente do cache local + queryClient.setQueryData([REACTQUERY_KEYS.GROUPS.ALL], oldData => { + if (!oldData) return oldData; + return { + ...oldData, + content: oldData.content.filter(group => group.id !== id), + totalElements: oldData.totalElements - 1, + }; + }); + + customToast('Adição de um novo membro', response, 'success'); + }, + onError: error => { + customToast( + 'Erro', + error.response.data.message || 'Erro ao convidar um novo membro grupo.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useDeleteExpenseByIdMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useDeleteExpenseByIdMutation.js index 91b15736..f63088c9 100644 --- a/frontend/src/hooks/ReactQuery/Mutations/useDeleteExpenseByIdMutation.js +++ b/frontend/src/hooks/ReactQuery/Mutations/useDeleteExpenseByIdMutation.js @@ -31,10 +31,11 @@ export function useDeleteExpenseByIdMutation() { 'success' ); }, - onError: () => { + onError: error => { customToast( 'Erro ao excluir despesa', - 'Não foi possível excluir a despesa. Tente novamente.', + error?.response?.data?.message || + 'Não foi possível excluir a despesa. Tente novamente.', 'error' ); }, diff --git a/frontend/src/hooks/ReactQuery/Mutations/useDeleteMeGroupByIdMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useDeleteMeGroupByIdMutation.js new file mode 100644 index 00000000..a1e6a892 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/Mutations/useDeleteMeGroupByIdMutation.js @@ -0,0 +1,45 @@ +import { useMutation } from '@tanstack/react-query'; +import { leaveGroup } from '../../../services/groups'; +import { queryClient } from '../../../libs/ReactQuery/queryClient'; +import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; +import { customToast } from '../../../components/CustomToast'; +import { redirect } from 'react-router'; + +export function useDeleteMeGroupByIdMutation(group_id) { + return useMutation({ + mutationFn: () => leaveGroup(group_id), + onSuccess: () => { + // Invalida a lista de grupos para forçar um refetch + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'page'], + }); + queryClient.invalidateQueries({ + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'detail', Number(group_id)], + }); + queryClient.invalidateQueries([ + REACTQUERY_KEYS.GROUPS.MEMBERS, + Number(id), + ]); + + // Opcional: remover manualmente do cache local + queryClient.setQueryData([REACTQUERY_KEYS.GROUPS.ALL], oldData => { + if (!oldData) return oldData; + return { + ...oldData, + content: oldData.content.filter(group => group.id !== id), + totalElements: oldData.totalElements - 1, + }; + }); + + customToast('Saída', 'Saída do grupo efetuada com sucesso', 'success'); + redirect('/groups'); + }, + onError: error => { + customToast( + 'Erro', + error.response.data.message || 'Erro ao sair do grupo.', + 'error' + ); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js b/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js index b860416f..97499698 100644 --- a/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js +++ b/frontend/src/hooks/ReactQuery/Mutations/useUpdateStatusExpensePartialValueMutation.js @@ -25,6 +25,14 @@ export function useUpdateStatusExpensePartialValueMutation() { queryKey: [REACTQUERY_KEYS.GROUPS.ALL, 'page'], }); + queryClient.invalidateQueries({ + queryKey: [ + REACTQUERY_KEYS.GROUPS.EXPENSES, + 'filters', + Number(group_id), + ], + }); + queryClient.invalidateQueries({ queryKey: [ REACTQUERY_KEYS.GROUPS.EXPENSES, diff --git a/frontend/src/loaders/expenseLoader.js b/frontend/src/loaders/expenseLoader.js index f4eff5ad..9412a2c5 100644 --- a/frontend/src/loaders/expenseLoader.js +++ b/frontend/src/loaders/expenseLoader.js @@ -1,13 +1,10 @@ import { REACTQUERY_KEYS } from '../libs/ReactQuery/keys'; import { queryClient } from '../libs/ReactQuery/queryClient'; -import { - getGroupExpenseById, - getGroupUserAuthenticatedAuthority, -} from '../services/groups'; +import { getGroupUserAuthenticatedAuthority } from '../services/groups'; import { getMe } from '../services/me'; -export async function expenseLoader({ request, params }) { - const { group_id, expense_id } = params; +export async function expenseLoader({ params }) { + const { group_id } = params; // Pega o usuário logado const user = await queryClient.ensureQueryData({ @@ -22,55 +19,5 @@ export async function expenseLoader({ request, params }) { queryFn: () => getGroupUserAuthenticatedAuthority(group_id, user.id), }); - // Declara expense fora do try - let expense; - const url = new URL(request.url); - const path = url.pathname.split(`/expenses`)[0]; - try { - expense = await queryClient.ensureQueryData({ - queryKey: [ - REACTQUERY_KEYS.GROUPS.EXPENSES, - 'groups', - Number(group_id), - Number(expense_id), - ], - queryFn: () => getGroupExpenseById(group_id, expense_id), - }); - } catch (error) { - const responseData = { - btnText: 'Voltar para a página anterior?', - message: 'Despesa não encontrada!', - path, - }; - throw new Response(JSON.stringify(responseData), { - status: 403, - statusText: 'Erro!', - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', - Pragma: 'no-cache', - }, - }); - } - - // Verifica se o usuário é membro da despesa - const isMember = expense.memberList.some(member => member.userId === user.id); - if (!isMember) { - const responseData = { - btnText: 'Voltar para a página anterior?', - message: 'Despesa não encontrada!', - path, - }; - throw new Response(JSON.stringify(responseData), { - status: 403, - statusText: 'Erro!', - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', - Pragma: 'no-cache', - }, - }); - } - return authority; } diff --git a/frontend/src/loaders/protectRoutersLoader.js b/frontend/src/loaders/protectRoutersLoader.js index 0da44d46..10ac6cc1 100644 --- a/frontend/src/loaders/protectRoutersLoader.js +++ b/frontend/src/loaders/protectRoutersLoader.js @@ -4,35 +4,54 @@ import { queryClient } from '../libs/ReactQuery/queryClient'; import { REACTQUERY_KEYS } from '../libs/ReactQuery/keys'; import { getGroupUserAuthenticatedAuthority } from '../services/groups'; import { getMe } from '../services/me'; +import { ForbiddenError } from '../utils/errors'; export async function protectRoutersLoader({ request, params }) { // 🔐 1. Proteção de login await useAuthStore.persist.rehydrate(); const isAuthenticated = !!useAuthStore.getState().token; - if (!isAuthenticated) return redirect('/login'); - // 🧩 2. Verifica se é uma rota protegida específica + if (!isAuthenticated) { + // Redireciona para login se não estiver autenticado + return redirect('/login'); + } + + // 🧩 2. Verifica se é rota de configuração de grupo const { group_id } = params; const url = new URL(request.url); const path = url.pathname; + + // Só bloqueia /groups/:group_id/settings e subrotas const match = matchPath(`/groups/${group_id}/settings/*`, path); - if (!match) return null; // rota não é settings → libera + if (!match) return null; // rota livre → prossegue + // 🧠 3. Obtém o usuário logado const user = await queryClient.ensureQueryData({ queryKey: [REACTQUERY_KEYS.USER.ME], queryFn: getMe, staleTime: Infinity, }); + // 👑 4. Obtém a autoridade do usuário no grupo const authority = await queryClient.ensureQueryData({ queryKey: [REACTQUERY_KEYS.GROUPS.ME_AUTHORITY, group_id, user.id], queryFn: () => getGroupUserAuthenticatedAuthority(group_id, user.id), staleTime: Infinity, }); + // 🚫 5. Se o usuário não for o dono, lança erro customizado if (authority !== 'OWNER') { - return redirect(path.split('/settings')[0]); + const basePath = `/groups/${group_id}`; + const btn = { label: 'Voltar para o grupo', path: basePath }; + + // Lança erro customizado que será capturado pelo React Router + throw new ForbiddenError( + 'Somente o dono do grupo pode acessar as configurações.', + 403, + btn + ); } + // ✅ 6. Permite prosseguir se for OWNER return null; } diff --git a/frontend/src/pages/Expenses/ExpenseDetails/index.jsx b/frontend/src/pages/Expenses/ExpenseDetails/index.jsx index 5c565c81..8b51a295 100644 --- a/frontend/src/pages/Expenses/ExpenseDetails/index.jsx +++ b/frontend/src/pages/Expenses/ExpenseDetails/index.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { TbPigMoney } from 'react-icons/tb'; import { EXPENSE_STATUS, @@ -20,20 +20,28 @@ import { ConfirmModal } from '../../../components/Modal'; import { validateExpensePayload } from '../../../schemas/createNewExpense/updateExpenseForm'; import { customToast } from '../../../components/CustomToast'; import { MdOutlineFactCheck } from 'react-icons/md'; +import { NotFoundError } from '../../../utils/errors'; +import { usePermissions } from '../../Groups/GroupDetails/usePermissions'; export default function ExpenseDetails() { const [isOpenConfirmModal, setIsOpenConfirmModal] = useState(false); const navigate = useNavigate(); const { group_id, expense_id } = useParams(); const loaderData = useLoaderData(); const authority = loaderData; - const canEditExpense = ['OWNER', 'ADMIN'].includes(authority); // pode editar campos - const canChangeStatus = authority === 'OWNER'; // só OWNER altera status - - const { data: expense, isLoading: isLoadingExpense } = - useGroupExpenseByIdQuery(group_id, expense_id); + const { canEditExpenses, canChangeStatusExpenses, canDeleteExpenses } = + usePermissions(authority); + const { + data: expense, + isLoading: isLoadingExpense, + isError, + } = useGroupExpenseByIdQuery(group_id, expense_id); + + if (isError) { + throw new NotFoundError('A despesa solicitada não foi encontrada.'); + } const { data: categories } = useAllGroupExpenseCategoriesQuery(); - const { mutateAsync, isPending: isLoading } = + const { mutateAsync: mutateStatus, isPending: isLoadingStatus } = useUpdateStatusExpensePartialValueMutation(); const { mutateAsync: updateExpense, isPending } = useUpdateGroupExpenseByIdMutation(); @@ -58,23 +66,33 @@ export default function ExpenseDetails() { } }, [expense]); - const color = STATUS_COLORS[expense?.status] || STATUS_COLORS.DEFAULT; - - function handleChange(e) { + const handleChange = e => { const { name, value } = e.target; setFormData(prev => ({ ...prev, [name]: value })); - } - - async function handleSubmit(e) { + }; + + // Verifica se há alterações em relação à despesa original + const hasChanges = useMemo(() => { + if (!expense) return false; + return ( + formData.title.trim() !== '' && // título obrigatório + (formData.title !== expense.title || + formData.description !== (expense.description ?? '') || + formData.categoryId !== expense.categoryId || + formData.deadlineDate !== expense.deadlineDate) + ); + }, [formData, expense]); + + const handleSubmit = async e => { e.preventDefault(); - if (!canEditExpense) return; // bloqueia edição + if (!canEditExpenses || !hasChanges) return; try { const data = { title: formData.title, description: formData.description, - deadlineDate: formData.deadlineDate, categoryId: formData.categoryId, + deadlineDate: formData.deadlineDate, }; const validation = validateExpensePayload(data); if (!validation.success) { @@ -87,21 +105,20 @@ export default function ExpenseDetails() { } catch (error) { console.error(error); } - } + }; - async function handleChangeStatusExpensePartialValue(member_id) { - if (!canChangeStatus) return; // bloqueia alteração de status + const handleChangeStatusExpensePartialValue = async member_id => { + if (!canChangeStatusExpenses) return; try { - const data = { group_id, expense_id, member_id }; - await mutateAsync(data); + await mutateStatus({ group_id, expense_id, member_id }); } catch (error) { console.error(error); } - } + }; - if (isLoadingExpense) { - return Carregado...; - } + if (isLoadingExpense) return Carregado...; + + const color = STATUS_COLORS[expense?.status] || STATUS_COLORS.DEFAULT; return (
-

{expense.title}

@@ -124,8 +140,6 @@ export default function ExpenseDetails() { {EXPENSE_STATUS[expense.status] || 'DESCONHECIDO'}
- - {/* Progresso de pagamento */} {expense.memberList?.length > 0 && (() => { const totalPago = expense.memberList @@ -142,21 +156,16 @@ export default function ExpenseDetails() { {formatBRL(totalPago)} / {formatBRL(total)} {allPaid ? 'Pago totalmente ✅' : `${Math.floor(porcentagem)}% pago`}
-
@@ -165,7 +174,6 @@ export default function ExpenseDetails() { })()}
-

Criado em:{' '} @@ -179,7 +187,6 @@ export default function ExpenseDetails() {

Informações da Despesa

-

Grupo

@@ -187,14 +194,12 @@ export default function ExpenseDetails() { {expense.groupName}

-

Total

{formatBRL(expense.total)}

-

Categoria Atual

@@ -205,32 +210,30 @@ export default function ExpenseDetails() {

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

✏️ Editar informações da despesa

-
- -
-
+
-
@@ -277,18 +279,26 @@ export default function ExpenseDetails() {
{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'} - + {canDeleteExpenses && ( + 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'} + + )}
@@ -314,13 +324,10 @@ export default function ExpenseDetails() { {/* Participantes */}
-
- {/* Título Principal: Mais forte e com cor sutil */} +

👥 Participantes

- - {/* Status da Despesa: Fonte menor e cinza sutil */} Status da Despesa @@ -329,18 +336,17 @@ export default function ExpenseDetails() {
{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; + const currentStatus = statusMap[member.status] || statusMap.UNPAID; return (

{member.userName}

@@ -358,11 +364,13 @@ export default function ExpenseDetails() { onClick={() => handleChangeStatusExpensePartialValue(member.userId) } - disabled={isLoading || !canChangeStatus} // bloqueia se não pode alterar - className={`flex min-w-[130px] cursor-pointer justify-center items-center gap-2 px-5 py-2 rounded-lg font-medium shadow-sm transition-all text-white ${STATUS_COLORS[member.status].bg}`} + disabled={!canChangeStatusExpenses} + className={`flex min-w-[130px] disabled:cursor-auto cursor-pointer justify-center items-center gap-2 px-5 py-2 rounded-lg font-medium shadow-sm transition-all text-white ${STATUS_COLORS[member.status].bg}`} type="button" > - {isLoading ? 'ALTERANDO...' : EXPENSE_STATUS[member.status]} + {isLoadingStatus + ? 'ALTERANDO...' + : EXPENSE_STATUS[member.status]}
); diff --git a/frontend/src/pages/Groups/GroupDetails/AddMember.jsx b/frontend/src/pages/Groups/GroupDetails/AddMember.jsx new file mode 100644 index 00000000..3069ca3e --- /dev/null +++ b/frontend/src/pages/Groups/GroupDetails/AddMember.jsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import ButtonUI from '../../../components/ui/Button'; +import { Form } from 'react-router'; +import InputUI from '../../../components/ui/Input'; +import SelectUI from '../../../components/ui/Select'; + +export default function AddMemberForm({ onSubmit, onCancel }) { + const [email, setEmail] = useState(''); + const [authorization, setAuthorization] = useState('USER'); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async e => { + e.preventDefault(); + + setIsSubmitting(true); + + try { + await onSubmit({ email, authorization }); + /* setEmail(''); */ + setAuthorization('USER'); + } finally { + setIsSubmitting(false); + } + }; + + const isSubmitDisabled = !email || isSubmitting; + + return ( + +

+ Adicionar Membro +

+ +
+ + setEmail(e.target.value)} + placeholder="email@exemplo.com" + className="border border-border rounded-lg px-4 py-2 text-text focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-shadow shadow-sm" + /> +
+ +
+ + setAuthorization(e.target.value)} + className="border border-border rounded-lg px-4 py-2 text-text focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition-shadow shadow-sm cursor-pointer" + > + + + +
+ +
+ {onCancel && ( + + Cancelar + + )} + + {isSubmitting ? 'Adicionando...' : 'Adicionar'} + +
+ + ); +} diff --git a/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx b/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx index e17a7c01..aeb2284e 100644 --- a/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx +++ b/frontend/src/pages/Groups/GroupDetails/ExpensesList.jsx @@ -158,7 +158,9 @@ export default function ExpensesList({ groupID, authorization }) { - {formatBRL(expense.partialValue ?? expense.total)} + {formatBRL( + allMembers ? expense.total : expense.partialValue + )} {/* Prazo */} diff --git a/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx b/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx index 39d07e04..028c8300 100644 --- a/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx +++ b/frontend/src/pages/Groups/GroupDetails/GroupSettings/index.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Link, useParams } from 'react-router'; import { FiArrowLeft, @@ -17,28 +17,45 @@ import Modal from '../../../../components/Modal'; import { MembersManager } from './MembersManager'; import { useGroupByIdQuery } from '../../../../hooks/ReactQuery/Queries/useGroupsQuery'; import { useUpdateGroupMutation } from '../../../../hooks/ReactQuery/Mutations/useUpdateGroupMutation'; +import { CategoryIcon } from '../../../../mockData/groupIcons/icons'; export default function GroupSettings() { const { group_id } = useParams(); const [isOpen, setIsOpen] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [isChanged, setIsChanged] = 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 + // Inicializa os campos com os valores do grupo + useEffect(() => { + if (group) { + setName(group.name); + setDescription(group.description); + setIsChanged(false); // Inicialmente não há alterações + } + }, [group]); + + // Atualiza o estado de alteração + useEffect(() => { + if (!group) return; + const changed = + name.trim() !== group.name || description.trim() !== group.description; + setIsChanged(changed && name.trim() !== ''); + }, [name, description, group]); + const handleSubmit = async e => { e.preventDefault(); - const formData = new FormData(e.target); - const payload = { - name: formData.get('name'), - description: formData.get('description'), - }; + if (!isChanged) return; // Segurança extra try { - await updateGroup({ group_id, data: payload }); + await updateGroup({ + group_id, + data: { name: name.trim(), description: description.trim() }, + }); + setIsChanged(false); // Reset após salvar } catch (error) { console.error('Erro ao atualizar grupo:', error); } @@ -53,22 +70,22 @@ export default function GroupSettings() { } return ( -
-
+
+
{/* Cabeçalho */} -
-
+
+
-
- +
+

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

-

- Defina as permissões, ajuste as regras e garanta o controle total - sobre o seu grupo. +

+ Defina as permissões, ajuste as regras e tenha controle total + sobre seu grupo.

@@ -76,7 +93,7 @@ export default function GroupSettings() { {/* Formulário principal */}
{/* Informações básicas */} -
+
setName(e.target.value)} + placeholder="Digite um novo nome" + className="transition-all focus:ring-2 focus:ring-primary/40 rounded-xl focus:shadow-md" /> @@ -97,29 +115,30 @@ export default function GroupSettings() { > setDescription(e.target.value)} rows={4} placeholder="Adicione uma breve descrição..." - 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" + className="w-full min-h-[100px] rounded-lg border border-border bg-background px-3 py-2 text-text transition-all duration-200 focus:border-primary focus:ring-2 focus:ring-primary/30 placeholder:text-muted resize-none focus:shadow-md" />
- {/* Acesso ao Gerenciamento de Membros */} -
+ {/* Gerenciamento de membros */} +

- Gerenciar membros do grupo + Gerenciar membros

- Adicione, remova ou altere as permissões dos membros do seu grupo. + Adicione, remova ou altere permissões dos membros do 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" + className="flex cursor-pointer items-center justify-center gap-2 mt-4 px-5 py-3 rounded-xl bg-linear-to-tr from-primary/70 to-primary/50 text-white hover:from-primary/80 hover:to-primary/60 hover:scale-[1.03] transition-all " > - Abrir Gerenciador de Membros + Abrir Gerenciador Voltar @@ -144,9 +163,16 @@ export default function GroupSettings() { {isPending ? 'Salvando...' : 'Salvar'} diff --git a/frontend/src/pages/Groups/GroupDetails/index.jsx b/frontend/src/pages/Groups/GroupDetails/index.jsx index eea135a6..df627a91 100644 --- a/frontend/src/pages/Groups/GroupDetails/index.jsx +++ b/frontend/src/pages/Groups/GroupDetails/index.jsx @@ -1,13 +1,16 @@ -import { useNavigate, useParams } from 'react-router'; +import { useParams } from 'react-router'; import { useState } from 'react'; -import { FiSettings, FiEdit2, FiTrash2 } from 'react-icons/fi'; +import { + FiSettings, + FiEdit2, + FiTrash2, + FiLogOut, + FiUserPlus, +} from 'react-icons/fi'; import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'; -/* import ButtonUI from '../../../components/ui/Button'; -import BalancesList from './BalancesList'; */ import ExpensesList from './ExpensesList'; import Expenses from '../../Expenses'; -import ModalButton from '../../../components/Modal/ModalButton'; import { CategoryIcon } from '../../../mockData/groupIcons/icons'; import Modal, { ConfirmModal } from '../../../components/Modal'; @@ -17,35 +20,75 @@ import { useGroupByIdQuery } from '../../../hooks/ReactQuery/Queries/useGroupsQu import { useDeleteGroupMutation } from '../../../hooks/ReactQuery/Mutations/useDeleteGroupMutation'; import GroupFinancialStatus from '../GrupoFinancialStatus'; import ButtonUI from '../../../components/ui/Button'; +import { NotFoundError } from '../../../utils/errors'; +import { useDeleteMeGroupByIdMutation } from '../../../hooks/ReactQuery/Mutations/useDeleteMeGroupByIdMutation'; +import AddMemberForm from './AddMember'; +import { addMemberGroupMutation } from '../../../hooks/ReactQuery/Mutations/addMemberGroupMutation'; export default function GroupDetails() { const [groupModalIsOpen, setGroupModalIsOpen] = useState(false); + const [showMemberModal, setShowMemberModal] = useState(false); const { group_id } = useParams(); - const [isOpenModal, setIsOpenModal] = useState(false); - const { data: group } = useGroupByIdQuery(group_id); + const { data: group, isError } = useGroupByIdQuery(group_id); + const { mutateAsync: leaveGroupMutation } = + useDeleteMeGroupByIdMutation(group_id); + const { mutateAsync: addMemberMutation } = addMemberGroupMutation(group_id); + // Estado genérico do ConfirmModal + const [confirmModal, setConfirmModal] = useState({ + isOpen: false, + message: '', + confirmLabel: 'Confirmar', + onConfirm: () => {}, + }); + + if (isError) { + throw new NotFoundError( + 'O grupo que você tentou acessar não existe.', + 404, + { + label: 'Voltar para os grupos', + path: '/groups', + } + ); + } const { authorization } = group; - const { canEditGroupInfo, canDeleteGroup, canCreateExpenses } = - usePermissions(authorization); + const { + canEditGroupInfo, + canAddMemberGroup, + canDeleteGroup, + canCreateExpenses, + } = usePermissions(authorization); const deleteGroup = useDeleteGroupMutation(group_id); - /* if (isError) { - return navigate('/404', { replace: true }); - } */ + + function openConfirmModal({ message, confirmLabel, onConfirm }) { + setConfirmModal({ isOpen: true, message, confirmLabel, onConfirm }); + } + + async function handleLeaveGroup() { + try { + await leaveGroupMutation(); + } catch {} + } + return (
+ {/* Modal genérico */} setIsOpenModal(false)} + isOpen={confirmModal.isOpen} + onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))} onConfirm={() => { - deleteGroup.mutate(); + confirmModal.onConfirm(); + setConfirmModal(prev => ({ ...prev, isOpen: false })); }} - confirmLabel="Excluir" - message={`Tem certeza de que deseja excluir o grupo "${group?.name}"? Essa ação não pode ser desfeita.`} + confirmLabel={confirmModal.confirmLabel} + message={confirmModal.message} /> + {/* Cabeçalho */}
@@ -64,16 +107,16 @@ export default function GroupDetails() {
{/* Menu de ações */} - {canEditGroupInfo && canDeleteGroup && ( - - - - - - + + + + + + + {canEditGroupInfo && ( {({ active }) => ( )} + )} + + {canAddMemberGroup && ( + + {({ active }) => ( + + )} + + )} + + {canDeleteGroup && ( {({ active }) => ( )} - - - )} + )} + + + {({ active }) => ( + + )} + + +
@@ -131,6 +219,25 @@ export default function GroupDetails() { /> )} + + {/* Modal genérico para adicionar membro (vazio por enquanto) */} + setShowMemberModal(false)} + autoCloseOnSuccess={false} + children={ + { + // Aqui você chamaria sua mutation para adicionar membro + try { + await addMemberMutation({ email, authorization }); + } catch {} + console.log({ email, authorization }); + }} + onCancel={() => setShowMemberModal(false)} + /> + } + />
); } diff --git a/frontend/src/pages/Groups/GroupDetails/usePermissions.js b/frontend/src/pages/Groups/GroupDetails/usePermissions.js index 690ce08c..8424ec6b 100644 --- a/frontend/src/pages/Groups/GroupDetails/usePermissions.js +++ b/frontend/src/pages/Groups/GroupDetails/usePermissions.js @@ -9,10 +9,12 @@ export function usePermissions(role) { canEditExpenses: false, canEditExpenseValue: false, canDeleteExpenses: false, + canChangeStatusExpenses: false, canManageMembers: false, canEditGroupInfo: false, canDeleteGroup: false, canGenerateReports: false, + canAddMemberGroup: false, // nova permissão }; } @@ -23,9 +25,11 @@ export function usePermissions(role) { canEditExpenses: hasPermission(role, 'EDIT_EXPENSES'), canEditExpenseValue: hasPermission(role, 'EDIT_EXPENSE_VALUE'), canDeleteExpenses: hasPermission(role, 'DELETE_EXPENSES'), + canChangeStatusExpenses: hasPermission(role, 'CHANGE_STATUS_EXPENSES'), canManageMembers: hasPermission(role, 'MANAGE_MEMBERS'), canEditGroupInfo: hasPermission(role, 'EDIT_GROUP_INFO'), canDeleteGroup: hasPermission(role, 'DELETE_GROUP'), canGenerateReports: hasPermission(role, 'GENERATE_REPORTS'), + canAddMemberGroup: hasPermission(role, 'ADD_GROUP_MEMEBER'), // lógica adicionada }; } diff --git a/frontend/src/pages/Groups/GrupoFinancialStatus.jsx b/frontend/src/pages/Groups/GrupoFinancialStatus.jsx index cbf468ad..df15cfba 100644 --- a/frontend/src/pages/Groups/GrupoFinancialStatus.jsx +++ b/frontend/src/pages/Groups/GrupoFinancialStatus.jsx @@ -18,7 +18,10 @@ export default function GroupFinancialStatus({ group, variant = 'compact' }) { - {formatBRL(group?.total)} + {formatBRL(group?.total, { + style: 'decimal', + notation: 'compact', + })} @@ -29,7 +32,10 @@ export default function GroupFinancialStatus({ group, variant = 'compact' }) { group?.partial_total <= 0 ? 'text-success' : 'text-error' }`} > - {formatBRL(group?.partial_total)} + {formatBRL(group?.partial_total, { + style: 'decimal', + notation: 'compact', + })}
diff --git a/frontend/src/pages/Groups/index.jsx b/frontend/src/pages/Groups/index.jsx index e614cb0a..ce74dffd 100644 --- a/frontend/src/pages/Groups/index.jsx +++ b/frontend/src/pages/Groups/index.jsx @@ -96,7 +96,7 @@ export default function Groups() { {filteredGroups.length > 0 ? ( <>
@@ -110,7 +110,7 @@ export default function Groups() {

- {status} + {error.status}

-

{statusText}

+

{error.statusText}

- {message} + {error.message}

- {btnText} + {error.btn.label}
diff --git a/frontend/src/pages/Profile/ActiveGroups/index.jsx b/frontend/src/pages/Profile/ActiveGroups/index.jsx index 5c902981..a60982d6 100644 --- a/frontend/src/pages/Profile/ActiveGroups/index.jsx +++ b/frontend/src/pages/Profile/ActiveGroups/index.jsx @@ -14,7 +14,7 @@ export default function ActiveGroups() { if (isLoading) { return (
- + Carregando grupos...
); @@ -25,18 +25,16 @@ export default function ActiveGroups() { if (groups.length === 0) { return ( -
- -

+

+ +

Você ainda não faz parte de nenhum grupo.

Criar grupo @@ -45,30 +43,37 @@ export default function ActiveGroups() { } return ( -
+
{groupsToShow.map(group => ( - + {/* Círculo do ícone */} +
- - {group.name} +
+ + {/* Nome do grupo */} + {group.name} ))} {hasMoreGroups && ( +{groupsLength - groupLimit} mais diff --git a/frontend/src/services/groups.js b/frontend/src/services/groups.js index d6a8bd8e..718445d6 100644 --- a/frontend/src/services/groups.js +++ b/frontend/src/services/groups.js @@ -64,7 +64,7 @@ export const getGroupById = async id => { return response.data; } catch (error) { console.error(error); - throw new Error('Erro ao obter grupo - id:', id); + throw error; } }; //Obter todas as categorias de despesa de um grupo @@ -133,11 +133,7 @@ export const getGroupExpenseById = async (group_id, 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 - ); + throw error; } }; @@ -215,3 +211,22 @@ export const getGroupUserAuthenticatedAuthority = async (group_id, userId) => { throw error; } }; +export const leaveGroup = async group_id => { + try { + const response = await apiApplication.delete(`/groups/${group_id}/members`); + return response.data; + } catch (error) { + throw error; + } +}; +export const addMemberGroup = async (group_id, data) => { + try { + const response = await apiApplication.post( + `/groups/${group_id}/members`, + data + ); + return response.data; + } catch (error) { + throw error; + } +}; diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index da7db199..4933e032 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -10,8 +10,9 @@ const PERMISSIONS = { VIEW_ALL_EXPENSES: [ROLES.admin, ROLES.owner], EDIT_EXPENSES: [ROLES.admin, ROLES.owner], EDIT_EXPENSE_VALUE: [ROLES.owner], - DELETE_EXPENSES: [ROLES.admin, ROLES.owner], + DELETE_EXPENSES: [ROLES.owner], CREATE_EXPENSES: [ROLES.admin, ROLES.owner], + CHANGE_STATUS_EXPENSES: [ROLES.owner], // 👥 Membros MANAGE_MEMBERS: [ROLES.admin, ROLES.owner], @@ -19,6 +20,7 @@ const PERMISSIONS = { // 🏠 Grupo EDIT_GROUP_INFO: [ROLES.owner], DELETE_GROUP: [ROLES.owner], + ADD_GROUP_MEMEBER: [ROLES.owner, ROLES.admin], // 📊 Extras GENERATE_REPORTS: [ROLES.owner], diff --git a/frontend/src/utils/errors.js b/frontend/src/utils/errors.js new file mode 100644 index 00000000..38160bee --- /dev/null +++ b/frontend/src/utils/errors.js @@ -0,0 +1,115 @@ +// Classe base para erros de aplicação +export class AppError extends Error { + constructor( + message, + status = 500, + statusText = 'Erro inesperado', + btn = { label: 'Voltar ao início', path: '/' } + ) { + super(message); + this.name = 'AppError'; + this.status = status; + this.statusText = statusText; + this.btn = btn; + } + + // 🔁 Método estático para interpretar diferentes tipos de erro + static from(error) { + // Caso 1 — Erro já convertido + if (error instanceof AppError) return error; + + // Caso 2 — Erro do React Router + if (error?.status) { + switch (error.status) { + case 400: + return new AppError( + 'A solicitação não pôde ser processada. Verifique os dados enviados.', + 400, + 'Requisição inválida' + ); + case 401: + return new UnauthorizedError(); + case 403: + return new ForbiddenError(); + case 404: + return new NotFoundError(); + case 500: + return new ServerError(); + default: + return new AppError( + error.data?.message || + 'Algo deu errado. Tente novamente mais tarde.', + error.status, + error.statusText || 'Erro inesperado' + ); + } + } + + // Caso 3 — Erros com formato Axios-like + if (error?.response?.data) { + const { status, title, message, btn } = error.response.data; + return new AppError( + message || 'Ocorreu um erro inesperado.', + status || 500, + title || 'Erro inesperado', + btn || { label: 'Voltar ao início', path: '/' } + ); + } + + // Caso 4 — Erros JS comuns + if (error instanceof Error) { + return new AppError(error.message); + } + + // Fallback total + return new AppError('Algo deu errado. Tente novamente mais tarde.'); + } +} + +// 404 - Não encontrado +export class NotFoundError extends AppError { + constructor( + message = 'Recurso não encontrado.', + status = 404, + btn = { label: 'Voltar ao início', path: '/' } + ) { + super(message, status, 'Página não encontrada', btn); + this.name = 'NotFoundError'; + } +} + +// 403 - Proibido +export class ForbiddenError extends AppError { + constructor( + message = 'Você não tem autorização para visualizar esta página.', + status = 403, + btn = { label: 'Voltar à página anterior', path: '/' } + ) { + super(message, status, 'Acesso negado!', btn); + this.name = 'ForbiddenError'; + } +} + +// 401 - Não autorizado +export class UnauthorizedError extends AppError { + constructor( + message = 'Você precisa estar autenticado para acessar esta página.', + status = 401, + btn = { label: 'Ir para login', path: '/' } + ) { + super(message, status, 'Não autorizado', btn); + this.name = 'UnauthorizedError'; + } +} + +// 500 - Erro interno do servidor +export class ServerError extends AppError { + constructor( + message = 'Ocorreu um problema inesperado no servidor. Tente novamente mais tarde.', + status = 500, + btn = { label: 'Voltar ao início', path: '/' } + ) { + super(message, status, 'Erro interno do servidor', btn); + this.name = 'ServerError'; + } +} diff --git a/frontend/src/utils/formatters.js b/frontend/src/utils/formatters.js index 72faa99a..3ade0224 100644 --- a/frontend/src/utils/formatters.js +++ b/frontend/src/utils/formatters.js @@ -4,13 +4,38 @@ export const parseBRL = value => { return parseFloat(value) || 0; }; -// Converte de número (1234.56) para string formatada (R$ 1.234,56) -export const formatBRL = value => { - const numericValue = typeof value === 'string' ? parseFloat(value) : value; - return new Intl.NumberFormat('pt-BR', { - style: 'currency', - currency: 'BRL', - }).format(isNaN(numericValue) ? 0 : numericValue); +export const formatBRL = ( + value, + { + locale = 'pt-BR', + currency = 'BRL', + style = 'currency', + notation = 'standard', + compactDisplay = undefined, + minimumFractionDigits = 2, + maximumFractionDigits = 2, + } = {} +) => { + let numericValue; + + if (typeof value === 'bigint') { + numericValue = Number(value); + } else if (typeof value === 'string') { + numericValue = parseFloat(value.replace(',', '.')); + } else { + numericValue = value; + } + + if (isNaN(numericValue)) numericValue = 0; + + return new Intl.NumberFormat(locale, { + style, + currency, + notation, + compactDisplay, + minimumFractionDigits, + maximumFractionDigits, + }).format(numericValue); }; export function formatDateBR(dateString) {