Skip to content
Merged
1,779 changes: 1,715 additions & 64 deletions client/package-lock.json

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
"api:generate": "openapi --input \"../server/docs/Expense Tracker API.yaml\" --output src/api --client axios"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/material": "^7.3.1",
"@mui/x-charts": "^8.10.2",
"@nivo/bar": "^0.99.0",
"@nivo/core": "^0.99.0",
"@nivo/pie": "^0.99.0",
"@radix-ui/react-icons": "^1.3.2",
"@tailwindcss/vite": "^4.1.11",
"@vercel/analytics": "^1.5.0",
Expand All @@ -21,8 +28,10 @@
"framer-motion": "^12.23.12",
"lucide-react": "^0.541.0",
"react": "^19.1.1",
"react-calendar": "^6.0.0",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.2",
"recharts": "^3.1.2",
"tailwindcss": "^4.1.11",
"zustand": "^5.0.8"
},
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/common/BackgroundImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { assets } from "../../assets/images";

function BackgroundImage() {
return (
<div className="min-w-screen min-h-screen absolute inset-0 z-1">
<div className="min-w-screen min-h-screen fixed inset-0 -z-1">
<img src={assets.bgDark} alt="Bg image" className="w-full h-full" />
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/common/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const DashboardHeader = () => {
</span>
)}
</h1>
<p className="text-indigo-100/90 font-light">
<p className="text-indigo-100/90 font-light text-sm">
Track all your transactions with PennyPal
</p>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ const DateDropdown = () => {
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-white justify-end">
<Clock size={18} className="text-white" />
<span className="font-medium">{formatTime(currentDate)}</span>
<span className="font-medium text-sm">{formatTime(currentDate)}</span>
<span className="mx-1">|</span>
<Calendar size={18} className="text-white" />
<span className="font-medium">{formatDate(currentDate)}</span>
<span className="font-medium text-sm">{formatDate(currentDate)}</span>
</div>

<div className="flex items-center gap-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const SearchInput = () => {
return (
<div
className={`relative transition-all duration-300 ${
isFocused ? "w-72" : "w-56"
isFocused ? "w-64" : "w-56"
}`}
>
<Search
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/common/Mascot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const Mascot: React.FC<MascotProps> = ({ className = "" }) => {
<img
src={getImageSrc()}
alt="Bear mascot"
className="w-52 h-52 object-contain"
className="w-32 h-32 object-contain"
/>
</div>
</div>
Expand Down
90 changes: 90 additions & 0 deletions client/src/components/dashboard/DisplayCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from "react";
import { TrendingUp, TrendingDown, type LucideProps } from "lucide-react";

export type MiniStatItem = {
label: string;
value: number;
icon: React.ComponentType<{ className?: string } & LucideProps>;
deltaPct?: number;
hint?: string;
valueSuffix?: string;
formatValue?: (v: number) => string;
className?: string;
};

const nfMG = new Intl.NumberFormat("fr-MG");
const fmtArShort = (n: number) =>
n >= 1_000_000
? `Ar ${(n / 1_000_000).toFixed(1)}M`
: n >= 1_000
? `Ar ${(n / 1_000).toFixed(1)}k`
: `Ar ${nfMG.format(n)}`;

export default function MiniStatCard({
label,
value,
icon: Icon,
deltaPct,
hint = "From last month",
valueSuffix,
formatValue,
className = "",
}: MiniStatItem) {
const isUp = typeof deltaPct === "number" ? deltaPct >= 0 : undefined;
const deltaColor =
isUp === undefined
? "bg-slate-100 text-slate-600"
: isUp
? "bg-emerald-50 text-emerald-600"
: "bg-rose-50 text-rose-600";

const valueStr = formatValue
? formatValue(value)
: valueSuffix
? nfMG.format(value)
: fmtArShort(value);

return (
<div
className={[
"rounded-xl bg-primary/50 shadow-sm min-w-[250px] border border-gray-700",
"p-4 flex flex-col gap-3",
className,
].join(" ")}
>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="w-8 h-8 rounded-lg bg-slate-100/20 text-white flex items-center justify-center">
<Icon className="w-4 h-4" />
</span>
<span className="text-lg text-white font-semibold">{label}</span>
</div>
</div>

{/* Value */}
<div className="text-3xl font-normal tracking-tight text-white">
{valueStr}
{valueSuffix ? valueSuffix : ""}
</div>

{/* Delta */}
<div className="flex items-center gap-2">
{typeof deltaPct === "number" && (
<span
className={`text-sm font-semibold px-2 py-0.5 rounded-full inline-flex items-center gap-1 ${deltaColor}`}
>
{isUp ? (
<TrendingUp className="w-3.5 h-3.5" />
) : (
<TrendingDown className="w-3.5 h-3.5" />
)}
{isUp ? "+" : ""}
{deltaPct.toFixed(1)}%
</span>
)}
<span className="text-sm text-white">{hint}</span>
</div>
</div>
);
}
31 changes: 31 additions & 0 deletions client/src/components/dashboard/Layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";

export default function Layout({
title,
children,
graphClassName,
titleClassName,
}: {
title?: string;
children: ReactNode;
graphClassName?: string;
titleClassName?: string;
}) {
return (
<div className="w-full mx-auto flex flex-col items-center gap-2">
{" "}
{title && (
<h2
className={
"text-white font-semibold text-xl text-center " + titleClassName
}
>
{title}
</h2>
)}
<div className={`w-full min-h-[300px] rounded-xl p-2 ${graphClassName}`}>
{children}
</div>
</div>
);
}
119 changes: 119 additions & 0 deletions client/src/components/dashboard/MonthlyBarchart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { useMemo, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import Layout from "./Layout";
import { fmtAr, fmtShort } from "../../utils/formatter";
export type Row = { month: string; spending: number; income: number };

const COLORS = {
spending: "#FF8042",
income: "#00C49F",
};

export function MonthlyBarChart({ data }: { data: Row[] }) {
const [show, setShow] = useState({ spending: true, income: true });

const totals = useMemo(
() => ({
spending: data.reduce((a, c) => a + c.spending, 0),
income: data.reduce((a, c) => a + c.income, 0),
}),
[data]
);

return (
<Layout title="Monthly Spending vs Income" graphClassName="h-[300px]">
{/* Toggles */}
<div className="flex items-center justify-end gap-2 px-2 pb-2">
<button
onClick={() => setShow((s) => ({ ...s, spending: !s.spending }))}
className={`text-sm font-semibold px-2 py-1 rounded-md transition outline-none ${
show.spending
? "bg-white text-slate-900"
: "bg-white/10 text-white/80 hover:text-white"
}`}
>
Spending • {fmtShort(totals.spending)}
</button>
<button
onClick={() => setShow((s) => ({ ...s, income: !s.income }))}
className={`text-sm font-semibold px-2 py-1 rounded-md transition outline-none ${
show.income
? "bg-white text-slate-900"
: "bg-white/10 text-white/80 hover:text-white"
}`}
>
Income • {fmtShort(totals.income)}
</button>
</div>

<ResponsiveContainer
width="100%"
height="100%"
className={"outline-none"}
>
<BarChart data={data} barCategoryGap="22%" barGap={2}>
<defs>
<linearGradient id="gradSpending" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={COLORS.spending} stopOpacity={0.9} />
<stop
offset="100%"
stopColor={COLORS.spending}
stopOpacity={0.3}
/>
</linearGradient>
<linearGradient id="gradIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={COLORS.income} stopOpacity={0.9} />
<stop offset="100%" stopColor={COLORS.income} stopOpacity={0.3} />
</linearGradient>
</defs>

<CartesianGrid
strokeDasharray="3 3"
stroke="rgba(255,255,255,0.12)"
/>
<XAxis dataKey="month" tick={{ fill: "rgba(255,255,255,0.8)" }} />
<YAxis
tick={{ fill: "rgba(255,255,255,0.8)" }}
tickFormatter={(v: number) => `Ar ${fmtShort(v)}`}
allowDecimals={false}
domain={["auto", "auto"]}
/>
<Tooltip
contentStyle={{
backgroundColor: "#0b0f1a",
border: "none",
borderRadius: 8,
color: "#fff",
}}
formatter={(v, name: string) => [fmtAr(Number(v)), String(name)]}
/>

{show.spending && (
<Bar
dataKey="spending"
name="Spending"
fill="url(#gradSpending)"
radius={[8, 8, 0, 0]}
/>
)}
{show.income && (
<Bar
dataKey="income"
name="Income"
fill="url(#gradIncome)"
radius={[8, 8, 0, 0]}
/>
)}
</BarChart>
</ResponsiveContainer>
</Layout>
);
}
Loading