diff --git a/backend/init.php b/backend/init.php index 4348ad2..a3fd05c 100644 --- a/backend/init.php +++ b/backend/init.php @@ -7,6 +7,7 @@ use App\Models\Transaction; use App\Models\Category; +use App\Models\Tag; echo "Creating database tables..." . PHP_EOL; @@ -15,6 +16,7 @@ $classes = [ $em->getClassMetadata(Category::class), + $em->getClassMetadata(Tag::class), $em->getClassMetadata(Transaction::class), ]; diff --git a/backend/public/index.php b/backend/public/index.php index a1a9fd2..45a0da1 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -52,6 +52,7 @@ }); // Transaction routes +$app->get('/api/v1/transactions/grid', [TransactionController::class, 'grid']); $app->get('/api/v1/transactions[/]', [TransactionController::class, 'index']); $app->get('/api/v1/transactions/{id}', [TransactionController::class, 'show']); $app->post('/api/v1/transactions[/]', [TransactionController::class, 'create']); diff --git a/backend/seed.php b/backend/seed.php index 4c9f96c..fd3b3a2 100644 --- a/backend/seed.php +++ b/backend/seed.php @@ -7,13 +7,14 @@ use App\Models\Transaction; use App\Models\Category; +use App\Models\Tag; echo "Clearing existing data..." . PHP_EOL; $em = createEntityManager(); $conn = $em->getConnection(); -$conn->executeStatement("TRUNCATE TABLE transactions, categories RESTART IDENTITY CASCADE"); +$conn->executeStatement("TRUNCATE TABLE transaction_tags, transactions, tags, categories RESTART IDENTITY CASCADE"); echo "Loading seed data from centralized JSON files..." . PHP_EOL; @@ -34,6 +35,69 @@ $em->flush(); +// Create tags +echo "Creating tags..." . PHP_EOL; +$tagNames = ['work', 'travel', 'reimbursable', 'personal', 'business', 'recurring', 'one-time']; +$tags = []; + +foreach ($tagNames as $tagName) { + $tag = new Tag(); + $tag->setName($tagName); + $em->persist($tag); + $tags[$tagName] = $tag; +} + +$em->flush(); + +// Helper function to assign tags based on transaction data +$assignTags = function($transaction, $data) use ($tags) { + $description = strtolower($data['description']); + $category = strtolower($data['category']); + + // Assign tags based on description and category + if (strpos($description, 'salary') !== false || + strpos($description, 'freelance') !== false || + strpos($description, 'bonus') !== false || + strpos($description, 'dividend') !== false || + $category === 'income' || + $category === 'work') { + $transaction->addTag($tags['work']); + } + + if (strpos($description, 'travel') !== false || + strpos($description, 'transportation') !== false || + strpos($description, 'gas') !== false || + $category === 'travel' || + $category === 'transport') { + $transaction->addTag($tags['travel']); + } + + if (strpos($description, 'rent') !== false || + strpos($description, 'subscription') !== false || + strpos($description, 'membership') !== false || + strpos($description, 'bill') !== false) { + $transaction->addTag($tags['recurring']); + } else { + $transaction->addTag($tags['one-time']); + } + + if ($category === 'work' || + strpos($description, 'software') !== false || + strpos($description, 'license') !== false) { + $transaction->addTag($tags['business']); + } else { + $transaction->addTag($tags['personal']); + } + + // Mark some transactions as reimbursable + if (strpos($description, 'travel') !== false || + strpos($description, 'transportation') !== false || + strpos($description, 'gas') !== false || + ($category === 'work' && $data['type'] === 'debit')) { + $transaction->addTag($tags['reimbursable']); + } +}; + foreach ($transactionsData as $data) { $transaction = new Transaction(); $transaction->setDescription($data['description']); @@ -42,10 +106,14 @@ $transaction->setCategory($categories[$data['category']]); $transaction->setUserId($data['user_id']); $transaction->setDate(new DateTime($data['date'])); + + // Assign tags to transaction + $assignTags($transaction, $data); + $em->persist($transaction); } $em->flush(); echo "Database seeded successfully." . PHP_EOL; -echo "Seeded " . count($categoriesData) . " categories and " . count($transactionsData) . " transactions" . PHP_EOL; +echo "Seeded " . count($categoriesData) . " categories, " . count($tagNames) . " tags, and " . count($transactionsData) . " transactions" . PHP_EOL; diff --git a/backend/src/Controllers/TransactionController.php b/backend/src/Controllers/TransactionController.php index 60f4d25..f083044 100644 --- a/backend/src/Controllers/TransactionController.php +++ b/backend/src/Controllers/TransactionController.php @@ -4,6 +4,7 @@ use App\Models\Transaction; use App\Models\Category; +use Doctrine\DBAL\ParameterType; use Doctrine\ORM\EntityManager; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -26,6 +27,8 @@ public function index(Request $request, Response $response): Response $transactionRepo = $this->em->getRepository(Transaction::class); $transactions = $transactionRepo->createQueryBuilder('t') + ->leftJoin('t.category', 'c') + ->addSelect('c') ->setFirstResult($skip) ->setMaxResults($limit) ->getQuery() @@ -174,4 +177,132 @@ public function delete(Request $request, Response $response, array $args): Respo $response->getBody()->write(json_encode($data, JSON_PRESERVE_ZERO_FRACTION)); return $response->withHeader('Content-Type', 'application/json'); } + + public function grid(Request $request, Response $response): Response + { + try { + $params = $request->getQueryParams(); + $page = isset($params['page']) ? max(1, (int)$params['page']) : 1; + $size = isset($params['size']) ? max(1, (int)$params['size']) : 10; + $sortBy = $params['sort_by'] ?? 'date'; + $sortOrder = strtolower($params['sort_order'] ?? 'desc'); + + // Validate sort_order + if ($sortOrder !== 'asc' && $sortOrder !== 'desc') { + $sortOrder = 'desc'; + } + + // Validate and sanitize sort_by column - use whitelist approach + $columnMap = [ + 'date' => 't.date', + 'description' => 't.description', + 'amount' => 't.amount', + 'category' => 'c.name', + 'id' => 't.id', + ]; + + // Only use columns from the whitelist + if (!isset($columnMap[$sortBy])) { + $sortBy = 'date'; + } + $orderByColumn = $columnMap[$sortBy]; + $orderDirection = $sortOrder === 'asc' ? 'ASC' : 'DESC'; + + $offset = ($page - 1) * $size; + + $conn = $this->em->getConnection(); + + // Get total count + $countSql = " + SELECT COUNT(DISTINCT t.id) as total + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + "; + $totalResult = $conn->fetchAssociative($countSql); + $total = (int)($totalResult['total'] ?? 0); + + // Get paginated and sorted transactions with tags + // Build ORDER BY clause safely - column names are whitelisted so safe to interpolate + $sql = " + SELECT + t.id, + t.description, + t.amount, + t.type, + t.date, + t.user_id, + t.category_id, + c.id as category_id_rel, + c.name as category_name, + COALESCE( + JSON_AGG( + JSON_BUILD_OBJECT('id', tag.id, 'name', tag.name) + ORDER BY tag.name + ) FILTER (WHERE tag.id IS NOT NULL), + '[]'::json + ) as tags + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + LEFT JOIN transaction_tags tt ON t.id = tt.transaction_id + LEFT JOIN tags tag ON tt.tag_id = tag.id + GROUP BY t.id, t.description, t.amount, t.type, t.date, t.user_id, t.category_id, c.id, c.name + ORDER BY " . $orderByColumn . " " . $orderDirection . " + LIMIT ? OFFSET ? + "; + + $result = $conn->executeQuery($sql, [$size, $offset], [ParameterType::INTEGER, ParameterType::INTEGER]); + $rows = $result->fetchAllAssociative(); + + // Format the response + $items = array_map(function($row) { + $tags = json_decode($row['tags'], true); + if (!is_array($tags)) { + $tags = []; + } + + // Format date to match API format + $date = $row['date']; + if ($date instanceof \DateTime) { + $date = $date->format('Y-m-d\TH:i:s\Z'); + } elseif (is_string($date)) { + // Try to parse and format if it's a string + try { + $dateObj = new \DateTime($date); + $date = $dateObj->format('Y-m-d\TH:i:s\Z'); + } catch (\Exception $e) { + // Keep original if parsing fails + } + } + + return [ + 'id' => (int)$row['id'], + 'description' => $row['description'], + 'amount' => (float)$row['amount'], + 'type' => $row['type'], + 'date' => $date, + 'user_id' => (int)$row['user_id'], + 'category_rel' => [ + 'id' => (int)$row['category_id_rel'], + 'name' => $row['category_name'], + ], + 'tags' => $tags, + ]; + }, $rows); + + $data = [ + 'items' => $items, + 'total' => $total, + ]; + + $response->getBody()->write(json_encode($data, JSON_PRESERVE_ZERO_FRACTION)); + return $response->withHeader('Content-Type', 'application/json'); + } catch (\Exception $e) { + $error = [ + 'detail' => 'An error occurred while fetching transactions', + 'message' => $e->getMessage(), + ]; + $response->getBody()->write(json_encode($error)); + return $response->withHeader('Content-Type', 'application/json')->withStatus(500); + } + } } diff --git a/backend/src/Models/Tag.php b/backend/src/Models/Tag.php new file mode 100644 index 0000000..28af33e --- /dev/null +++ b/backend/src/Models/Tag.php @@ -0,0 +1,77 @@ +transactions = new ArrayCollection(); + $this->created_at = new DateTime(); + $this->updated_at = new DateTime(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + $this->updated_at = new DateTime(); + return $this; + } + + public function getCreatedAt(): DateTime + { + return $this->created_at; + } + + public function getUpdatedAt(): DateTime + { + return $this->updated_at; + } + + public function getTransactions(): Collection + { + return $this->transactions; + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + ]; + } +} diff --git a/backend/src/Models/Transaction.php b/backend/src/Models/Transaction.php index 7e07920..9b8992f 100644 --- a/backend/src/Models/Transaction.php +++ b/backend/src/Models/Transaction.php @@ -3,6 +3,8 @@ namespace App\Models; use Doctrine\ORM\Mapping as ORM; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use DateTime; #[ORM\Entity] @@ -44,11 +46,16 @@ class Transaction #[ORM\Column(type: 'datetime')] private DateTime $updated_at; + #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'transactions')] + #[ORM\JoinTable(name: 'transaction_tags')] + private Collection $tags; + public function __construct() { $this->date = new DateTime(); $this->created_at = new DateTime(); $this->updated_at = new DateTime(); + $this->tags = new ArrayCollection(); } public function getId(): ?int @@ -151,6 +158,29 @@ public function getUpdatedAt(): DateTime return $this->updated_at; } + public function getTags(): Collection + { + return $this->tags; + } + + public function addTag(Tag $tag): self + { + if (!$this->tags->contains($tag)) { + $this->tags->add($tag); + $this->updated_at = new DateTime(); + } + return $this; + } + + public function removeTag(Tag $tag): self + { + if ($this->tags->contains($tag)) { + $this->tags->removeElement($tag); + $this->updated_at = new DateTime(); + } + return $this; + } + public function toArray(): array { return [ @@ -162,6 +192,7 @@ public function toArray(): array 'user_id' => $this->user_id, 'date' => $this->date->format('Y-m-d\TH:i:s\Z'), 'category_rel' => $this->category->toArray(), + 'tags' => array_map(fn($tag) => $tag->toArray(), $this->tags->toArray()), ]; } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4bcf768..d156ccd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,15 +1,16 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import './App.css'; import Header from './components/Header'; import TransactionList from './components/TransactionList'; +import TransactionGrid from './components/TransactionGrid'; function App() { const [userActivityCount, setUserActivityCount] = useState(0); - const handleUserProfileClick = () => { + const handleUserProfileClick = useCallback(() => { console.log("User profile clicked!"); setUserActivityCount(prev => prev + 1); - }; + }, []); return (
@@ -18,6 +19,7 @@ function App() {

FinTech Dashboard

User Activity: {userActivityCount}

+
); diff --git a/frontend/src/components/TransactionGrid.tsx b/frontend/src/components/TransactionGrid.tsx new file mode 100644 index 0000000..4f87725 --- /dev/null +++ b/frontend/src/components/TransactionGrid.tsx @@ -0,0 +1,281 @@ +import React, { useEffect, useState, useCallback } from 'react'; + +interface Tag { + id: number; + name: string; +} + +interface Transaction { + id: number; + description: string; + amount: number; + type: string; + category_rel: { + id: number; + name: string; + }; + tags: Tag[]; + date: string; + user_id: number; +} + +interface GridResponse { + items: Transaction[]; + total: number; +} + +type SortColumn = 'date' | 'description' | 'amount' | 'category'; +type SortOrder = 'asc' | 'desc'; + +const TransactionGrid: React.FC = () => { + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [sortBy, setSortBy] = useState('date'); + const [sortOrder, setSortOrder] = useState('desc'); + const pageSize = 10; + + const fetchTransactions = useCallback(async () => { + try { + setLoading(true); + setError(null); + const backendUrl = import.meta.env.VITE_BACKEND_URL; + const params = new URLSearchParams({ + page: page.toString(), + size: pageSize.toString(), + sort_by: sortBy, + sort_order: sortOrder, + }); + const response = await fetch(`${backendUrl}/api/v1/transactions/grid?${params}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data: GridResponse = await response.json(); + setTransactions(data.items); + setTotal(data.total); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'An error occurred'); + } finally { + setLoading(false); + } + }, [page, sortBy, sortOrder]); + + useEffect(() => { + fetchTransactions(); + }, [fetchTransactions]); + + const handleSort = (column: SortColumn) => { + if (sortBy === column) { + // Toggle sort order if clicking the same column + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + // Set new column and default to ascending + setSortBy(column); + setSortOrder('asc'); + } + setPage(1); // Reset to first page when sorting changes + }; + + const handlePreviousPage = () => { + if (page > 1) { + setPage(page - 1); + } + }; + + const handleNextPage = () => { + const totalPages = Math.ceil(total / pageSize); + if (page < totalPages) { + setPage(page + 1); + } + }; + + const getSortIcon = (column: SortColumn) => { + if (sortBy !== column) { + return ; + } + return sortOrder === 'asc' ? : ; + }; + + const totalPages = Math.ceil(total / pageSize); + + if (loading) { + return
Loading transactions...
; + } + + if (error) { + return
Error: {error}
; + } + + return ( +
+
+

Transaction Grid

+

+ Showing {transactions.length > 0 ? (page - 1) * pageSize + 1 : 0} to {Math.min(page * pageSize, total)} of {total} transactions +

+
+
+ + + + + + + + + + + + {transactions.map((transaction, index) => ( + + + + + + + + ))} + {transactions.length === 0 && ( + + + + )} + +
handleSort('date')} + > +
+ Date + {getSortIcon('date')} +
+
handleSort('description')} + > +
+ Description + {getSortIcon('description')} +
+
handleSort('amount')} + > +
+ Amount + {getSortIcon('amount')} +
+
handleSort('category')} + > +
+ Category + {getSortIcon('category')} +
+
+ Tags +
+ {new Date(transaction.date).toLocaleDateString()} + + {transaction.description} + + + {transaction.type === 'credit' ? '+' : '-'}${Math.abs(transaction.amount).toFixed(2)} + + + {transaction.category_rel.name} + +
+ {transaction.tags && transaction.tags.length > 0 ? ( + transaction.tags.map((tag) => ( + + {tag.name} + + )) + ) : ( + No tags + )} +
+
+ No transactions found. +
+
+ {totalPages > 1 && ( +
+
+ + +
+
+
+

+ Page {page} of {totalPages} +

+
+
+ +
+
+
+ )} +
+ ); +}; + +export default React.memo(TransactionGrid); diff --git a/frontend/src/components/TransactionList.tsx b/frontend/src/components/TransactionList.tsx index adbfd1d..10ded29 100644 --- a/frontend/src/components/TransactionList.tsx +++ b/frontend/src/components/TransactionList.tsx @@ -99,4 +99,4 @@ const TransactionList: React.FC = () => { ); }; -export default TransactionList; \ No newline at end of file +export default React.memo(TransactionList); \ No newline at end of file