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
82 changes: 82 additions & 0 deletions app/(dashboard)/budgets/_components/AutoSuggestBudgetButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use client";

import { Button } from "@/components/ui/button";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { toast } from "sonner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";

interface AutoSuggestBudgetButtonProps {
month: number;
year: number;
}

export default function AutoSuggestBudgetButton({
month,
year,
}: AutoSuggestBudgetButtonProps) {
const queryClient = useQueryClient();

const autoSuggestMutation = useMutation({
mutationFn: async () => {
const response = await fetch("/api/budgets/auto-suggest", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
month,
year,
}),
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.message || error.error || "Failed to auto-suggest budget");
}

return response.json();
},
onSuccess: (data) => {
toast.success("Budget Auto-Suggested ✨", {
description: data.message,
});
queryClient.invalidateQueries({ queryKey: ["budget-progress"] });
},
onError: (error: Error) => {
toast.error(error.message);
},
});

return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
onClick={() => autoSuggestMutation.mutate()}
disabled={autoSuggestMutation.isPending}
className="flex-1 sm:flex-none bg-gradient-to-r from-amber-500/10 to-orange-500/10 hover:from-amber-500/20 hover:to-orange-500/20 text-amber-600 dark:text-amber-500 border border-amber-200 dark:border-amber-900/50"
>
<Sparkles className="mr-2 h-4 w-4" />
<span className="hidden lg:inline">
{autoSuggestMutation.isPending ? "Analyzing..." : "Auto-Suggest"}
</span>
<span className="lg:hidden">
{autoSuggestMutation.isPending ? "Analyzing..." : "Auto"}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Calculates your 3-month average to build your budget instantly.</p>
<p className="text-xs text-muted-foreground mt-1">Discretionary spending is cut by 5% to boost savings.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
12 changes: 11 additions & 1 deletion app/(dashboard)/budgets/_components/BudgetChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,17 @@ export default function BudgetChart({
}}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="name" stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
<XAxis
dataKey="name"
stroke="#888888"
fontSize={10}
tickLine={false}
axisLine={false}
interval={0}
angle={-45}
textAnchor="end"
height={60}
/>
<YAxis
stroke="#888888"
fontSize={12}
Expand Down
246 changes: 246 additions & 0 deletions app/(dashboard)/budgets/_components/BudgetGridView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
"use client";

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UserSettings } from "@prisma/client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { useEffect, useState } from "react";
import SkeletonWrapper from "@/components/SkeletonWrapper";
import { toast } from "sonner";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { GetFormatterForCurrency } from "@/lib/helper";

interface BudgetGridViewProps {
userSettings: UserSettings;
year: number;
}

interface Budget {
id: string;
category: string;
categoryIcon: string;
amount: number;
month: number;
year: number;
}

interface Category {
name: string;
icon: string;
type: string;
}

const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];

function BudgetCell({
initialAmount,
month,
year,
category,
categoryIcon,
disabled
}: {
initialAmount: number | "";
month: number;
year: number;
category: string;
categoryIcon: string;
disabled: boolean;
}) {
const [value, setValue] = useState<string>(
initialAmount !== "" ? initialAmount.toString() : ""
);
const queryClient = useQueryClient();

useEffect(() => {
setValue(initialAmount !== "" ? initialAmount.toString() : "");
}, [initialAmount]);

const mutation = useMutation({
mutationFn: async (amount: number) => {
const response = await fetch("/api/budgets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
category,
categoryIcon,
amount,
month,
year,
}),
});
if (!response.ok) throw new Error("Failed to save budget");
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["budgets", year] });
queryClient.invalidateQueries({ queryKey: ["budget-progress"] });
toast.success(`Saved ${category} budget for ${MONTHS[month]} ${year}`);
},
onError: () => {
toast.error("Failed to save budget");
setValue(initialAmount !== "" ? initialAmount.toString() : "");
},
});

const handleBlur = () => {
if (value === "" && initialAmount === "") return;
if (value !== "" && parseFloat(value) === initialAmount) return;

if (value === "") {
// Technically should delete if empty, but for now we skip or can set to 0
return;
}

const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue >= 0) {
// Optimistically update or just start mutation
mutation.mutate(numValue);
} else {
toast.error("Invalid amount");
setValue(initialAmount !== "" ? initialAmount.toString() : "");
}
};

return (
<Input
type="number"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleBlur}
disabled={disabled}
className={`h-8 w-16 md:w-24 text-right text-sm ${value !== "" && "font-semibold"} ${disabled && "bg-muted/30 cursor-not-allowed opacity-50"}`}
placeholder="-"
/>
);
}

