diff --git a/frontend/package.json b/frontend/package.json index d4beefc..32bad6e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,12 +23,14 @@ "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-persist-client": "^5.90.7", "axios": "^1.12.2", + "big.js": "^5.2.1", "jwt-decode": "^4.0.0", "react": "^19.1.0", "react-color": "^2.19.3", "react-currency-input-field": "^3.10.0", "react-dom": "^19.1.0", "react-icons": "^5.5.0", + "react-intl-currency-input": "^0.2.6", "react-router": "^7.6.3", "react-toastify": "^11.0.5", "recharts": "^3.1.0", diff --git a/frontend/src/components/ChartSwitcher/ExpenseChart.jsx b/frontend/src/components/ChartSwitcher/ExpenseChart.jsx index a5fb041..0c1eed2 100644 --- a/frontend/src/components/ChartSwitcher/ExpenseChart.jsx +++ b/frontend/src/components/ChartSwitcher/ExpenseChart.jsx @@ -1,67 +1,54 @@ -import { - PieChart, - Pie, - Cell, - Tooltip, - Legend, - ResponsiveContainer, -} from 'recharts'; -import userData from '../../mockData/user/user.data'; +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; +import { avatarBackgroundColors } from '../../mockData/colorsPallete/colors'; +import { useMeDashboardQuery } from '../../hooks/ReactQuery/useMeDashboardStatsQuery'; -const COLORS = [ - '#ff69b4', // Rosa vibrante - '#ffb6c1', // Rosa claro - '#ffd700', // Dourado - '#ba55d3', // Roxo médio - '#87cefa', // Azul claro - '#ffa07a', // Coral suave -]; - -export default function ExpensePieChart() { - const expenseData = userData.dashboard.chartsData.expenseData; +export default function ExpenseQuantityDonutChart() { + const { data: expenseData } = useMeDashboardQuery(); + const total = expenseData?.reduce((sum, item) => sum + item.quantity, 0) || 0; return ( - - - {/* Pie principal */} - - {expenseData.map((entry, index) => ( - - ))} - - - {/* Tooltip */} - +
+ + + + `${name}: ${expenseData.find(c => c.category === name)?.quantity}` + } + labelLine={true} + > + {expenseData?.map((_, index) => ( + + ))} + + [`${value}`, 'Quantidade de despesas']} + /> + + - {/* Legenda */} - - - +
+
+

Total

+

{total}

