From ecc771f3dda81d80f48642b9d1432a9d7cf2a1b5 Mon Sep 17 00:00:00 2001 From: Mohamed Omar Date: Sat, 6 Sep 2025 13:55:56 +0100 Subject: [PATCH 1/5] Enhance layout component to allow overflow for children elements --- src/app/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6a79c53..2878269 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -134,7 +134,7 @@ export default function RootLayout({ -
{children}
+
{children}
From c17c48788996e80a19721dad6554efba502b099e Mon Sep 17 00:00:00 2001 From: Mohamed Omar Date: Sat, 6 Sep 2025 14:35:11 +0100 Subject: [PATCH 2/5] Add ExpenseManager component and integrate CurrentFinances display --- src/app/expenses/page.tsx | 10 ++ src/app/page.tsx | 4 +- src/components/ExpenseManager.tsx | 104 ++++++++++++++++++++ src/components/dashboard/CurrentFinaces.tsx | 23 +++++ src/lib/routes.ts | 2 +- 5 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/app/expenses/page.tsx create mode 100644 src/components/ExpenseManager.tsx create mode 100644 src/components/dashboard/CurrentFinaces.tsx diff --git a/src/app/expenses/page.tsx b/src/app/expenses/page.tsx new file mode 100644 index 0000000..bfb10a7 --- /dev/null +++ b/src/app/expenses/page.tsx @@ -0,0 +1,10 @@ +import ExpenseManager from "@/components/ExpenseManager"; +import React from "react"; + +function Expenses() { + return
+ +
; +} + +export default Expenses; diff --git a/src/app/page.tsx b/src/app/page.tsx index c195ed7..7e63351 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,4 @@ +import CurrentFinances from "@/components/dashboard/CurrentFinaces"; import { Button } from "@/components/ui/button"; import { Card, @@ -6,6 +7,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { useIncomeTransactions } from "@/hooks/useFinancialData"; import { SignedIn, SignedOut, SignUpButton } from "@clerk/nextjs"; import { currentUser } from "@clerk/nextjs/server"; import { Calendar, PoundSterling, Target, TrendingDown } from "lucide-react"; @@ -160,7 +162,7 @@ export default async function Home() { availableBalance >= 0 ? "text-white" : "text-red-500" }`} > - £{availableBalance.toLocaleString()} + £{}

(null); + + return ( +
+ + +
+

Expense Management

+

+ Manage your expense drains, categories, and transactions. You can also import a CSV file of your expenses. +

+
+
+ +
+
+
+ {showImportForm && ( + + +

Import Expenses from CSV

+
+
+ + +
+
+ {/* Header/Columns Format i.e "Date, Transaction"... */} + + +
+ {/* collumn to be date and column to be amount and column to be category */} +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ )} +
+ ); +} + +export default ExpenseManager; diff --git a/src/components/dashboard/CurrentFinaces.tsx b/src/components/dashboard/CurrentFinaces.tsx new file mode 100644 index 0000000..2696c20 --- /dev/null +++ b/src/components/dashboard/CurrentFinaces.tsx @@ -0,0 +1,23 @@ +"use client"; +import { useIncomeTransactions } from "@/hooks/useFinancialData"; +import React from "react"; + +function CurrentFinances() { + const { data: transactions = [], isLoading: transactionsLoading } = + useIncomeTransactions(); + + if (transactionsLoading) { + return
Loading...
; + } + + return ( + + {transactions.reduce( + (acc, transaction) => acc + parseInt(transaction.amount), + 0 + ).toLocaleString()} + + ); +} + +export default CurrentFinances; diff --git a/src/lib/routes.ts b/src/lib/routes.ts index b9438fe..1ee98e0 100644 --- a/src/lib/routes.ts +++ b/src/lib/routes.ts @@ -8,7 +8,7 @@ export interface RouteConfig { } // Currently accessible routes -export const enabledRoutes = ["/", "/settings", "/income"]; +export const enabledRoutes = ["/", "/settings", "/income", "/expenses"]; export const routes: RouteConfig[] = [ // Budget Control Items From 5ee164e59de1459aafc5230dabb3704554bf52fc Mon Sep 17 00:00:00 2001 From: Mohamed Omar Date: Sat, 6 Sep 2025 15:04:18 +0100 Subject: [PATCH 3/5] Implement CSV import functionality for expense transactions with validation and telemetry --- src/app/api/expenses/import/route.ts | 247 ++++++++ src/components/ExpenseManager.tsx | 852 +++++++++++++++++++++++++-- src/hooks/useFinancialData.ts | 87 +++ 3 files changed, 1128 insertions(+), 58 deletions(-) create mode 100644 src/app/api/expenses/import/route.ts diff --git a/src/app/api/expenses/import/route.ts b/src/app/api/expenses/import/route.ts new file mode 100644 index 0000000..5a2fa59 --- /dev/null +++ b/src/app/api/expenses/import/route.ts @@ -0,0 +1,247 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { TransactionType, CategoryKind } from "../../../../../generated/prisma"; +import { tracer } from "@/lib/telemetry"; +import { Decimal } from "@prisma/client/runtime/library"; + +interface CSVImportRequest { + csvData: string; + dateColumn: string; + amountColumn: string; + categoryColumn: string; + merchantColumn?: string; + descriptionColumn?: string; +} + +export async function POST(request: NextRequest) { + return tracer.startActiveSpan("api.expenses.import.POST", async (span) => { + try { + span.setAttributes({ + "http.method": "POST", + "http.route": "/api/expenses/import", + }); + + const user = await getCurrentUser(); + if (!user) { + span.setAttributes({ + "http.status_code": 401, + "auth.result": "unauthorized", + }); + span.end(); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { + csvData, + dateColumn, + amountColumn, + categoryColumn, + merchantColumn, + descriptionColumn, + }: CSVImportRequest = await request.json(); + + // Parse CSV data + const lines = csvData.trim().split("\n"); + if (lines.length < 2) { + return NextResponse.json( + { error: "CSV must have at least a header and one data row" }, + { status: 400 } + ); + } + + const headers = lines[0].split(",").map((h) => h.trim()); + const dateColumnIndex = headers.indexOf(dateColumn); + const amountColumnIndex = headers.indexOf(amountColumn); + const categoryColumnIndex = headers.indexOf(categoryColumn); + const merchantColumnIndex = merchantColumn + ? headers.indexOf(merchantColumn) + : -1; + const descriptionColumnIndex = descriptionColumn + ? headers.indexOf(descriptionColumn) + : -1; + + if ( + dateColumnIndex === -1 || + amountColumnIndex === -1 || + categoryColumnIndex === -1 + ) { + return NextResponse.json( + { + error: "Required columns not found in CSV", + missing: { + date: dateColumnIndex === -1, + amount: amountColumnIndex === -1, + category: categoryColumnIndex === -1, + }, + }, + { status: 400 } + ); + } + + const dataLines = lines.slice(1); + const transactions = []; + const categoriesMap = new Map(); + const skippedRows: string[] = []; + + // Get current date for filtering (last 90 days) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 90); + + span.setAttributes({ + "csv.total_rows": dataLines.length, + "csv.headers_count": headers.length, + }); + + for (let i = 0; i < dataLines.length; i++) { + const row = dataLines[i]; + const columns = row.split(",").map((c) => c.trim()); + + if (columns.length !== headers.length) { + skippedRows.push(`Row ${i + 2}: Column count mismatch`); + continue; + } + + try { + // Parse date + const dateStr = columns[dateColumnIndex]; + const transactionDate = new Date(dateStr); + + if (isNaN(transactionDate.getTime())) { + skippedRows.push(`Row ${i + 2}: Invalid date format: ${dateStr}`); + continue; + } + + // Only import transactions from the last 30 days + if (transactionDate < thirtyDaysAgo) { + continue; + } + + // Parse amount (handle negative values and currency symbols) + const amountStr = columns[amountColumnIndex].replace(/[£$€,]/g, ""); + const amount = parseFloat(amountStr); + + if (isNaN(amount)) { + skippedRows.push( + `Row ${i + 2}: Invalid amount: ${columns[amountColumnIndex]}` + ); + continue; + } + + // Make sure it's treated as an expense (positive amount) + const expenseAmount = Math.abs(amount); + + const categoryName = columns[categoryColumnIndex]; + const merchant = + merchantColumnIndex !== -1 + ? columns[merchantColumnIndex] + : undefined; + const description = + descriptionColumnIndex !== -1 + ? columns[descriptionColumnIndex] + : undefined; + + // Create or find category + let categoryId: string | undefined; + if (categoryName && categoryName !== "") { + if (!categoriesMap.has(categoryName)) { + // Create category if it doesn't exist + const existingCategory = await db.category.findFirst({ + where: { + userId: user.id, + name: categoryName, + kind: CategoryKind.EXPENSE, + }, + }); + + if (existingCategory) { + categoriesMap.set(categoryName, existingCategory.id); + } else { + const newCategory = await db.category.create({ + data: { + userId: user.id, + name: categoryName, + kind: CategoryKind.EXPENSE, + color: getRandomColor(), + }, + }); + categoriesMap.set(categoryName, newCategory.id); + } + } + categoryId = categoriesMap.get(categoryName); + } + + transactions.push({ + userId: user.id, + type: TransactionType.EXPENSE, + amount: new Decimal(expenseAmount), + occurredAt: transactionDate.toISOString(), + description: + [merchant, description].filter(Boolean).join(" - ") || undefined, + categoryId, + }); + } catch (error) { + skippedRows.push(`Row ${i + 2}: ${(error as Error).message}`); + continue; + } + } + + // Bulk create transactions + let importedCount = 0; + if (transactions.length > 0) { + const result = await db.transaction.createMany({ + data: transactions, + skipDuplicates: true, + }); + importedCount = result.count; + } + + span.setAttributes({ + "transactions.created": importedCount, + "transactions.skipped": skippedRows.length, + "categories.created": categoriesMap.size, + success: true, + }); + + span.end(); + + return NextResponse.json({ + success: true, + imported: importedCount, + skipped: skippedRows.length, + skippedReasons: skippedRows, + categoriesCreated: categoriesMap.size, + message: `Successfully imported ${importedCount} expense transactions from the last 30 days`, + }); + } catch (error) { + span.recordException(error as Error); + span.setAttributes({ + error: true, + "error.message": (error as Error).message, + }); + span.end(); + + console.error("CSV import error:", error); + return NextResponse.json( + { error: "Failed to import CSV data" }, + { status: 500 } + ); + } + }); +} + +function getRandomColor(): string { + const colors = [ + "#ef4444", // red + "#f97316", // orange + "#eab308", // yellow + "#22c55e", // green + "#06b6d4", // cyan + "#3b82f6", // blue + "#8b5cf6", // violet + "#ec4899", // pink + "#f59e0b", // amber + "#10b981", // emerald + ]; + return colors[Math.floor(Math.random() * colors.length)]; +} diff --git a/src/components/ExpenseManager.tsx b/src/components/ExpenseManager.tsx index f496365..23f4d56 100644 --- a/src/components/ExpenseManager.tsx +++ b/src/components/ExpenseManager.tsx @@ -1,102 +1,838 @@ -'use client'; +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { + useCategories, + useCreateCategory, + useCreateTransaction, + useDeleteTransaction, + useExpenseCategories, + useExpenseTransactions, + useImportExpenses, + useSetupDefaults, +} from "@/hooks/useFinancialData"; +import { tracer } from "@/lib/telemetry"; +import { formatCurrency, formatDate } from "@/lib/types"; +import { + CalendarDays, + DollarSign, + Info, + Plus, + Tag, + Trash, + Upload, + FileSpreadsheet, +} from "lucide-react"; import React from "react"; -import { Card, CardContent } from "./ui/card"; -import { Button } from "./ui/button"; +import { CategoryKind, TransactionType } from "../../generated/prisma"; +import { toast } from "sonner"; + +function recentlyUpdated( + timeThresholdInMinutes: number, + sourceDate: Date, + referenceDate: Date = new Date() +): boolean { + const sourceTime = new Date(sourceDate).getTime(); + const referenceTime = referenceDate.getTime(); + + const thresholdMs = timeThresholdInMinutes * 60 * 1000; + + return referenceTime - sourceTime <= thresholdMs; +} function ExpenseManager() { + const [showExpenseForm, setShowExpenseForm] = React.useState(false); + const [showCategoryForm, setShowCategoryForm] = React.useState(false); + const [showImportForm, setShowImportForm] = React.useState(false); - const [showImportForm, setShowImportForm] = React.useState(true); const fileRef = React.useRef(null); + // TanStack Query hooks + const { data: categories = [], isLoading: categoriesLoading } = + useCategories(); + const { data: transactions = [], isLoading: transactionsLoading } = + useExpenseTransactions(); + const { data: expenseCategories = [] } = useExpenseCategories(); + + // Mutations + const createCategoryMutation = useCreateCategory({ + onSuccess: () => { + tracer.startActiveSpan("ui.expense_category_created", (span) => { + span.setAttributes({ + component: "ExpenseManager", + action: "category_created", + }); + span.end(); + }); + setCategoryForm({ name: "", color: "#ef4444" }); + setShowCategoryForm(false); + toast.success("Category created successfully"); + }, + onError: (error) => { + tracer.startActiveSpan("ui.expense_category_creation_failed", (span) => { + span.recordException(error); + span.setAttributes({ + component: "ExpenseManager", + action: "category_creation_failed", + "error.message": error.message, + }); + span.end(); + }); + console.error("Failed to create category:", error); + toast.error("Failed to create category"); + }, + }); + + const createTransactionMutation = useCreateTransaction({ + onSuccess: () => { + tracer.startActiveSpan("ui.expense_transaction_created", (span) => { + span.setAttributes({ + component: "ExpenseManager", + action: "transaction_created", + }); + span.end(); + }); + setTransactionForm({ + amount: "", + occurredAt: new Date().toISOString().split("T")[0], + description: "", + categoryId: "", + }); + setShowExpenseForm(false); + toast.success("Expense added successfully"); + }, + onError: (error) => { + tracer.startActiveSpan( + "ui.expense_transaction_creation_failed", + (span) => { + span.recordException(error); + span.setAttributes({ + component: "ExpenseManager", + action: "transaction_creation_failed", + "error.message": error.message, + }); + span.end(); + } + ); + console.error("Failed to create transaction:", error); + toast.error("Failed to add expense"); + }, + }); + + const deleteTransactionMutation = useDeleteTransaction({ + onSuccess: () => { + tracer.startActiveSpan("ui.expense_transaction_deleted", (span) => { + span.setAttributes({ + component: "ExpenseManager", + action: "transaction_deleted", + }); + span.end(); + }); + toast.success("Transaction deleted"); + }, + onError: (error) => { + tracer.startActiveSpan( + "ui.expense_transaction_deletion_failed", + (span) => { + span.recordException(error); + span.setAttributes({ + component: "ExpenseManager", + action: "transaction_deletion_failed", + "error.message": error.message, + }); + span.end(); + } + ); + console.error("Failed to delete transaction:", error); + toast.error("Failed to delete transaction"); + }, + }); + + const importExpensesMutation = useImportExpenses({ + onSuccess: (result) => { + tracer.startActiveSpan("ui.expenses_imported", (span) => { + span.setAttributes({ + component: "ExpenseManager", + action: "expenses_imported", + imported_count: result.imported, + skipped_count: result.skipped, + categories_created: result.categoriesCreated, + }); + span.end(); + }); + setShowImportForm(false); + setImportForm({ + dateColumn: "Date", + amountColumn: "Amount", + categoryColumn: "Category", + merchantColumn: "", + descriptionColumn: "", + }); + if (fileRef.current) fileRef.current.value = ""; + toast.success(result.message); + }, + onError: (error) => { + tracer.startActiveSpan("ui.expenses_import_failed", (span) => { + span.recordException(error); + span.setAttributes({ + component: "ExpenseManager", + action: "expenses_import_failed", + "error.message": error.message, + }); + span.end(); + }); + console.error("Failed to import expenses:", error); + toast.error("Failed to import expenses"); + }, + }); + + const setupMutation = useSetupDefaults({ + onSuccess: (result) => { + tracer.startActiveSpan("ui.expense_setup_completed", (span) => { + span.setAttributes({ + component: "ExpenseManager", + action: "setup_completed", + categories_created: result.categoriesCreated, + was_skipped: result.skipped, + }); + span.end(); + }); + toast.success("Default expense categories created"); + }, + onError: (error) => { + tracer.startActiveSpan("ui.expense_setup_failed", (span) => { + span.recordException(error); + span.setAttributes({ + component: "ExpenseManager", + action: "setup_failed", + "error.message": error.message, + }); + span.end(); + }); + console.error("Failed to setup defaults:", error); + toast.error("Failed to setup defaults"); + }, + }); + + // Form state + const [transactionForm, setTransactionForm] = React.useState({ + amount: "", + occurredAt: new Date().toISOString().split("T")[0], + description: "", + categoryId: "", + }); + + const [categoryForm, setCategoryForm] = React.useState({ + name: "", + color: "#ef4444", + }); + + const [importForm, setImportForm] = React.useState({ + dateColumn: "Date", + amountColumn: "Amount", + categoryColumn: "Category", + merchantColumn: "Merchant Name", + descriptionColumn: "Description", + }); + + // Track component mounting for telemetry + React.useEffect(() => { + tracer.startActiveSpan("ui.expense_manager_mounted", (span) => { + span.setAttributes({ + component: "ExpenseManager", + action: "mounted", + "categories.count": categories.length, + "transactions.count": transactions.length, + }); + span.end(); + }); + }, [categories.length, transactions.length]); + + const handleCreateCategory = (e: React.FormEvent) => { + e.preventDefault(); + createCategoryMutation.mutate({ + name: categoryForm.name, + kind: CategoryKind.EXPENSE, + color: categoryForm.color, + }); + }; + + const handleCreateTransaction = (e: React.FormEvent) => { + e.preventDefault(); + createTransactionMutation.mutate({ + type: TransactionType.EXPENSE, + amount: parseFloat(transactionForm.amount), + occurredAt: new Date(transactionForm.occurredAt).toISOString(), + description: transactionForm.description || undefined, + categoryId: transactionForm.categoryId || undefined, + }); + }; + + const handleImportCSV = (e: React.FormEvent) => { + e.preventDefault(); + + const file = fileRef.current?.files?.[0]; + if (!file) { + toast.error("Please select a CSV file"); + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + const csvData = event.target?.result as string; + if (!csvData) { + toast.error("Failed to read CSV file"); + return; + } + + importExpensesMutation.mutate({ + csvData, + dateColumn: importForm.dateColumn, + amountColumn: importForm.amountColumn, + categoryColumn: importForm.categoryColumn, + merchantColumn: importForm.merchantColumn || undefined, + descriptionColumn: importForm.descriptionColumn || undefined, + }); + }; + + reader.readAsText(file); + }; + + const handleSetupDefaults = () => { + setupMutation.mutate(); + }; + + const isLoading = + categoriesLoading || + transactionsLoading || + createCategoryMutation.isPending || + createTransactionMutation.isPending || + importExpensesMutation.isPending || + setupMutation.isPending; + return (
+ {/* Header */}

Expense Management

- Manage your expense drains, categories, and transactions. You can also import a CSV file of your expenses. + Manage your expense categories and transactions. You can also + import a CSV file of your expenses.

-
- +
+ {expenseCategories.length === 0 && ( + + )} + + +
+ + {/* Category Form */} + {showCategoryForm && ( + + + Add New Expense Category + + +
+
+ + + setCategoryForm((prev) => ({ + ...prev, + name: e.target.value, + })) + } + required + /> +
+
+ + + setCategoryForm((prev) => ({ + ...prev, + color: e.target.value, + })) + } + className="w-12 h-10 border" + /> +
+ + +
+
+
+ )} + + {/* Expense Transaction Form */} + {showExpenseForm && ( + + + + Add New Expense Transaction + + + +
+
+ + + setTransactionForm((prev) => ({ + ...prev, + amount: e.target.value, + })) + } + required + /> +
+ +
+ + + setTransactionForm((prev) => ({ + ...prev, + occurredAt: e.target.value, + })) + } + required + /> +
+ +
+ + +
+ +
+ +