export default function BudgetGridView({
userSettings,
year,
}: BudgetGridViewProps) {
const { data: budgets, isFetching: budgetsFetching } = useQuery<Budget[]>({
queryKey: ["budgets", year],
queryFn: () =>
fetch(`/api/budgets?year=${year}`).then((res) => res.json()),
});

const { data: categories, isFetching: categoriesFetching } = useQuery<
Category[]
>({
queryKey: ["categories"],
queryFn: () =>
fetch(`/api/categories`).then((res) => res.json()),
});

const isLoading = budgetsFetching || categoriesFetching;

const currentDate = new Date();
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();

// We only want expense categories ideally, or whatever categories user has budgets for
const userCategories = (categories || []).filter(c => c.type === "expense");

// also add categories that have budgets but might be deleted/different type
const activeCategoryNames = new Set(userCategories.map(c => c.name));
const missingCategories = (budgets || []).filter(b => !activeCategoryNames.has(b.category));

const allCategoriesMap = new Map();
userCategories.forEach(c => allCategoriesMap.set(c.name, c));
missingCategories.forEach(b => {
if(!allCategoriesMap.has(b.category)) {
allCategoriesMap.set(b.category, { name: b.category, icon: b.categoryIcon });
}
});

const allCategories = Array.from(allCategoriesMap.values()).sort((a, b) => a.name.localeCompare(b.name));

const formatter = GetFormatterForCurrency(userSettings.currency);

return (
<SkeletonWrapper isLoading={isLoading}>
<div className="rounded-md border bg-card text-card-foreground shadow-sm">
<ScrollArea className="w-full whitespace-nowrap">
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead className="w-[150px] sticky left-0 z-20 bg-muted/50 md:w-[200px] border-r">Category</TableHead>
{MONTHS.map((monthStr, mIndex) => (
<TableHead key={mIndex} className="text-center w-24">
{monthStr}
</TableHead>
))}
<TableHead className="text-right w-[100px]">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allCategories.map((cat) => {
let yearlyTotal = 0;
return (
<TableRow key={cat.name}>
<TableCell className="font-medium sticky left-0 z-10 bg-card border-r w-[150px] md:w-[200px]">
<div className="flex items-center gap-2 overflow-hidden text-ellipsis whitespace-nowrap">
<span>{cat.icon}</span>
<span className="truncate">{cat.name}</span>
</div>
</TableCell>
{MONTHS.map((_, month) => {
const budget = budgets?.find(
(b) => b.category === cat.name && b.month === month
);
if (budget) yearlyTotal += budget.amount;
return (
<TableCell key={month} className="p-2 text-center">
<BudgetCell
initialAmount={budget ? budget.amount : ""}
month={month}
year={year}
category={cat.name}
categoryIcon={cat.icon}
disabled={year > currentYear || (year === currentYear && month > currentMonth)}
/>
</TableCell>
);
})}
<TableCell className="text-right text-muted-foreground font-semibold">
{formatter.format(yearlyTotal)}
</TableCell>
</TableRow>
);
})}
{allCategories.length === 0 && (
<TableRow>
<TableCell colSpan={14} className="text-center py-6 text-muted-foreground">
No expense categories found. Create categories first to use grid view.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
</SkeletonWrapper>
);
}
8 changes: 4 additions & 4 deletions app/(dashboard)/budgets/_components/BudgetProgressCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export default function BudgetProgressCards({
{dataAvailable ? (
<>
<BudgetChart userSettings={userSettings} budgetProgress={budgetProgress} />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 mt-4">
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 mt-4">
{budgetProgress.map((budget) => (
<Card
key={budget.id}
Expand All @@ -107,9 +107,9 @@ export default function BudgetProgressCards({
>
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-base">
<span className="flex items-center gap-2">
<span className="text-2xl">{budget.categoryIcon}</span>
<span>{budget.category}</span>
<span className="flex items-center gap-2 overflow-hidden">
<span className="text-2xl flex-shrink-0">{budget.categoryIcon}</span>
<span className="truncate">{budget.category}</span>
</span>
<div className="flex items-center gap-1">
{budget.isOverBudget && (
Expand Down
4 changes: 2 additions & 2 deletions app/(dashboard)/budgets/_components/BudgetSummaryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export default function BudgetSummaryCard({
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
{/* Total Budget */}
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Total Budget</p>
Expand Down Expand Up @@ -239,7 +239,7 @@ export default function BudgetSummaryCard({

{/* Available to Budget Section */}
<div className={cn(
"mt-6 rounded-lg border-2 p-4 flex items-center justify-between",
"mt-6 rounded-lg border-2 p-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between",
availableToBudget >= 0
? "border-emerald-500/50 bg-emerald-50 dark:bg-emerald-950/20"
: "border-orange-500/50 bg-orange-50 dark:bg-orange-950/20"
Expand Down
Loading
Loading