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
17 changes: 15 additions & 2 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { NetWorth } from "@/components/custom/Dashboard/NetWorth";
import { CommandCenterModal } from "@/components/custom/Modal/CommandCenterModal";
import { InfoCenterModal } from "@/components/custom/Modal/InfoCenterModal";
import { useAccountAnalytics } from "@/components/hooks/useAnalytics";
import { useCategories } from "@/components/hooks/useCategories";
import { useCategoryAnalytics } from "@/components/hooks/useCategoryAnalytics";
import { useUser } from "@/components/hooks/useUser";
import { Button } from "@/components/ui/button";
Expand All @@ -17,8 +18,14 @@ import { useState } from "react";

export default function Page() {
const { data: user } = useUser();
const { data: categories } = useCategories();
const [isNewModalOpen, setIsNewModalOpen] = useState(false);
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
const [selectedCategoryIds, setSelectedCategoryIds] = useState<number[]>([]);

const categoryIds = selectedCategoryIds.length
? selectedCategoryIds
: undefined;

// Date range for category analytics (last month to now)
const [dateRange, setDateRange] = useState({
Expand All @@ -32,7 +39,8 @@ export default function Page() {
isError: categoryError,
} = useCategoryAnalytics(
format(dateRange.from, "yyyy-MM-dd"),
format(dateRange.to, "yyyy-MM-dd")
format(dateRange.to, "yyyy-MM-dd"),
categoryIds
);

const {
Expand Down Expand Up @@ -86,7 +94,12 @@ export default function Page() {
</div>
) : (
<>
<CategoryAnalytics data={categoryData?.category_transactions} />
<CategoryAnalytics
data={categoryData?.category_transactions}
categories={categories}
selectedCategoryIds={selectedCategoryIds}
onCategoryFilterChange={setSelectedCategoryIds}
/>
<AccountAnalytics data={accountData?.account_analytics} />
</>
)}
Expand Down
249 changes: 237 additions & 12 deletions frontend/components/custom/CategoryAnalytics/CategoryAnalytics.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { AddCategoryModal } from "@/components/custom/Modal/Category/AddCategoryModal";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
Expand All @@ -10,12 +17,16 @@ import {
TableRow,
} from "@/components/ui/table";
import type { CategoryAnalyticsResponse } from "@/lib/models/analytics";
import type { Category } from "@/lib/models/category";
import { formatCurrency } from "@/lib/utils";
import { ChevronRight, FileQuestion, Plus, Tag } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";

interface CategoryAnalyticsProps {
data?: CategoryAnalyticsResponse["category_transactions"];
categories?: Category[];
selectedCategoryIds?: number[];
onCategoryFilterChange?: (value: number[]) => void;
}

// Color palette for different categories
Expand All @@ -32,13 +43,76 @@ const categoryColors = [
"bg-red-500",
];

export function CategoryAnalytics({ data }: CategoryAnalyticsProps) {
export function CategoryAnalytics({
data,
categories,
selectedCategoryIds = [],
onCategoryFilterChange,
}: CategoryAnalyticsProps) {
const [expandedCategories, setExpandedCategories] = useState<Set<number>>(
new Set()
);
const [isAddCategoryModalOpen, setIsAddCategoryModalOpen] = useState(false);
const [draftSelectedIds, setDraftSelectedIds] =
useState<number[]>(selectedCategoryIds);

useEffect(() => {
setDraftSelectedIds(selectedCategoryIds);
}, [selectedCategoryIds]);

const hasData = !!data && data.length > 0;
const hasCategoryList = !!categories && categories.length > 0;
const showFilter = hasCategoryList && !!onCategoryFilterChange;
const handleCategoryFilterChange =
onCategoryFilterChange ?? (() => undefined);
const allCategoryIds = hasCategoryList
? [...categories.map((category) => category.id), -1]
: [-1];
const hasAllSelectedApplied =
selectedCategoryIds.length > 0 &&
allCategoryIds.every((categoryId) =>
selectedCategoryIds.includes(categoryId)
);
const hasAllSelectedDraft =
draftSelectedIds.length > 0 &&
allCategoryIds.every((categoryId) => draftSelectedIds.includes(categoryId));
const selectedCategoryCount = selectedCategoryIds.length;
const triggerLabel =
selectedCategoryCount === 0 || hasAllSelectedApplied
? "All categories"
: `${selectedCategoryCount} selected`;
const isDirty =
selectedCategoryIds.length !== draftSelectedIds.length ||
selectedCategoryIds.some(
(categoryId) => !draftSelectedIds.includes(categoryId)
);

const toggleCategorySelection = (categoryId: number, checked: boolean) => {
if (checked) {
if (draftSelectedIds.includes(categoryId)) {
return;
}
setDraftSelectedIds([...draftSelectedIds, categoryId]);
return;
}

if (!data || data.length === 0) {
setDraftSelectedIds(draftSelectedIds.filter((id) => id !== categoryId));
};

const toggleSelectAll = () => {
if (hasAllSelectedDraft) {
setDraftSelectedIds([]);
return;
}

setDraftSelectedIds(allCategoryIds);
};

const applyCategoryFilter = () => {
handleCategoryFilterChange(draftSelectedIds);
};

if (!hasData && !hasCategoryList) {
return (
<>
<Card className="h-full">
Expand Down Expand Up @@ -84,6 +158,101 @@ export function CategoryAnalytics({ data }: CategoryAnalyticsProps) {
);
}

if (!hasData) {
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-3">
<span>Categories</span>
<div className="flex items-center gap-2">
{showFilter && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
{triggerLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-muted-foreground">
<span>Categories</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={toggleSelectAll}
>
{hasAllSelectedDraft ? "Deselect all" : "Select all"}
</Button>
</div>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={draftSelectedIds.includes(-1)}
onCheckedChange={(checked) =>
toggleCategorySelection(-1, Boolean(checked))
}
onSelect={(event) => event.preventDefault()}
>
Uncategorized
</DropdownMenuCheckboxItem>
{categories?.map((category) => (
<DropdownMenuCheckboxItem
key={category.id}
checked={draftSelectedIds.includes(category.id)}
onCheckedChange={(checked) =>
toggleCategorySelection(
category.id,
Boolean(checked)
)
}
onSelect={(event) => event.preventDefault()}
>
{category.name}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<div className="flex justify-end px-2 py-2">
<Button
size="sm"
onClick={applyCategoryFilter}
disabled={!isDirty}
>
Apply
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setIsAddCategoryModalOpen(true)}
className="h-8 w-8 p-0"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">
No category activity for the selected filter.
</div>
</CardContent>
</Card>

<AddCategoryModal
isOpen={isAddCategoryModalOpen}
onOpenChange={setIsAddCategoryModalOpen}
onCategoryAdded={() => {
// The category list will automatically refresh due to React Query
setIsAddCategoryModalOpen(false);
}}
/>
</>
);
}

// Calculate total amount - use absolute values to handle negative amounts
const totalAmount = data.reduce(
(sum, category) => sum + Math.abs(category.total_amount),
Expand Down Expand Up @@ -122,16 +291,72 @@ export function CategoryAnalytics({ data }: CategoryAnalyticsProps) {
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<CardTitle className="flex items-center justify-between gap-3">
<span>Categories</span>
<Button
variant="ghost"
size="sm"
onClick={() => setIsAddCategoryModalOpen(true)}
className="h-8 w-8 p-0"
>
<Plus className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
{showFilter && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
{triggerLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-muted-foreground">
<span>Categories</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={toggleSelectAll}
>
{hasAllSelectedDraft ? "Deselect all" : "Select all"}
</Button>
</div>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={draftSelectedIds.includes(-1)}
onCheckedChange={(checked) =>
toggleCategorySelection(-1, Boolean(checked))
}
onSelect={(event) => event.preventDefault()}
>
Uncategorized
</DropdownMenuCheckboxItem>
{categories?.map((category) => (
<DropdownMenuCheckboxItem
key={category.id}
checked={draftSelectedIds.includes(category.id)}
onCheckedChange={(checked) =>
toggleCategorySelection(category.id, Boolean(checked))
}
onSelect={(event) => event.preventDefault()}
>
{category.name}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<div className="flex justify-end px-2 py-2">
<Button
size="sm"
onClick={applyCategoryFilter}
disabled={!isDirty}
>
Apply
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setIsAddCategoryModalOpen(true)}
className="h-8 w-8 p-0"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
Expand Down
13 changes: 10 additions & 3 deletions frontend/components/hooks/useCategoryAnalytics.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { getCategoryAnalytics } from "@/lib/api/analytics";
import { useQuery } from "@tanstack/react-query";

export function useCategoryAnalytics(startDate: string, endDate: string) {
export function useCategoryAnalytics(
startDate: string,
endDate: string,
categoryIds?: number[]
) {
const categoryKey = categoryIds?.length ? categoryIds.join(",") : "all";

return useQuery({
queryKey: ["categoryAnalytics", startDate, endDate],
queryFn: ({ signal }) => getCategoryAnalytics(startDate, endDate, signal),
queryKey: ["categoryAnalytics", startDate, endDate, categoryKey],
queryFn: ({ signal }) =>
getCategoryAnalytics(startDate, endDate, categoryIds, signal),
enabled: !!startDate && !!endDate,
});
}
5 changes: 5 additions & 0 deletions frontend/lib/api/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,18 @@ export async function getNetworthTimeSeries(
export async function getCategoryAnalytics(
startDate: string,
endDate: string,
categoryIds?: number[],
signal?: AbortSignal
): Promise<CategoryAnalyticsResponse> {
const params = new URLSearchParams({
start_date: startDate,
end_date: endDate,
});

if (categoryIds && categoryIds.length > 0) {
params.set("category_ids", categoryIds.join(","));
}

return apiRequest<CategoryAnalyticsResponse>(
`${API_BASE_URL}/analytics/category?${params.toString()}`,
{
Expand Down
Loading
Loading