Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,38 +45,24 @@ public interface ExpenseRepository extends JpaRepository<Expense, Long> {
Page<UserExpensesDTO> 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<UserMonthlyExpensesDTO> 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<UserMonthlyExpensesDTO> getUserMonthlyExpenses(@Param("userId") Long userId);

// @Query(nativeQuery = true, value = """
// SELECT expenses.id, expenses.title, expenses.description,
// categories.name as categoryName,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import SummaryCards from './components/SummaryCards';

function App() {
return (
<div>
<div className="flex flex-col gap-10">
<SummaryCards />
<ChartSwitcher />
<LatestExpenses />
Expand Down
46 changes: 30 additions & 16 deletions frontend/src/components/ChartSwitcher/BalanceChart.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,19 @@ export default function BalanceChart() {
})) ?? [];

return (
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer
lassName="relative w-full aspect-[4/3] sm:aspect-[5/3] rounded-xl"
width="100%"
height="100%"
>
<AreaChart
data={expensesByMonthly}
margin={{ top: 10, right: 30, left: 30, bottom: 10 }}
margin={{ top: 15, right: 35, left: 0, bottom: 5 }}
>
{/* Grade */}
{/* Linhas de grade */}
<CartesianGrid stroke="var(--color-border)" strokeDasharray="3 3" />

{/* Eixo X (meses) */}
<XAxis
dataKey="name"
stroke="var(--color-text)"
Expand All @@ -41,30 +46,37 @@ export default function BalanceChart() {
tickLine={false}
/>

{/* Eixo Y (valores) */}
<YAxis
stroke="var(--color-text)"
tickFormatter={value => formatBRL(value)}
tick={{ fontSize: 12, fill: 'var(--color-text)' }}
axisLine={false}
tickLine={false}
width={80}
/>

{/* Tooltip com tema dinâmico */}
{/* Tooltip estilizado */}
<Tooltip
formatter={value => 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 (
<div className="bg-surface border border-border/40 shadow-md rounded-lg px-3 py-2 text-sm text-text">
<p className="font-semibold">{label}</p>
<p className="text-muted-foreground">
Despesas:{' '}
<span className="font-medium">
{formatBRL(payload[0].value)}
</span>
</p>
</div>
);
}
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) */}
<defs>
<linearGradient id="colorExpense" x1="0" y1="0" x2="0" y2="1">
<stop
Expand All @@ -84,7 +96,9 @@ export default function BalanceChart() {
stroke="var(--color-error)"
fill="url(#colorExpense)"
strokeWidth={2}
animationDuration={800}
dot={{ r: 3, strokeWidth: 1, fill: 'var(--color-error)' }}
activeDot={{ r: 5 }}
animationDuration={700}
/>
</AreaChart>
</ResponsiveContainer>
Expand Down
106 changes: 72 additions & 34 deletions frontend/src/components/ChartSwitcher/ExpenseChart.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative w-full h-[280px] sm:h-[340px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={expenseData}
dataKey="quantity"
nameKey="category"
innerRadius="60%"
outerRadius="80%"
stroke="var(--color-surface)"
strokeWidth={2}
paddingAngle={1}
labelLine={false}
label={({ name }) =>
`${name}: ${
expenseData.find(c => c.category === name)?.quantity ?? 0
}`
}
>
{expenseData?.map((_, index) => (
<Cell
key={index}
fill={
avatarBackgroundColors[index % avatarBackgroundColors.length]
<div className="relative w-full h-full flex items-center justify-center">
<div className="w-full h-full relative">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Tooltip
content={({ active, payload }) => {
if (active && payload?.length) {
const { name, value } = payload[0];
const percent = ((value / total) * 100).toFixed(1);
return (
<div className="bg-surface border border-border/40 shadow-md rounded-lg px-3 py-2 text-sm text-text">
<p className="font-semibold">{name}</p>
<p className="text-muted-foreground">
Quantidade:{' '}
<span className="font-medium">
{formatBRL(value, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
style: 'decimal',
})}
</span>
</p>
<p className="text-muted-foreground">
{percent}% do total
</p>
</div>
);
}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
return null;
}}
/>

{/* 💬 Centro do gráfico */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<p className="text-sm text-muted">Total de despesas</p>
<p className="text-3xl font-bold text-text mt-1">{total}</p>
<Pie
data={expenseData}
dataKey="quantity"
nameKey="category"
cx="50%"
cy="50%"
innerRadius="55%"
outerRadius="80%"
stroke="var(--color-surface)"
strokeWidth={3}
paddingAngle={2}
labelLine={false} // labels removidos
>
{expenseData?.map((_, index) => (
<Cell
key={index}
fill={
avatarBackgroundColors[
index % avatarBackgroundColors.length
]
}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>

{/* Total centralizado */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-lg font-bold text-text leading-none">
{formatBRL(total, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
style: 'decimal',
})}
</span>
<span className="text-sm text-muted-foreground leading-none">
Total
</span>
</div>
</div>
</div>
);
Expand Down
95 changes: 45 additions & 50 deletions frontend/src/components/ChartSwitcher/index.jsx
Original file line number Diff line number Diff line change
@@ -1,80 +1,75 @@
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 (
<div className="w-full bg-surface p-4 rounded-2xl shadow-md space-y-4 mb-6 transition-colors">
<div className="w-full bg-surface p-6 rounded-2xl shadow-md border border-border/40 transition-colors duration-300 space-y-6">
{/* Cabeçalho */}
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-text">
<div className="flex flex-1 justify-between items-center flex-wrap gap-3">
<h2 className="text-xl font-semibold text-text transition-all duration-300">
{activeChart === 'balance'
? 'Visualização Financeira'
: 'Distribuição das Despesas por Categoria'}
? `Como Você Gastou em (${new Date().getFullYear()})`
: 'Despesas por Categoria (Geral)'}
</h2>

{/* Botão para alternar gráfico */}
<button
onClick={toggleChart}
aria-label={`Alternar para ${buttonLabel}`}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-white bg-primary hover:opacity-90 transition cursor-pointer"
>
<ButtonIcon size={18} />
<span className="text-sm">{buttonLabel}</span>
</button>
{/* Alternador */}
<div className="relative flex bg-muted/20 rounded-full p-1.5 text-sm font-medium w-fit select-none">
{/* Indicador animado */}
<div
className={`absolute top-1 bottom-1 w-[50%] bg-primary rounded-full transition-all duration-300 ${
activeChart === 'expense' ? 'left-1/2' : 'left-1'
}`}
></div>
{/* Opção: Gastos Mensais */}
<ButtonUI
onClick={() => 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'
}`}
>
<FaChartLine size={16} />
<span>Resumo Anual</span>
</ButtonUI>
{/* Opção: Saldo Geral */}
<ButtonUI
onClick={() => 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'
}`}
>
<FaChartPie size={16} />
<span>Divisão de Gastos</span>
</ButtonUI>
</div>
</div>
<GraphicArea activeChart={activeChart} />
</div>
);
}

function GraphicArea({ activeChart }) {
return (
<>
{/* Área do gráfico */}
<div
className="
relative w-full h-72 sm:h-80
bg-surface
rounded-xl
flex items-center justify-center
transition-all duration-500

"
>
{/* ✨ Transição suave entre os gráficos */}
{/* Área dos gráficos */}
<div className="relative w-full min-h-72 sm:h-80 rounded-xl ">
<div
className={`absolute inset-0 transition-opacity duration-500 ${
className={`absolute inset-0 transition-opacity duration-500 ease-in-out ${
activeChart === 'balance' ? 'opacity-100 z-10' : 'opacity-0 z-0'
}`}
>
<BalanceChart />
</div>

<div
className={`absolute inset-0 transition-opacity duration-500 ${
className={`absolute inset-0 transition-opacity duration-500 ease-in-out ${
activeChart === 'expense' ? 'opacity-100 z-10' : 'opacity-0 z-0'
}`}
>
<ExpenseChart />
</div>

{/* Fallback caso não haja dados */}
{!activeChart && (
<p className="text-muted italic">Nenhum gráfico selecionado.</p>
)}
</div>
</>
</div>
);
}
Loading
Loading