+
+
+
); } diff --git a/frontend/src/components/ChartSwitcher/index.jsx b/frontend/src/components/ChartSwitcher/index.jsx index a7378a0..9ffcdb9 100644 --- a/frontend/src/components/ChartSwitcher/index.jsx +++ b/frontend/src/components/ChartSwitcher/index.jsx @@ -36,7 +36,7 @@ export default function ChartSwitcher() { {/* Área do gráfico */} -
+
{activeChart === 'balance' ? : }
diff --git a/frontend/src/components/Filters/Groups/useFilteredGroups.js b/frontend/src/components/Filters/Groups/useFilteredGroups.js index 3847335..f3aadcf 100644 --- a/frontend/src/components/Filters/Groups/useFilteredGroups.js +++ b/frontend/src/components/Filters/Groups/useFilteredGroups.js @@ -1,13 +1,13 @@ import { useMemo } from 'react'; -export function useFilteredGroups(groups, currentUserId, filters) { +export function useFilteredGroups(groups, role, filters) { const { search, onlyOwner, sortOrder } = filters; return useMemo(() => { return groups ?.filter( group => - (!onlyOwner || group.ownerId === currentUserId) && + (!onlyOwner || group.authority === role) && group.name.toLowerCase().includes(search.trim().toLowerCase()) ) .sort((a, b) => { @@ -15,5 +15,5 @@ export function useFilteredGroups(groups, currentUserId, filters) { const bDate = new Date(b.createdAt).getTime(); return sortOrder === 'asc' ? aDate - bDate : bDate - aDate; }); - }, [groups, currentUserId, filters]); + }, [groups, role, filters]); } diff --git a/frontend/src/components/LatestExpenses/index.jsx b/frontend/src/components/LatestExpenses/index.jsx index 340f7d0..095bd1d 100644 --- a/frontend/src/components/LatestExpenses/index.jsx +++ b/frontend/src/components/LatestExpenses/index.jsx @@ -1,17 +1,13 @@ import { Menu, MenuItem } from '@headlessui/react'; -import userData from '../../mockData/user/user.data'; import { formatBRL, formatDateBR, formatRelativeDate, } from '../../utils/formatters'; -import useMeQuery from '../../hooks/ReactQuery/useMeQuery'; +import useMeExpensesQuery from '../../hooks/ReactQuery/useMeExpensesQuery'; export default function LatestExpenses() { - const current_user = userData; - const { data: me } = useMeQuery(); - const myExpenses = me?.totalExpenses; - console.log(myExpenses); + const { data: myExpenses } = useMeExpensesQuery(); return (
+
{'<'} @@ -18,7 +18,7 @@ export default function Pagination({ page, totalPages, onNext, onPrev }) { {'>'} diff --git a/frontend/src/components/forms/ExpenseForm/CustomSplitAmount/index.jsx b/frontend/src/components/forms/ExpenseForm/CustomSplitAmount/index.jsx index 6affcd7..7d61bcb 100644 --- a/frontend/src/components/forms/ExpenseForm/CustomSplitAmount/index.jsx +++ b/frontend/src/components/forms/ExpenseForm/CustomSplitAmount/index.jsx @@ -1,54 +1,139 @@ +import { useEffect, useState } from 'react'; import { useFormExpense } from '../useForm'; import CurrencyInputUI from '../../../ui/CurrencyInput'; import { formatBRL } from '../../../../utils/formatters'; +import { FiCheck } from 'react-icons/fi'; export default function CustomSplitAmount({ members }) { const { + amount, divisionAmount, - updateMemberShare, - remainingDifference, redistributeEvenly, + getRemainingDifference, + isBalanced, } = useFormExpense(); + // Divide igualmente apenas na primeira renderização + useEffect(() => { + if (members?.length && amount > 0) { + const hasAnyValue = Object.values(divisionAmount).some(v => v?.float > 0); + if (!hasAnyValue) redistributeEvenly(); + } + }, []); // executa apenas 1x + + const remainingDifference = getRemainingDifference(); + const balanced = isBalanced(); + return ( -
-

Valores por pessoa

-
- {members.map(({ id, name }) => ( -
- {name} - { - updateMemberShare(name, values); - }} - className="w-full p-2 border rounded" +
+

+ 💰 Distribuição personalizada +

+ +
+ {members.map(({ id, name }) => { + const member = divisionAmount[id] || { float: 0, formatted: '' }; + return ( + -
- ))} + ); + })} +
+ + {/* Status de equilíbrio */} + {amount > 0 && ( +
+ {balanced ? ( + + Distribuição correta + + ) : ( +
+

+ ⚠️ Diferença restante:{' '} + + {formatBRL(remainingDifference)} + +

+ +
+ )} +
+ )} +
+ ); +} + +function CustomMemberInput({ balanced, member: { id, member, name } }) { + const { updateMemberShare } = useFormExpense(); + const [localValue, setLocalValue] = useState(''); + const [hasChanged, setHasChanged] = useState(false); + + const valueColor = + member.float === 0 + ? 'text-muted' + : balanced + ? 'text-success' + : 'text-error'; + + // função chamada ao confirmar manualmente + const handleConfirm = () => { + updateMemberShare(id, localValue); + setHasChanged(false); + }; + + return ( +
+
+ {name} + + Participação: {member.float ? formatBRL(member.float) : 'R$ 0,00'} +
-
- {Math.abs(remainingDifference) < 0.01 ? ( - '✓ Distribuição correta' - ) : ( - <> -

⚠️ Diferença: {formatBRL(remainingDifference)}

- - +
+ { + setLocalValue(values?.float ?? ''); + setHasChanged(true); + }} + className="flex-1 p-2 border border-border rounded-md bg-background focus:ring-2 focus:ring-primary/40 transition outline-none" + /> + + {/* Botão de confirmação aparece só se houver mudança */} + {hasChanged && localValue !== '' && ( + )}
diff --git a/frontend/src/components/forms/ExpenseForm/FormFieldsExpenses/index.jsx b/frontend/src/components/forms/ExpenseForm/FormFieldsExpenses/index.jsx index e593ff0..2002618 100644 --- a/frontend/src/components/forms/ExpenseForm/FormFieldsExpenses/index.jsx +++ b/frontend/src/components/forms/ExpenseForm/FormFieldsExpenses/index.jsx @@ -1,17 +1,16 @@ import categories from '../../../../mockData/categories'; - import { getCurrentDate } from '../../../../utils/helpers'; import InputUI from '../../../ui/Input'; import SelectUI from '../../../ui/Select'; import TextareaUI from '../../../ui/Textarea'; -import { useFormExpense } from '../useForm'; import CurrencyInputUI from '../../../ui/CurrencyInput'; +import { useFormExpense } from '../useForm'; -export default function FormFieldsExpenses({ members }) { +export default function FormFieldsExpenses({ data: members }) { const { setAmount } = useFormExpense(); const handleAmountChange = ({ float: numericValue }) => { - // Converte o valor formatado para número + if (numericValue == null) return; setAmount(numericValue); }; @@ -20,6 +19,7 @@ export default function FormFieldsExpenses({ members }) {
{/* Título e Valor */}
+ {/* Título */}
+ {/* Valor */}
- handleAmountChange(values) - } name="total_amount" + allowNegativeValue={false} placeholder="R$ 0,00" required - className="w-full p-2 border border-neutral rounded-md text-text placeholder-muted focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition" + className="w-full p-2 border border-neutral rounded-md text-text placeholder-muted + focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition" + onValueChange={(_, __, values) => handleAmountChange(values)} />
- {/* Restante do formulário permanece igual */} + {/* Data */}
+ {/* Descrição */}
+ {/* Categoria */}
-
- - - {members.map((member, i) => ( - - ))} - -
+ {/* Quem pagou */} + {/* {members?.length > 0 && ( +
+ + + {members.map(member => ( + + ))} + +
+ )} */}
); diff --git a/frontend/src/components/forms/ExpenseForm/ListMembers/index.jsx b/frontend/src/components/forms/ExpenseForm/ListMembers/index.jsx new file mode 100644 index 0000000..a83ac0c --- /dev/null +++ b/frontend/src/components/forms/ExpenseForm/ListMembers/index.jsx @@ -0,0 +1,161 @@ +import { useState, useEffect, useMemo } from 'react'; +import { FiSearch } from 'react-icons/fi'; +import ButtonUI from '../../../ui/Button'; + +import useMeQuery from '../../../../hooks/ReactQuery/useMeQuery'; +import { useMembersQuery } from '../../../../hooks/ReactQuery/useMembersQuery'; +import InputUI from '../../../ui/Input'; +import Pagination from '../../../PaginationController'; + +export default function ListMembers({ groupID, onClose, onConfirm }) { + const [search, setSearch] = useState(''); // termo final para API + const [inputValue, setInputValue] = useState(''); // valor do input enquanto digita + const [page, setPage] = useState(0); + + const { data: user } = useMeQuery(); + const { + data: { members, totalPages } = { members: [], totalPages: 0 }, + isLoading, + } = useMembersQuery(groupID, page, search); + + const [checkedMembers, setCheckedMembers] = useState([]); + + const excludeSelf = false; // se excluir da lista de membros + + const filteredMembers = useMemo(() => { + if (!members) return []; + return excludeSelf ? members.filter(m => m.id !== user?.id) : members; + }, [members, user, excludeSelf]); + + // Alterna seleção de membros + const toggleMember = id => { + 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 + + // Reseta seleção ao mudar de página ou busca + useEffect(() => { + setCheckedMembers([]); + }, [page, search]); + + return ( +
+ {/* Campo de busca */} +
+ + setInputValue(e.target.value)} + className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary transition" + /> +
+ + {/* Lista de membros */} +
+ {isLoading ? ( +

+ Carregando membros... +

+ ) : filteredMembers.length > 0 ? ( + filteredMembers.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' + } + `} + > + {/* Avatar + nome */} +
+
+ {member.name[0].toUpperCase()} +
+
+ + {member.name} + + {member.role && ( + + {member.role} + + )} +
+
+ + {/* Rótulo de seleção */} + {isSelected && ( + + Selecionado + + )} +
+ ); + }) + ) : ( +

+ Nenhum membro encontrado +

+ )} +
+ + {/* Paginação simples */} + {totalPages > 1 && ( + setPage(prev => Math.min(prev + 1, totalPages - 1))} + onPrev={() => setPage(prev => Math.max(prev - 1, 0))} + page={page} + totalPages={totalPages} + /> + )} + + {/* Botões de ação */} +
+ + onConfirm( + filteredMembers.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 + ? 'opacity-50 bg-gray-400! cursor-not-allowed!' + : '' + }`} + > + Confirmar + +
+
+ ); +} diff --git a/frontend/src/components/forms/ExpenseForm/index.jsx b/frontend/src/components/forms/ExpenseForm/index.jsx index e477ed5..e927913 100644 --- a/frontend/src/components/forms/ExpenseForm/index.jsx +++ b/frontend/src/components/forms/ExpenseForm/index.jsx @@ -1,88 +1,112 @@ -import { Form, useParams } from 'react-router'; +import { Form } from 'react-router'; import FormFieldsExpenses from './FormFieldsExpenses'; import ButtonUI from '../../ui/Button'; import CustomSplitAmount from './CustomSplitAmount'; import { useFormExpense } from './useForm'; import { useEffect, useState, useCallback } from 'react'; -import userData from '../../../mockData/user/user.data'; import { customToast } from '../../CustomToast'; +import Modal from '../../Modal'; +import ListMembers from './ListMembers'; +import { FiUserPlus, FiX } from 'react-icons/fi'; +/* import useMeQuery from '../../../hooks/ReactQuery/useMeQuery'; */ +import { useMembersQuery } from '../../../hooks/ReactQuery/useMembersQuery'; +import { useCreateExpenseMutation } from '../../../hooks/ReactQuery/useCreateExpenseMutation'; -export default function ExpenseForm() { - const params = useParams(); - const group = userData.groups.find(({ id }) => id === params['group-id']); - const groupMembers = group?.members || []; +export default function ExpenseForm({ groupData: group }) { + /* const { data: user } = useMeQuery(); */ + const useExpenseMutation = useCreateExpenseMutation(group?.id); + const { + data: { members: groupMembers }, + } = useMembersQuery(group?.id, 0, ''); - const { setMembers, amount, divisionAmount, remainingDifference, reset } = + const { setMembers, amount, divisionAmount, reset, getRemainingDifference } = useFormExpense(); const [isSubmitting, setIsSubmitting] = useState(false); + const [selectedMembers, setSelectedMembers] = useState([]); + const [showMemberModal, setShowMemberModal] = useState(false); - // Inicializa membros do grupo - useEffect(() => { - /* setMembers(groupMembers.map(m => m.name)); */ - }, [groupMembers, setMembers]); - - // Reseta store ao desmontar + // 🔄 Reseta store ao desmontar useEffect(() => reset, [reset]); - const isDistributionValid = remainingDifference <= 0.01; + // 👥 Atualiza membros selecionados + useEffect(() => { + setMembers(selectedMembers.length > 0 ? selectedMembers : []); + }, [selectedMembers, setMembers]); + + // ⚖️ Verifica se a distribuição está correta + const distributionOK = + selectedMembers.length > 0 && Math.abs(getRemainingDifference()) < 0.01; + // 📝 Envio do formulário const handleExpenseSubmit = useCallback( async e => { e.preventDefault(); + + if (!distributionOK) { + return customToast( + 'Erro', + 'A distribuição de valores não corresponde ao total da despesa.', + 'error' + ); + } + setIsSubmitting(true); try { const formData = new FormData(e.target); const formValues = Object.fromEntries(formData); - // Validações - ['title_expense', 'date', 'payer_id'].forEach(field => { - if (!formValues[field]) throw new Error(`${field} é obrigatório.`); - }); - - // Monta payload const expenseData = { title: formValues.title_expense, description: formValues.description || '', - date: formValues.date, - amount: Number(amount), - payer_id: Number(formValues.payer_id), - category_id: Number(formValues.category_id), - group_id: params['group-id'], - splits: Object.entries(divisionAmount).map(([member, { float }]) => ({ - member_name: member, - amount: float || 0, - })), + deadlineDate: formValues.date, + expenseValue: Number(amount), + categoryId: Number(formValues.category_id), + expenseDivision: Object.entries(divisionAmount).map( + ([memberId, val]) => ({ + id: Number(memberId), + value: val.float, + }) + ), }; - if (!isDistributionValid) { - throw new Error( - 'A distribuição de valores não corresponde ao total da despesa.' - ); - } + console.log('💾 Dados da despesa preparados:', expenseData); - console.log('Dados da despesa preparados:', expenseData); - // await api.post('/expenses', expenseData); - customToast( - 'Nova despesa', - 'Despesa adicionada com sucesso!', - 'success' - ); + await useExpenseMutation.mutateAsync(expenseData, { + onSuccess: () => + customToast( + 'Nova despesa', + 'Despesa adicionada com sucesso!', + 'success' + ), + onError: ({ response: { data: error } }) => { + console.log({ error }); + customToast(error.title, error.message, 'error'); + }, + onSettled: () => setIsSubmitting(false), + }); } catch (err) { console.error(err); - alert(`Erro: ${err.message}`); - } finally { + customToast( + 'Erro', + err?.message || 'Ocorreu um erro inesperado.', + 'error' + ); setIsSubmitting(false); } }, - [amount, divisionAmount, isDistributionValid, params, groupMembers] + [amount, divisionAmount, distributionOK] ); + const removeMember = memberId => { + setSelectedMembers(prev => prev.filter(m => m.id !== memberId)); + }; + return ( -
- {/* Título */} -
+
+ {/* Cabeçalho */} +

Adicionar nova despesa

@@ -90,7 +114,7 @@ export default function ExpenseForm() { Preencha os detalhes abaixo para adicionar uma despesa ao grupo{' '} {group?.name || ''}.

-
+
- - + {/* Campos principais */} + + {/* Seleção de membros */} +
+ {selectedMembers.length === 0 && groupMembers?.length > 0 && ( + setShowMemberModal(true)} + className="flex items-center gap-2 justify-center bg-secondary text-white py-2 px-4 rounded-lg hover:bg-secondary/90 focus:outline-none focus:ring-2 focus:ring-primary transition-shadow shadow-sm cursor-pointer" + > + + Selecionar participantes + + )} + + {selectedMembers.length > 0 && ( +
+ + {selectedMembers.length > 1 + ? 'Membros envolvidos' + : 'Membro envolvido'}{' '} + na despesa: + +
+ {selectedMembers.map(member => ( +
+ {member.name} + +
+ ))} +
+
+ )} +
+ + {/* Divisão personalizada */} + {selectedMembers.length > 0 && ( + + )} + + {/* Botão de envio */}
- {isSubmitting ? 'Enviando...' : 'Adicionar Despesa'} + {isSubmitting ? 'Enviando...' : 'Adicionar Despesa'}
+ + {/* Modal de seleção */} + setShowMemberModal(false)}> + { + setSelectedMembers(selected); + setMembers(selected); + setShowMemberModal(false); + }} + onClose={() => setShowMemberModal(false)} + /> +
); } diff --git a/frontend/src/components/forms/ExpenseForm/useForm.js b/frontend/src/components/forms/ExpenseForm/useForm.js index 2394258..564f632 100644 --- a/frontend/src/components/forms/ExpenseForm/useForm.js +++ b/frontend/src/components/forms/ExpenseForm/useForm.js @@ -6,42 +6,73 @@ import { export const useFormExpense = create((set, get) => ({ amount: 0, - members: [], - divisionAmount: {}, // { member: { float, formatted, value } } - remainingDifference: 0, + members: [], // array de ids + divisionAmount: {}, // { memberId: { float, formatted, value } } + // 💰 Atualiza o valor total da despesa setAmount: value => { - const amount = value === null ? 0 : value; - const divisionAmount = calculateEqualShares(amount, get().members); - set({ amount, divisionAmount, remainingDifference: 0 }); + const amount = Number(value) || 0; + const members = get().members; + const divisionAmount = calculateEqualShares(amount, members); + set({ amount, divisionAmount }); }, - /* setMembers: members => { - const divisionAmount = calculateEqualShares(get().amount, members); - set({ members, divisionAmount, remainingDifference: 0 }); - }, */ + // 👥 Atualiza os membros participantes + setMembers: members => { + const amount = get().amount; + const divisionAmount = calculateEqualShares(amount, members); + set({ members, divisionAmount }); + }, + + updateMemberShare: (memberId, raw) => { + // converte string para número + if (raw === '') return; + let floatValue = 0; + if (typeof raw === 'string') { + floatValue = parseFloat(raw.replace(',', '.')) || 0; + } else if (typeof raw === 'number') { + floatValue = raw; + } - updateMemberShare: (member, raw) => { - //raw:{float,formatted,value} - const divisionAmount = { ...get().divisionAmount, [member]: raw }; - const remainingDifference = calculateRemainingDifference( - get().amount, - divisionAmount - ); + // atualiza a store com formato consistente + const divisionAmount = { + ...get().divisionAmount, + [memberId]: { + float: floatValue, + formatted: floatValue.toFixed(2), // para mostrar no input + value: String(floatValue), // string “bruta” + }, + }; - set({ divisionAmount, remainingDifference }); + set({ divisionAmount }); }, + // ♻️ Redistribui os valores igualmente redistributeEvenly: () => { - const divisionAmount = calculateEqualShares(get().amount, get().members); - set({ divisionAmount, remainingDifference: 0 }); + const amount = get().amount; + const members = get().members; + const divisionAmount = calculateEqualShares(amount, members); + set({ divisionAmount }); }, + // 🔄 Resetar tudo reset: () => set({ amount: 0, members: [], divisionAmount: {}, - remainingDifference: 0, }), + + // 🧮 Soma total distribuída + getTotalAssigned: () => + Object.values(get().divisionAmount).reduce( + (acc, val) => acc + (val?.float || 0), + 0 + ), + + // ⚖️ Diferença restante + getRemainingDifference: () => get().amount - get().getTotalAssigned(), + + // ✅ Verifica se está equilibrado + isBalanced: () => Math.abs(get().getRemainingDifference()) < 0.01, })); diff --git a/frontend/src/components/ui/CurrencyInput.jsx b/frontend/src/components/ui/CurrencyInput.jsx index 3f37db4..f4de0be 100644 --- a/frontend/src/components/ui/CurrencyInput.jsx +++ b/frontend/src/components/ui/CurrencyInput.jsx @@ -3,12 +3,11 @@ import CurrencyInput from 'react-currency-input-field'; export default function CurrencyInputUI(props) { return ( diff --git a/frontend/src/hooks/ReactQuery/useCreateExpenseMutation.js b/frontend/src/hooks/ReactQuery/useCreateExpenseMutation.js new file mode 100644 index 0000000..ffa6f76 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/useCreateExpenseMutation.js @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { createExpense } from '../../services/expenses'; + +export function useCreateExpenseMutation(groupID) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: expenseData => createExpense(groupID, expenseData), + onSuccess: () => { + // Atualiza apenas as despesas do grupo específico + queryClient.invalidateQueries([REACTQUERY_KEYS.GROUPS.EXPENSES, groupID]); + }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/useGroupMembersQuery.js b/frontend/src/hooks/ReactQuery/useGroupMembersQuery.js new file mode 100644 index 0000000..76e8fc9 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/useGroupMembersQuery.js @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { getGroupMembers } from '../../services/groups'; + +export function useGroupMembersQuery(group_id) { + return useQuery({ + queryKey: [REACTQUERY_KEYS.GROUPS.MEMBERS, group_id], + queryFn: async () => await getGroupMembers(group_id), + staleTime: Infinity, + enabled: !!group_id, + placeholderData: { content: [] }, + }); +} diff --git a/frontend/src/hooks/ReactQuery/useGroupsQuery.js b/frontend/src/hooks/ReactQuery/useGroupsQuery.js index 6a301d2..adcecc5 100644 --- a/frontend/src/hooks/ReactQuery/useGroupsQuery.js +++ b/frontend/src/hooks/ReactQuery/useGroupsQuery.js @@ -4,7 +4,7 @@ import { getGroupById, getGroups } from '../../services/groups'; export function useGroupsQuery(page, size) { return useQuery({ - queryKey: [REACTQUERY_KEYS.GROUPS.ALL, page], + queryKey: [REACTQUERY_KEYS.GROUPS.ALL, { page, size }], queryFn: async () => await getGroups(page, size), staleTime: Infinity, placeholderData: { content: [] }, diff --git a/frontend/src/hooks/ReactQuery/useMeDashboardStatsQuery.js b/frontend/src/hooks/ReactQuery/useMeDashboardStatsQuery.js new file mode 100644 index 0000000..3432c72 --- /dev/null +++ b/frontend/src/hooks/ReactQuery/useMeDashboardStatsQuery.js @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { getMeDashboardStats } from '../../services/me'; + +export function useMeDashboardQuery() { + return useQuery({ + queryKey: [REACTQUERY_KEYS.USER.DASHBOARD], + queryFn: getMeDashboardStats, + placeholderData: [], + }); +} diff --git a/frontend/src/hooks/ReactQuery/useMeExpensesQuery.js b/frontend/src/hooks/ReactQuery/useMeExpensesQuery.js new file mode 100644 index 0000000..b9d9e6f --- /dev/null +++ b/frontend/src/hooks/ReactQuery/useMeExpensesQuery.js @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { getMeExpenses } from '../../services/me'; +import useMeQuery from './useMeQuery'; + +export default function useMeExpensesQuery() { + const { data: user } = useMeQuery(); + const userId = user?.id; + return useQuery({ + queryKey: [REACTQUERY_KEYS.USER.EXPENSES, userId], + queryFn: getMeExpenses, + enabled: !!userId, + }); +} diff --git a/frontend/src/hooks/ReactQuery/useMeQuery.js b/frontend/src/hooks/ReactQuery/useMeQuery.js index 9a12320..19c1d03 100644 --- a/frontend/src/hooks/ReactQuery/useMeQuery.js +++ b/frontend/src/hooks/ReactQuery/useMeQuery.js @@ -7,5 +7,6 @@ export default function useMeQuery() { queryKey: [REACTQUERY_KEYS.USER.ME], queryFn: getMe, staleTime: Infinity, + refetchInterval: false, }); } diff --git a/frontend/src/hooks/ReactQuery/useMembersQuery.js b/frontend/src/hooks/ReactQuery/useMembersQuery.js new file mode 100644 index 0000000..8c730aa --- /dev/null +++ b/frontend/src/hooks/ReactQuery/useMembersQuery.js @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { getMembers } 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], + queryFn: () => getMembers(groupID, page, search), + placeholderData: [], + enabled: !!groupID && page >= 0, + }); +}; diff --git a/frontend/src/libs/ReactQuery/keys.js b/frontend/src/libs/ReactQuery/keys.js index 6e72a20..507340c 100644 --- a/frontend/src/libs/ReactQuery/keys.js +++ b/frontend/src/libs/ReactQuery/keys.js @@ -1,9 +1,16 @@ export const REACTQUERY_KEYS = { USER: { ME: 'me', + EXPENSES: 'expenses', + DASHBOARD: 'dashboard', }, GROUPS: { ALL: 'allGroups', + MEMBERS: 'members', DETAILS: 'groupDetails', + EXPENSES: 'groupExpenses', + }, + MEMBERS: { + ALL: 'allMembers', }, }; diff --git a/frontend/src/libs/ReactQuery/queryClient.js b/frontend/src/libs/ReactQuery/queryClient.js index 122cc12..01ae1fe 100644 --- a/frontend/src/libs/ReactQuery/queryClient.js +++ b/frontend/src/libs/ReactQuery/queryClient.js @@ -1,7 +1,17 @@ import { QueryClient } from '@tanstack/react-query'; import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'; import { SecureLS } from '../../utils/localStorageEncryption'; -export const queryClient = new QueryClient(); + +const refetchInterval = 180000; // 3 minutos + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchInterval, + refetchOnWindowFocus: true, + }, + }, +}); export const localStoragePersistor = createAsyncStoragePersister({ storage: { diff --git a/frontend/src/pages/Expenses/index.jsx b/frontend/src/pages/Expenses/index.jsx index 3874d02..2b0dd3b 100644 --- a/frontend/src/pages/Expenses/index.jsx +++ b/frontend/src/pages/Expenses/index.jsx @@ -1,9 +1,9 @@ import ExpenseForm from '../../components/forms/ExpenseForm'; -export default function Expenses() { +export default function Expenses({ groupData }) { return (
- +
); } diff --git a/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js b/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js index e68368a..9da71a6 100644 --- a/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js +++ b/frontend/src/pages/Groups/GroupDetails/groupDetailsLoader.js @@ -1,17 +1,17 @@ import { REACTQUERY_KEYS } from '../../../libs/ReactQuery/keys'; import { queryClient } from '../../../libs/ReactQuery/queryClient'; -import { getGroupById } from '../../../services/groups'; +import { getGroupById, getGroupMembers } from '../../../services/groups'; -export async function groupDetailsLoader({ params, request }) { +export async function groupDetailsLoader({ params }) { const { group_id } = params; - // 3️⃣ Tenta pegar o grupo específico do cache de DETAILS + // 🔹 1. Tenta pegar o grupo do cache let group = queryClient.getQueryData([ REACTQUERY_KEYS.GROUPS.DETAILS, group_id, ]); - // 6️⃣ Se ainda não achou, busca no servidor + // 🔹 2. Se não tiver, busca e cacheia if (!group) { group = await queryClient.fetchQuery({ queryKey: [REACTQUERY_KEYS.GROUPS.DETAILS, group_id], @@ -19,6 +19,26 @@ export async function groupDetailsLoader({ params, request }) { }); } - console.log({ groupLoader: group }); - return group || 'Num achei'; + // 🔹 3. Tenta pegar os membros do cache + let members = queryClient.getQueryData([ + REACTQUERY_KEYS.GROUPS.MEMBERS, + group_id, + ]); + + // 🔹 4. Se não tiver no cache, busca e adiciona + if (!members) { + members = await queryClient.fetchQuery({ + queryKey: [REACTQUERY_KEYS.GROUPS.MEMBERS, group_id], + queryFn: () => getGroupMembers(group_id), + staleTime: Infinity, + }); + } + + // 🔹 5. Combina grupo + membros antes de retornar + const groupWithMembers = { + ...group, + members: members?.members || members || [], + }; + console.log({ groupWithMembers }); + return groupWithMembers; } diff --git a/frontend/src/pages/Groups/GroupDetails/index.jsx b/frontend/src/pages/Groups/GroupDetails/index.jsx index 1a8ecc4..4a162fb 100644 --- a/frontend/src/pages/Groups/GroupDetails/index.jsx +++ b/frontend/src/pages/Groups/GroupDetails/index.jsx @@ -19,6 +19,7 @@ import { CategoryIcon } from '../../../mockData/groupIcons/icons'; import { ConfirmModal } from '../../../components/Modal'; import { useDeleteGroupMutation } from '../../../hooks/ReactQuery/useDeleteGroupMutation'; import { customToast } from '../../../components/CustomToast'; +import { ROLES } from '../../../utils/constants'; function useQueryParams() { const { search } = useLocation(); // ex: "?page=0&size=10" @@ -89,7 +90,7 @@ export default function GroupDetails() {
{/* Menu de ações */} - {group?.authority === 'OWNER' && ( + {group?.authorization !== ROLES.user && ( {/* Modal de adicionar despesa */} - } /> + {group?.authorization !== ROLES.user && ( + } /> + )}
); } diff --git a/frontend/src/pages/Groups/index.jsx b/frontend/src/pages/Groups/index.jsx index c4a67ca..1947726 100644 --- a/frontend/src/pages/Groups/index.jsx +++ b/frontend/src/pages/Groups/index.jsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo } from 'react'; import { Link } from 'react-router'; import GroupFilters from '../../components/Filters/Groups'; import { useFilteredGroups } from '../../components/Filters/Groups/useFilteredGroups'; @@ -9,25 +9,50 @@ import ModalButton from '../../components/Modal/ModalButton'; import { useGroupsQuery } from '../../hooks/ReactQuery/useGroupsQuery'; import useMeQuery from '../../hooks/ReactQuery/useMeQuery'; import { CategoryIcon } from '../../mockData/groupIcons/icons'; -import Pagination from './Pagination'; +import { useQueries } from '@tanstack/react-query'; +import { REACTQUERY_KEYS } from '../../libs/ReactQuery/keys'; +import { getGroupMembers } from '../../services/groups'; +import Pagination from '../../components/PaginationController'; export default function Groups() { const { data: user } = useMeQuery(); const [page, setPage] = useState(0); - const pageSize = undefined; // itens por página - + const pageSize = undefined; + const membersLengthToShow = 4; const { data: groupsData, isLoading } = useGroupsQuery(page, pageSize); - const groups = groupsData?.content || []; - const totalPages = groupsData?.totalPages || 1; + const groups = groupsData?.content ?? []; + const totalPages = groupsData?.totalPages ?? 1; + + // Cria queries dinâmicas de membros (cacheadas) + const membersQueries = useQueries({ + queries: groups.map(group => ({ + queryKey: [REACTQUERY_KEYS.GROUPS.MEMBERS, group.id], + queryFn: () => getGroupMembers(group.id), + enabled: !!group.id, + staleTime: Infinity, // mantém o cache fresco indefinidamente + })), + }); + + // Cria um map: { [groupId]: members } + const membersMap = {}; + membersQueries.forEach((q, i) => { + if (q.data) + membersMap[groups[i].id] = { + totalElements: q.data.totalElements, + members: q.data.members, + }; + }); + + console.log({ membersMap }); - // Estado de filtros + // Filtros const [filters, setFilters] = useState({ search: '', onlyOwner: false, sortOrder: 'desc', }); - const filteredGroups = useFilteredGroups(groups, user.name, filters); + const filteredGroups = useFilteredGroups(groups, 'OWNER', filters); const totalGroups = groups.length; const totalFiltered = filteredGroups.length; @@ -37,7 +62,7 @@ export default function Groups() { return `Meus grupos (${totalGroups})`; }, [filters, totalFiltered, totalGroups]); - if (isLoading) return
Carregando...
; + if (isLoading) return
Carregando grupos...
; return (
@@ -65,63 +90,91 @@ export default function Groups() { {filteredGroups.length > 0 ? ( <>
- {filteredGroups.map(group => ( - - { + const membersData = membersMap[group?.id]?.members ?? []; + const membersLength = + membersMap[group?.id]?.totalElements ?? []; + const membersLoading = membersMap[group?.id]?.isLoading; + return ( + - {/* Nome + Ícone */} -
- - - -

{group.name}

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

- {group.members?.length} membro - {group.members?.length > 1 ? 's' : ''} -

- - {/* Avatares dos membros */} -
- {group.members?.map(({ name, themeColor }, idx) => ( - - {name[0].toUpperCase()} - - ))} -
- - {/* Status financeiro */} -

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

-
- - ))} + {/* 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 && ( + 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)} +

+ + + ); + })}
{/* Paginação */} diff --git a/frontend/src/pages/Profile/index.jsx b/frontend/src/pages/Profile/index.jsx index ade6773..1bb9de9 100644 --- a/frontend/src/pages/Profile/index.jsx +++ b/frontend/src/pages/Profile/index.jsx @@ -18,9 +18,11 @@ import Modal from '../../components/Modal'; import useMeQuery from '../../hooks/ReactQuery/useMeQuery'; import { useGroupsQuery } from '../../hooks/ReactQuery/useGroupsQuery'; +import { useMeDashboardQuery } from '../../hooks/ReactQuery/useMeDashboardStatsQuery'; export default function Profile() { const { data: user } = useMeQuery(); + const { data: dashboard } = useMeDashboardQuery(); const { data: groups } = useGroupsQuery(); const groupsLength = groups?.content.length; const [isModalOpen, setIsModalOpen] = useState(false); @@ -63,9 +65,7 @@ export default function Profile() { />
diff --git a/frontend/src/services/expenses.js b/frontend/src/services/expenses.js index 6a03aa1..9a4b96b 100644 --- a/frontend/src/services/expenses.js +++ b/frontend/src/services/expenses.js @@ -1,17 +1,23 @@ -import api from "./api"; +import { apiApplication } from './api'; // Busca todas as despesas do usuário export const getExpenses = async () => { - const response = await api.get("/expenses"); + const response = await api.get('/expenses'); return response.data; }; // Cria uma nova despesa -export const addExpense = async (expense) => { - const response = await api.post("/expenses", expense); - - return response.data; +export const createExpense = async (groupID, expense) => { + try { + const response = await apiApplication.post( + `groups/${groupID}/expenses`, + expense + ); + return response.data; + } catch (error) { + throw error; + } }; // Atualiza uma despesa existente pelo id @@ -22,7 +28,7 @@ export const updateExpense = async (id, expense) => { }; // Remove uma despesa pelo id -export const deleteExpense = async (id) => { +export const deleteExpense = async id => { const response = await api.delete(`/expenses/${id}`); return response.data; diff --git a/frontend/src/services/groups.js b/frontend/src/services/groups.js index 8d7e962..9ba5233 100644 --- a/frontend/src/services/groups.js +++ b/frontend/src/services/groups.js @@ -1,35 +1,40 @@ -import { REACTQUERY_KEYS } from '../libs/ReactQuery/keys'; -import { queryClient } from '../libs/ReactQuery/queryClient'; import { apiApplication } from './api'; // Busca todos os grupos export const getGroups = async (page = 0, size = 6) => { try { - const userId = queryClient.getQueryData([REACTQUERY_KEYS.USER.ME]).id; - const response = await apiApplication.get( `/groups?page=${page}&size=${size}` ); + return response.data; + } catch (error) { + throw error; + } +}; - // Para cada grupo, pega os membros - const groupsWithMembers = await Promise.all( - response.data.content.map(async group => { - const { - data: { content: members }, - } = await apiApplication.get(`groups/${group.id}/members`); - members.some(member => { - if (member.authority === 'OWNER') { - if (member.id === userId) { - group['ownerId'] = member.name; - } - } - }); - return { ...group, members }; // adiciona members ao grupo - }) - ); +export const getGroupMembers = async (groupId, size = 4) => { + try { + const { + data: { content: members, totalElements }, + } = await apiApplication.get(`groups/${groupId}/members?size=${size}`); + console.log({ members }); + return { members, totalElements }; + } catch (error) { + throw error; + } +}; - // Retorna o objeto original, mas com grupos já populados - return { ...response.data, content: groupsWithMembers }; +export const getMembers = async (groupId, page, search) => { + try { + const query = new URLSearchParams({ page: page.toString() }); + if (search) query.append('search', search); + const response = await apiApplication.get( + `/groups/${groupId}/members?${query.toString()}` + ); + return { + totalPages: response.data.totalPages, + members: response.data.content, + }; } catch (error) { throw error; } diff --git a/frontend/src/services/me.js b/frontend/src/services/me.js index b6068b7..3e35245 100644 --- a/frontend/src/services/me.js +++ b/frontend/src/services/me.js @@ -1,19 +1,53 @@ +import { REACTQUERY_KEYS } from '../libs/ReactQuery/keys'; +import { queryClient } from '../libs/ReactQuery/queryClient'; import { apiApplication } from './api'; export const getMe = async () => { try { let response = await apiApplication.get('user/me'); - const expenses = await apiApplication.get('user/me/expenses'); - response.data['totalExpenses'] = expenses.data.content || 0.0; - const most_used_category = await apiApplication.get( - 'user/me/classify-spending-by-category' - ); - response.data['most_used_category'] = most_used_category.data[0].category; return response.data; } catch (error) { throw new Error('Erro ao obter dados do usuário'); } }; + +// A CHAVE [REACTQUERY_KEYS.USER.ME] deve ser onde a query de usuário está. +// Aqui, assumimos que a busca de usuário JÁ FOI FEITA. + +export const getMeExpenses = async () => { + // 1. Obter o userId do cache da query de usuário + // Assumimos que o array de chaves do usuário é [REACTQUERY_KEYS.USER.ME] + const user = queryClient.getQueryData([REACTQUERY_KEYS.USER.ME]); + const userId = user?.id; // Usar optional chaining para segurança + + if (!userId) { + // Isso deve ser impossível se você usar consultas dependentes (enabled: !!userId) + throw new Error( + 'ID do usuário não encontrado no cache. A query de despesas foi executada muito cedo.' + ); + } + + try { + // 2. Chamada de API para obter as despesas + const response = await apiApplication.get('user/me/expenses'); + const expensesList = response.data.content; + + // 3. NORMALIZAÇÃO: Injetar o dado na chave separada, que agora tem o ID + // CHAVE: [REACTQUERY_KEYS.USER.EXPENSES, userId] + queryClient.setQueryData( + [REACTQUERY_KEYS.USER.EXPENSES, userId], + expensesList + ); + + // 4. Retornar o dado (para a query principal) + // OBS: Você pode retornar apenas a lista aqui, pois o setQueryData já lidou com o cache. + return expensesList || []; + } catch (error) { + // É uma boa prática lançar o erro do próprio objeto de erro para que o TanStack Query o capture + throw error; + } +}; + export const updateMe = async data => { try { await apiApplication.put('/user', data); @@ -21,3 +55,14 @@ export const updateMe = async data => { throw new Error('Erro ao atualizar os dados do usuário'); } }; + +export const getMeDashboardStats = async () => { + try { + const categories = await apiApplication.get( + 'user/me/classify-spending-by-category' + ); + return categories.data; + } catch (error) { + throw new Error('Erro ao obter dados das despesas'); + } +}; diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index cb0ff5c..27ced65 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -1 +1,5 @@ -export {}; +export const ROLES = { + user: 'USER', + admin: 'ADMIN', + owner: 'OWNER', +}; diff --git a/frontend/src/utils/helpers.js b/frontend/src/utils/helpers.js index a78cd90..99b2f69 100644 --- a/frontend/src/utils/helpers.js +++ b/frontend/src/utils/helpers.js @@ -3,29 +3,82 @@ export const getCurrentDate = () => { return new Intl.DateTimeFormat('en-CA').format(today); // 'en-CA' => formato YYYY-MM-DD }; -export const calculateEqualShares = (amount, members) => { - if (members.length === 0) return {}; +// Divide igualmente e distribui o resto para os primeiros membros +export const calculateEqualShares = (amount, members = []) => { + if (!amount || members.length === 0) return {}; - const equalShare = Math.floor((amount / members.length) * 100) / 100; // truncar para 2 casas - const remainder = + const equalShare = Math.floor((amount / members.length) * 100) / 100; + let remainder = Math.round((amount - equalShare * members.length) * 100) / 100; - return Object.fromEntries( - members.map((m, index) => { - const float = index === 0 ? equalShare + remainder : equalShare; - const formatted = float.toLocaleString('pt-BR', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - return [m, { float, formatted, value: formatted }]; - }) + const division = {}; + + members.forEach(m => { + let finalShare = equalShare; + if (remainder > 0.009) { + // garante precisão + finalShare += 0.01; + remainder = Math.round((remainder - 0.01) * 100) / 100; + } + + division[m.id] = { + float: finalShare, + formatted: formatCurrencyValue(finalShare), + value: finalShare.toFixed(2).replace('.', ','), + }; + }); + + return division; +}; + +// Corrige diferenças restantes automaticamente entre membros +export const autoAdjustDifference = (total, divisionAmount = {}) => { + const members = Object.keys(divisionAmount); + if (members.length === 0) return divisionAmount; + + let sum = Object.values(divisionAmount).reduce( + (acc, d) => acc + (parseFloat(d?.float) || 0), + 0 ); + let diff = Math.round((total - sum) * 100) / 100; + + if (diff === 0) return divisionAmount; // já está equilibrado + + const adjusted = { ...divisionAmount }; + + // Corrige centavos (para cima ou para baixo) + let index = 0; + const step = diff > 0 ? 0.01 : -0.01; + + while (Math.abs(diff) >= 0.01) { + const memberId = members[index % members.length]; + const current = adjusted[memberId].float + step; + + adjusted[memberId] = { + float: Math.round(current * 100) / 100, + formatted: formatCurrencyValue(current), + value: current.toFixed(2).replace('.', ','), + }; + + diff = Math.round((diff - step) * 100) / 100; + index++; + } + + return adjusted; }; -export const calculateRemainingDifference = (amount, divisionAmount) => { - const distributedTotal = Object.values(divisionAmount).reduce( - (sum, { float }) => sum + (float || 0), +// Calcula diferença restante (quanto falta ou sobra) +export const calculateRemainingDifference = (total, divisionAmount) => { + const sum = Object.values(divisionAmount).reduce( + (acc, d) => acc + (parseFloat(d?.float) || 0), 0 ); - return Math.round((amount - distributedTotal) * 100) / 100; + return Math.round((total - sum) * 100) / 100; }; + +// Formata para moeda BRL +export const formatCurrencyValue = value => + new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', + }).format(value);