Refactor Home, Investments, and Transactions Controllers; enhance transaction pagination; update investment historical rates chart;

This commit is contained in:
Kiyan 2026-02-22 21:25:34 +02:00
parent 052b3a5fed
commit 975b36d097
12 changed files with 312 additions and 561 deletions

View File

@ -34,7 +34,7 @@ public class PdfManager {
public List<Transaction> extractTransactions() { public List<Transaction> extractTransactions() {
PagePdfDocumentReader pdfReader = PagePdfDocumentReader pdfReader =
new PagePdfDocumentReader("/static/statements/account_statement_1-Jan-2024_to_2-Feb-2026.pdf"); new PagePdfDocumentReader("/static/statements/statement.pdf");
List<Document> documents = pdfReader.get(); List<Document> documents = pdfReader.get();
StringBuilder documentText = new StringBuilder(); StringBuilder documentText = new StringBuilder();

View File

@ -1,5 +1,10 @@
package com.example.FinanceTracker.Controllers; package com.example.FinanceTracker.Controllers;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
@ -11,6 +16,11 @@ import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.example.FinanceTracker.Components.PdfManager;
import com.example.FinanceTracker.Models.Transaction; import com.example.FinanceTracker.Models.Transaction;
import com.example.FinanceTracker.Repo.CategoriesRepo; import com.example.FinanceTracker.Repo.CategoriesRepo;
import com.example.FinanceTracker.Services.TransactionService; import com.example.FinanceTracker.Services.TransactionService;
@ -24,44 +34,68 @@ public class AddTransaction {
@Autowired @Autowired
private CategoriesRepo categoriesRepo; private CategoriesRepo categoriesRepo;
@Autowired
private PdfManager pdfManager;
// Define the upload directory
private static final String UPLOAD_DIR = "src/main/resources/static/statements/";
@GetMapping("/addTransaction") @GetMapping("/addTransaction")
public String addTransaction(Model model) { public String addTransaction(Model model) {
model.addAttribute("transaction", new Transaction()); model.addAttribute("transaction", new Transaction());
List<String> categoriesNames = categoriesRepo.findAllCategoryNames(); List<String> categoriesNames = categoriesRepo.findAllCategoryNames();
model.addAttribute("categories", categoriesNames); model.addAttribute("categories", categoriesNames);
// footer
model.addAttribute("footerDetails", "Copyright © FinanceTracker " + LocalDate.now().getYear()); model.addAttribute("footerDetails", "Copyright © FinanceTracker " + LocalDate.now().getYear());
return "add-transaction"; return "add-transaction";
} }
@PostMapping("/addTranaction") @PostMapping("/addTranaction")
public String addNewTranactions(@ModelAttribute Transaction transaction, Model model) { public String addNewTranactions(@ModelAttribute Transaction transaction, Model model) {
transactionValidation(transaction); transactionValidation(transaction);
transactionService.save(transaction); transactionService.save(transaction);
// Set Category Dropdown menu
List<String> categoriesNames = categoriesRepo.findAllCategoryNames(); List<String> categoriesNames = categoriesRepo.findAllCategoryNames();
model.addAttribute("categories", categoriesNames); model.addAttribute("categories", categoriesNames);
return "add-transaction"; return "add-transaction";
}
@PostMapping("/uploadStatement")
public String uploadStatement(@RequestParam("file") MultipartFile file, RedirectAttributes redirectAttributes) {
if (file.isEmpty() || !file.getOriginalFilename().endsWith(".pdf")) {
redirectAttributes.addFlashAttribute("message", "Please select a valid PDF file.");
return "redirect:/addTransaction";
}
try {
// Ensure directory exists
Path uploadPath = Paths.get(UPLOAD_DIR);
if (!Files.exists(uploadPath)) {
Files.createDirectories(uploadPath);
}
// Resolve file path and save (Replace existing)
Path filePath = uploadPath.resolve("statement.pdf");
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
redirectAttributes.addFlashAttribute("message",
"File uploaded successfully: " + file.getOriginalFilename());
transactionService.saveAll(pdfManager.extractTransactions());
} catch (IOException e) {
e.printStackTrace();
redirectAttributes.addFlashAttribute("message", "Failed to upload file.");
}
return "redirect:/addTransaction";
} }
private void transactionValidation(Transaction transaction) { private void transactionValidation(Transaction transaction) {
// Set Balance
BigDecimal balance = new BigDecimal(transactionService.getLatestBalance()); BigDecimal balance = new BigDecimal(transactionService.getLatestBalance());
transaction.setBalance( transaction.setBalance(
balance.add(transaction.getMoneyIn()) balance.add(transaction.getMoneyIn())
.add(transaction.getFee()).setScale(2, RoundingMode.HALF_UP)); .add(transaction.getFee()).setScale(2, RoundingMode.HALF_UP));
// Set Money In/Money Out
if (transaction.getMoneyIn().compareTo(BigDecimal.ZERO) > 0) { if (transaction.getMoneyIn().compareTo(BigDecimal.ZERO) > 0) {
transaction.setMoneyOut(new BigDecimal(0)); transaction.setMoneyOut(new BigDecimal(0));
} else if ((transaction.getMoneyIn().compareTo(BigDecimal.ZERO) < 0)) { } else if ((transaction.getMoneyIn().compareTo(BigDecimal.ZERO) < 0)) {
@ -69,11 +103,8 @@ public class AddTransaction {
transaction.setMoneyIn(new BigDecimal(0)); transaction.setMoneyIn(new BigDecimal(0));
} }
// Handle Fees
if (transaction.getFee() == null) { if (transaction.getFee() == null) {
transaction.setFee(new BigDecimal(0)); transaction.setFee(new BigDecimal(0));
} }
} }
} }

View File

@ -16,7 +16,6 @@ import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import com.example.FinanceTracker.Components.BootstrapColours; import com.example.FinanceTracker.Components.BootstrapColours;
//import com.example.FinanceTracker.Components.PdfManager;
import com.example.FinanceTracker.DTOs.GroupedTransaction; import com.example.FinanceTracker.DTOs.GroupedTransaction;
import com.example.FinanceTracker.DTOs.Home.OverviewCard; import com.example.FinanceTracker.DTOs.Home.OverviewCard;
import com.example.FinanceTracker.Models.Transaction; import com.example.FinanceTracker.Models.Transaction;
@ -25,10 +24,6 @@ import com.example.FinanceTracker.Services.TransactionService;
@Controller @Controller
public class HomeController { public class HomeController {
// @Autowired
// private PdfManager pdfManager;
@Autowired @Autowired
private TransactionService transactionsService; private TransactionService transactionsService;
@ -41,8 +36,6 @@ public class HomeController {
@GetMapping("/") @GetMapping("/")
public String Home(Model model) { public String Home(Model model) {
//transactionsService.saveAll(pdfManager.extractTransactions());
// Balance Text // Balance Text
model.addAttribute("latestBalance", model.addAttribute("latestBalance",
"Balance: " + currencyFormat.format(transactionsService.getLatestBalance()) + "*"); "Balance: " + currencyFormat.format(transactionsService.getLatestBalance()) + "*");

View File

@ -3,6 +3,7 @@ package com.example.FinanceTracker.Controllers;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -10,6 +11,7 @@ import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import com.example.FinanceTracker.DTOs.Investments.CurrRate; import com.example.FinanceTracker.DTOs.Investments.CurrRate;
import com.example.FinanceTracker.DTOs.Investments.Currency; import com.example.FinanceTracker.DTOs.Investments.Currency;
import com.example.FinanceTracker.DTOs.Investments.ExchangeRate; import com.example.FinanceTracker.DTOs.Investments.ExchangeRate;
@ -27,66 +29,53 @@ public class InvestmentsController {
@GetMapping("/investments") @GetMapping("/investments")
public String investments(Model model) { public String investments(Model model) {
// Stats and Header
model.addAttribute("latestBalance", "Total Invested: " + transactionService.getTotalInvested() + "*");
// Heading // Navigation / UI Elements
model.addAttribute("latestBalance", "Total Invested: " +transactionService.getTotalInvested() + "*"); model.addAttribute("currRates", investmentService.getCurrRates());
model.addAttribute("allCurrencies", investmentService.getAllCurrencies());
// Create Currency Pills
List<CurrRate> allRates = investmentService.getCurrRates();
model.addAttribute("currRates", allRates);
// Populate Historical Rates Dropdown
List<Currency> allCurrencies = investmentService.getAllCurrencies();
model.addAttribute("allCurrencies", allCurrencies);
// historical rates chart
List<ExchangeRate> historicalExchangeRates = investmentService.getHistoricalRates("USD", 1).reversed();
// Get all the rates from the historical Exchange Rates
ArrayList<BigDecimal> historicalRates = new ArrayList<>();
ArrayList<LocalDate> historicalRatesLabels = new ArrayList<>();
for (ExchangeRate exchangeRate : historicalExchangeRates) {
historicalRates.add(exchangeRate.getRate());
historicalRatesLabels.add(exchangeRate.getCreatedAt().toLocalDate());
}
model.addAttribute("historicalRates", historicalRates);
model.addAttribute("labels", historicalRatesLabels);
// footer
model.addAttribute("footerDetails", "Copyright © FinanceTracker " + LocalDate.now().getYear()); model.addAttribute("footerDetails", "Copyright © FinanceTracker " + LocalDate.now().getYear());
// Default Chart Data: USD for 1 Week
populateChartModel("USD", "WEEK", model);
return "investments"; return "investments";
} }
@GetMapping("/historicalRates") @GetMapping("/historicalRates")
public String getHistoricalRates( public String getHistoricalRates(
@RequestParam("duration") String durationCode, @RequestParam(value = "duration", defaultValue = "WEEK") String durationCode,
@RequestParam("currencyChange") String currencyChange, @RequestParam(value = "currencyChange", defaultValue = "USD") String currencyChange,
Model model) { Model model) {
// historical rates chart populateChartModel(currencyChange, durationCode, model);
int duration = 1;
if (durationCode.equals("MONTH"))
duration = 2;
if (durationCode.equals("YEAR"))
duration = 3;
List<ExchangeRate> historicalExchangeRates = investmentService.getHistoricalRates(currencyChange, duration)
.reversed();
// Get all the rates from the historical Exchange Rates
ArrayList<BigDecimal> historicalRates = new ArrayList<>();
ArrayList<LocalDate> historicalRatesLabels = new ArrayList<>();
for (ExchangeRate exchangeRate : historicalExchangeRates) {
historicalRates.add(exchangeRate.getRate());
historicalRatesLabels.add(exchangeRate.getCreatedAt().toLocalDate());
}
model.addAttribute("historicalRates", historicalRates);
model.addAttribute("labels", historicalRatesLabels);
// Return only the fragment for HTMX to inject
return "investments :: HistoricalRatesChart"; return "investments :: HistoricalRatesChart";
} }
/**
* Helper to process business logic for chart data
*/
private void populateChartModel(String currencyCode, String durationCode, Model model) {
int durationKey = 1;
if ("MONTH".equals(durationCode)) durationKey = 2;
if ("YEAR".equals(durationCode)) durationKey = 3;
// Fetch rates and ensure they are sorted by date ascending for the chart
List<ExchangeRate> ratesList = investmentService.getHistoricalRates(currencyCode, durationKey);
Collections.reverse(ratesList);
List<BigDecimal> data = new ArrayList<>();
List<LocalDate> labels = new ArrayList<>();
for (ExchangeRate er : ratesList) {
data.add(er.getRate());
labels.add(er.getCreatedAt().toLocalDate());
}
model.addAttribute("historicalRates", data);
model.addAttribute("labels", labels);
}
} }

View File

@ -1,102 +1,58 @@
package com.example.FinanceTracker.Controllers; package com.example.FinanceTracker.Controllers;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView;
import com.example.FinanceTracker.Models.Transaction; import com.example.FinanceTracker.Models.Transaction;
import com.example.FinanceTracker.Services.TransactionService; import com.example.FinanceTracker.Services.TransactionService;
@Controller @Controller
public class TransactionsController { public class TransactionsController {
private int pageNum = 0;
private int pageSize = 10;
@Autowired @Autowired
private TransactionService transactionService; private TransactionService transactionService;
@GetMapping("/transactions") // Main Page Load
public String transactions(Model model) { @GetMapping("/transactions")
public String transactions(
Model model,
@RequestParam(defaultValue = "0") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
Page<Transaction> pageResults = transactionService.findAllPaged(pageNum, pageSize); prepareModel(model, "", pageNum, pageSize);
model.addAttribute("footerDetails", "Copyright © FinanceTracker " + LocalDate.now().getYear());
return "transactions";
}
model.addAttribute("pageNum", pageNum); @GetMapping({ "/transactions-fragment", "/searchTransactions" })
model.addAttribute("pageSize", pageResults.getTotalPages()); public String getTransactionsFragment(
model.addAttribute("transactionCount", pageResults.getNumberOfElements()); Model model,
model.addAttribute("transactions", pageResults); @RequestParam(value = "searchValue", required = false, defaultValue = "") String searchValue,
@RequestParam(defaultValue = "0") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
// footer prepareModel(model, searchValue, pageNum, pageSize);
model.addAttribute("footerDetails", "Copyright © FinanceTracker " + LocalDate.now().getYear()); // Returns only the specific fragment inside transactions.html
return "transactions :: transactionList";
}
return "transactions"; private void prepareModel(Model model, String search, int page, int size) {
Page<Transaction> results;
if (search == null || search.isBlank()) {
results = transactionService.findAllPaged(page, size);
} else {
results = transactionService.searchTransactions(search, page, size);
} }
@GetMapping("/changePageSize") model.addAttribute("transactions", results.getContent());
public Collection<ModelAndView> changePageSize( model.addAttribute("pageNum", page);
@RequestParam("pageSizeCB") int pageSizeCB) { model.addAttribute("totalPages", results.getTotalPages());
this.pageSize = pageSizeCB; model.addAttribute("searchValue", search);
this.pageNum = 0; model.addAttribute("pageSize", size);
}
Page<Transaction> results = transactionService.findAllPaged(pageNum, pageSize);
return List.of(
new ModelAndView("transactions :: transactionTable",
Map.of("transactions", results)),
setPageDetails(results));
}
@GetMapping("/searchTransactions")
public Collection<ModelAndView> SearchTable(@RequestParam("searchValue") String searchValue) {
this.pageNum = 0;
Page<Transaction> searchResults = transactionService.searchTransactions(searchValue, pageNum, pageSize);
return List.of(
new ModelAndView("transactions :: transactionTable",
Map.of("transactions", searchResults)),
setPageDetails(searchResults));
}
@GetMapping("/previousPage")
public Collection<ModelAndView> previousPage() {
if (pageNum > 0) {
pageNum--;
}
Page<Transaction> results = transactionService.findAllPaged(pageNum, pageSize);
return List.of(
new ModelAndView("transactions :: transactionTable",
Map.of("transactions", results)),
setPageDetails(results));
}
@GetMapping("/nextPage")
public Collection<ModelAndView> nextPage() {
Page<Transaction> current = transactionService.findAllPaged(pageNum, pageSize);
if (pageNum < current.getTotalPages() - 1) {
pageNum++;
}
Page<Transaction> results = transactionService.findAllPaged(pageNum, pageSize);
return List.of(
new ModelAndView("transactions :: transactionTable",
Map.of("transactions", results)),
setPageDetails(results));
}
private ModelAndView setPageDetails(Page<?> page) {
return new ModelAndView("transactions :: tableInfo",
Map.of("pageNum", pageNum, "pageSize", page.getTotalPages(), "transactionCount",
page.getNumberOfElements()));
}
} }

View File

@ -19,8 +19,9 @@ import com.example.FinanceTracker.Models.Transaction;
public interface TransactionRepo extends JpaRepository<Transaction, UUID> { public interface TransactionRepo extends JpaRepository<Transaction, UUID> {
@Query(value = """ @Query(value = """
SELECT * SELECT t.balance, t.date, t.money_in, t.money_out, t.description, c.category_name as category, t.fee, t.id
FROM transactions FROM transactions t
JOIN categories c ON t.category = c.id
WHERE date BETWEEN :fromDate AND :toDate WHERE date BETWEEN :fromDate AND :toDate
ORDER BY date ORDER BY date
""", nativeQuery = true) """, nativeQuery = true)
@ -85,6 +86,7 @@ public interface TransactionRepo extends JpaRepository<Transaction, UUID> {
FROM transactions t JOIN categories c ON t.category = c.id FROM transactions t JOIN categories c ON t.category = c.id
WHERE money_in != 0 WHERE money_in != 0
GROUP BY category GROUP BY category
ORDER BY SUM(money_in) DESC
""", nativeQuery = true) """, nativeQuery = true)
List<GroupedTransaction> getIncomeGroupedTransactions(); List<GroupedTransaction> getIncomeGroupedTransactions();
@ -110,6 +112,8 @@ public interface TransactionRepo extends JpaRepository<Transaction, UUID> {
FROM transactions t JOIN categories c ON t.category = c.id FROM transactions t JOIN categories c ON t.category = c.id
WHERE money_out != 0 WHERE money_out != 0
GROUP BY category GROUP BY category
ORDER BY SUM(money_out)
LIMIT 10
""", nativeQuery = true) """, nativeQuery = true)
List<GroupedTransaction> getExpenditureGroupedTransactions(); List<GroupedTransaction> getExpenditureGroupedTransactions();

View File

@ -8,68 +8,19 @@
<title>FinanceTracker | Add Transaction</title> <title>FinanceTracker | Add Transaction</title>
<link rel="stylesheet" th:href="@{/assets/bootstrap/css/bootstrap.min.css}"> <link rel="stylesheet" th:href="@{/assets/bootstrap/css/bootstrap.min.css}">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style> <style>
body { body { background-color: #f8f9fc; }
background-color: #f8f9fc; .card { border: none; border-radius: 0.75rem; }
} .card-header { background: transparent; border-bottom: 1px solid rgba(0, 0, 0, 0.05); }
.material-icons { vertical-align: middle; font-size: 20px; }
.card { .input-group-text { background-color: #f8f9fc; border-right: none; color: #4e73df; }
border: none; .form-control, .form-select { border-left: none; }
border-radius: 0.75rem; .form-control:focus, .form-select:focus { box-shadow: none; border-color: #dee2e6; }
} .input-group:focus-within .input-group-text { border-color: #86b7fe; }
label { font-weight: 600; color: #495057; margin-bottom: 0.4rem; font-size: 0.85rem; }
.card-header { .scroll-to-top { position: fixed; right: 1rem; bottom: 1rem; width: 40px; height: 40px; background-color: #0d6efd; color: #fff; display: flex; align-items: center; justify-content: center; text-decoration: none; }
background: transparent;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.material-icons {
vertical-align: middle;
font-size: 20px;
}
.input-group-text {
background-color: #f8f9fc;
border-right: none;
color: #4e73df;
}
.form-control, .form-select {
border-left: none;
}
.form-control:focus, .form-select:focus {
box-shadow: none;
border-color: #dee2e6;
}
.input-group:focus-within .input-group-text {
border-color: #86b7fe;
}
label {
font-weight: 600;
color: #495057;
margin-bottom: 0.4rem;
font-size: 0.85rem;
}
.scroll-to-top {
position: fixed;
right: 1rem;
bottom: 1rem;
width: 40px;
height: 40px;
background-color: #0d6efd;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
</style> </style>
</head> </head>
@ -83,9 +34,14 @@
<div class="container-fluid px-4"> <div class="container-fluid px-4">
<div th:if="${message}" class="alert alert-info alert-dismissible fade show" role="alert">
<span th:text="${message}"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="mb-0">Add Transaction</h3> <h3 class="mb-0">Add Transaction</h3>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#exampleModal"> <button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
<span class="material-icons me-1">upload_file</span> <span class="material-icons me-1">upload_file</span>
Bulk Upload Bulk Upload
</button> </button>
@ -161,23 +117,25 @@
<div th:replace="~{fragments/footer :: footer}"></div> <div th:replace="~{fragments/footer :: footer}"></div>
<div class="modal fade" id="exampleModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="uploadModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow"> <div class="modal-content border-0 shadow">
<div class="modal-header border-bottom-0"> <form th:action="@{/uploadStatement}" method="post" enctype="multipart/form-data">
<h5 class="modal-title fw-bold">Import CSV</h5> <div class="modal-header border-bottom-0">
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <h5 class="modal-title fw-bold">Import Statement</h5>
</div> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<div class="modal-body py-4">
<p class="text-muted small">Select a .csv or .xlsx file to import multiple transactions at once.</p>
<div class="input-group">
<input type="file" class="form-control" id="inputGroupFile02" style="border-left: 1px solid #dee2e6;">
</div> </div>
</div> <div class="modal-body py-4">
<div class="modal-footer border-top-0"> <p class="text-muted small">Select a .pdf file to import. Existing files with the same name will be overwritten.</p>
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button> <div class="input-group">
<button type="button" class="btn btn-primary">Process Upload</button> <input type="file" name="file" class="form-control" id="inputGroupFile02" accept=".pdf" required style="border-left: 1px solid #dee2e6;">
</div> </div>
</div>
<div class="modal-footer border-top-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Process Upload</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
@ -188,11 +146,9 @@
</a> </a>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
<script th:src="@{/assets/bootstrap/js/bootstrap.min.js}"></script> <script th:src="@{/assets/bootstrap/js/bootstrap.min.js}"></script>
<script th:src="@{/assets/js/bs-init.js}"></script> <script th:src="@{/assets/js/bs-init.js}"></script>
<script th:src="@{/assets/js/theme.js}"></script> <script th:src="@{/assets/js/theme.js}"></script>
</body> </body>
</html> </html>

View File

@ -113,8 +113,7 @@
<!-- Header --> <!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="mb-0">Dashboard</h3> <h3 class="mb-0">Dashboard</h3>
<a th:hx-get="@{/summary}" hx-trigger="click" hx-swap="none" <a th:hx-get="@{/summary}" hx-trigger="click" hx-swap="none" class="btn btn-sm btn-primary">
class="btn btn-sm btn-primary">
<span class="material-icons me-1">description</span> <span class="material-icons me-1">description</span>
Generate Report Generate Report
</a> </a>
@ -216,17 +215,17 @@
</div> </div>
<!-- Insights Panel --> <!-- Logs Panel -->
<div class="col-lg-3"> <div class="col-lg-3">
<div class="card shadow h-100"> <div class="card shadow h-100">
<div class="card-header d-flex align-items-center gap-2"> <div class="card-header d-flex align-items-center gap-2">
<span class="material-icons text-primary">insights</span> <span class="material-icons text-primary">Logs</span>
<h6 class="fw-bold text-primary mb-0">Insights</h6> <h6 class="fw-bold text-primary mb-0">Logs</h6>
</div> </div>
<div class="card-body small"> <div class="card-body small">
<div class="insight-item"> <div class="log-item">
<div class="insight-icon"> <div class="log-icon">
<span class="material-icons">warning</span> <span class="material-icons">warning</span>
</div> </div>
<div> <div>
@ -235,8 +234,8 @@
</div> </div>
</div> </div>
<div class="insight-item"> <div class="log-item">
<div class="insight-icon"> <div class="log-icon">
<span class="material-icons">savings</span> <span class="material-icons">savings</span>
</div> </div>
<div> <div>
@ -245,8 +244,8 @@
</div> </div>
</div> </div>
<div class="insight-item"> <div class="log-item">
<div class="insight-icon"> <div class="log-icon">
<span class="material-icons">calendar_month</span> <span class="material-icons">calendar_month</span>
</div> </div>
<div> <div>
@ -308,7 +307,8 @@
type: /*[[${incomeChartType}]]*/ 'doughnut', type: /*[[${incomeChartType}]]*/ 'doughnut',
data: /*[[${incomeDoughnutdata}]]*/[], data: /*[[${incomeDoughnutdata}]]*/[],
labels: /*[[${incomeDoughnutLabels}]]*/[], labels: /*[[${incomeDoughnutLabels}]]*/[],
colours: /*[[${incomeDoughnutColours}]]*/[] //colours: /*[[${incomeDoughnutColours}]]*/[]
colours: ['#126618', '#1A7A1F', '#238F25', '#2FA42C', '#3CB833', '#58C447', '#76CF65', '#94DB83', '#B2E6A1', '#D0F0C0']
}); });
renderChart({ renderChart({
@ -316,7 +316,8 @@
type: /*[[${expenseChartType}]]*/ 'doughnut', type: /*[[${expenseChartType}]]*/ 'doughnut',
data: /*[[${expenseDoughnutdata}]]*/[], data: /*[[${expenseDoughnutdata}]]*/[],
labels: /*[[${expenseDoughnutLabels}]]*/[], labels: /*[[${expenseDoughnutLabels}]]*/[],
colours: /*[[${expenseDoughnutColours}]]*/[] //colours: /*[[${expenseDoughnutColours}]]*/[]
colours: ['#731A1A', '#8A2020', '#A12727', '#B82D2D', '#CF3333', '#D74D4D', '#DE6666', '#E68080', '#ED9A9A', '#F4B4B4']
}); });
new Chart(document.getElementById('categoryBarChart'), { new Chart(document.getElementById('categoryBarChart'), {

View File

@ -7,10 +7,7 @@
<meta name="description" content="Personal finance dashboard showing income, expenses, and transactions"> <meta name="description" content="Personal finance dashboard showing income, expenses, and transactions">
<title>FinanceTracker | Investments</title> <title>FinanceTracker | Investments</title>
<!-- Bootstrap -->
<link rel="stylesheet" th:href="@{/assets/bootstrap/css/bootstrap.min.css}"> <link rel="stylesheet" th:href="@{/assets/bootstrap/css/bootstrap.min.css}">
<!-- Material Icons -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style> <style>
@ -18,111 +15,29 @@
--positive-bg: rgba(40, 167, 69, 0.12); --positive-bg: rgba(40, 167, 69, 0.12);
--negative-bg: rgba(220, 53, 69, 0.12); --negative-bg: rgba(220, 53, 69, 0.12);
} }
body { background-color: #f8f9fc; }
body { .chart-area { position: relative; min-height: 260px; }
background-color: #f8f9fc; .card { border: none; border-radius: 0.75rem; }
} .card-header { background: transparent; border-bottom: 1px solid rgba(0, 0, 0, 0.05); }
.txn-positive td {
background-color: var(--positive-bg);
}
.txn-negative td {
background-color: var(--negative-bg);
}
.numeric {
text-align: right;
white-space: nowrap;
}
.chart-area {
position: relative;
min-height: 260px;
}
.card {
border: none;
border-radius: 0.75rem;
}
.card-header {
background: transparent;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.material-icons {
vertical-align: middle;
font-size: 20px;
}
.insight-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 0.5rem;
background-color: #f8f9fc;
}
.insight-item+.insight-item {
margin-top: 0.75rem;
}
.insight-icon {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: #e9ecef;
color: #495057;
}
.scroll-to-top {
position: fixed;
right: 1rem;
bottom: 1rem;
width: 40px;
height: 40px;
background-color: #0d6efd;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
</style> </style>
</head> </head>
<body id="page-top"> <body id="page-top">
<div id="wrapper"> <div id="wrapper">
<!-- Sidebar -->
<div th:replace="~{fragments/sidebar-nav :: sidebar-nav}"></div> <div th:replace="~{fragments/sidebar-nav :: sidebar-nav}"></div>
<div id="content-wrapper" class="d-flex flex-column"> <div id="content-wrapper" class="d-flex flex-column">
<div id="content"> <div id="content">
<!-- Topbar -->
<div th:replace="~{fragments/topbar :: topbar}"></div> <div th:replace="~{fragments/topbar :: topbar}"></div>
<div class="container-fluid px-4"> <div class="container-fluid px-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="mb-0">Investments</h3> <h3 class="mb-0">Investments</h3>
<!-- <a th:hx-get="@{/summary}" hx-trigger="click" hx-swap="none" class="btn btn-sm btn-primary">
<span class="material-icons me-1">description</span>
Generate Report
</a> -->
</div> </div>
<div class="row mb-4"> <div class="row mb-4">
<div th:each="rate : ${currRates}" class="col-lg-2 col-md-3 col-sm-4 mb-2 text-center"> <div th:each="rate : ${currRates}" class="col-lg-2 col-md-3 col-sm-4 mb-2 text-center">
<span th:replace="~{fragments/investments/curr-badges :: curr-pill(currRate=${rate})}"> <span th:replace="~{fragments/investments/curr-badges :: curr-pill(currRate=${rate})}"></span>
</span>
</div> </div>
</div> </div>
@ -130,145 +45,90 @@
<div class="col-lg-12"> <div class="col-lg-12">
<div class="card shadow"> <div class="card shadow">
<div class="card-header"> <div class="card-header">
<div class="row"> <div class="d-flex justify-content-between align-items-center">
<div class="col-lg-4 "> <span class="fw-bold text-primary">Historical Rates</span>
<span class="fw-bold text-primary mb-1">Historical Rates</span>
</div>
<form id="historicalRatesControls" >
<!-- Duration Radios --> <form id="historicalRatesControls" class="d-flex gap-2">
<div class="btn-group d-flex justify-content-center mb-1" role="group"> <div class="btn-group" role="group">
<input type="radio" name="duration" value="WEEK" class="btn-check" <input type="radio" name="duration" value="WEEK" class="btn-check" id="opt1" checked
id="option1" checked hx-get="/historicalRates" hx-trigger="change" hx-get="/historicalRates" hx-target="#HistoricalRatesChartContainer" hx-include="#historicalRatesControls">
hx-target="#HistoricalRatesChartContainer" hx-swap="outerHTML" <label class="btn btn-outline-secondary btn-sm" for="opt1">Week</label>
hx-include="#historicalRatesControls">
<label class="btn btn-secondary" for="option1">Week</label>
<input type="radio" name="duration" value="MONTH" class="btn-check" <input type="radio" name="duration" value="MONTH" class="btn-check" id="opt2"
id="option2" hx-get="/historicalRates" hx-trigger="change" hx-get="/historicalRates" hx-target="#HistoricalRatesChartContainer" hx-include="#historicalRatesControls">
hx-target="#HistoricalRatesChartContainer" hx-swap="outerHTML" <label class="btn btn-outline-secondary btn-sm" for="opt2">Month</label>
hx-include="#historicalRatesControls">
<label class="btn btn-secondary" for="option2">Month</label>
<input type="radio" name="duration" value="YEAR" class="btn-check" <input type="radio" name="duration" value="YEAR" class="btn-check" id="opt3"
id="option3" hx-get="/historicalRates" hx-trigger="change" hx-get="/historicalRates" hx-target="#HistoricalRatesChartContainer" hx-include="#historicalRatesControls">
hx-target="#HistoricalRatesChartContainer" hx-swap="outerHTML" <label class="btn btn-outline-secondary btn-sm" for="opt3">Year</label>
hx-include="#historicalRatesControls">
<label class="btn btn-secondary" for="option3">Year</label>
</div> </div>
<!-- Currency Select --> <select class="form-select form-select-sm" name="currencyChange" style="width: auto;"
<select class="form-select" name="currencyChange" style="max-width: 200px;" hx-get="/historicalRates" hx-target="#HistoricalRatesChartContainer" hx-include="#historicalRatesControls">
hx-get="/historicalRates" hx-trigger="change"
hx-target="#HistoricalRatesChartContainer" hx-swap="outerHTML"
hx-include="#historicalRatesControls">
<option th:each="currency : ${allCurrencies}" <option th:each="currency : ${allCurrencies}"
th:selected="${currency.code == 'USD'}"
th:value="${currency.code}" th:value="${currency.code}"
th:text="${currency.code + ' - ' + currency.currencyName}"> th:text="${currency.code + ' - ' + currency.currencyName}">
</option> </option>
</select> </select>
</form> </form>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="HistoricalRatesChartContainer" th:fragment="HistoricalRatesChart" <div id="HistoricalRatesChartContainer" th:fragment="HistoricalRatesChart" style="height: 350px; width: 100%;">
style="height: 350px;">
<canvas id="HistoricalRatesChart"></canvas> <canvas id="HistoricalRatesChart"></canvas>
<script th:inline="javascript"> <script th:inline="javascript">
/*<![CDATA[*/ (function() {
new Chart(document.getElementById('HistoricalRatesChart'), { const ctx = document.getElementById('HistoricalRatesChart');
type: 'line', // Destroy existing chart instance to prevent memory leaks and scaling issues
data: { const existingChart = Chart.getChart(ctx);
labels: /*[[${labels}]]*/[], if (existingChart) {
datasets: [{ existingChart.destroy();
label: 'Rates', }
data: /*[[${historicalRates}]]*/[],
borderColor: '#2E7D32', new Chart(ctx, {
backgroundColor: 'rgba(46,125,50,0.15)', type: 'line',
borderWidth: 2, data: {
fill: true, labels: /*[[${labels}]]*/ [],
tension: 0 datasets: [{
}] label: 'Rates',
}, data: /*[[${historicalRates}]]*/ [],
options: { borderColor: '#2E7D32',
responsive: true, backgroundColor: 'rgba(46,125,50,0.15)',
maintainAspectRatio: false, borderWidth: 2,
scales: { fill: true,
y: { tension: 0.1
beginAtZero: true, }]
ticks: { },
callback: v => `$${v.toLocaleString()}` options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 500 },
scales: {
y: {
beginAtZero: false,
ticks: {
callback: v => v.toLocaleString()
}
} }
} }
} }
} });
}); })();
/*]]>*/
</script> </script>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Footer -->
<div th:replace="~{fragments/footer :: footer}"></div> <div th:replace="~{fragments/footer :: footer}"></div>
</div> </div>
</div> </div>
<a class="scroll-to-top rounded" href="#page-top">
<span class="material-icons">keyboard_arrow_up</span>
</a>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
<script src="assets/bootstrap/js/bootstrap.min.js"></script> <script src="assets/bootstrap/js/bootstrap.min.js"></script>
<script src="assets/js/chart.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="assets/js/bs-init.js"></script>
<script src="assets/js/theme.js"></script>
<script th:inline="javascript">
/*<![CDATA[*/
new Chart(document.getElementById('HistoricalRatesChart'), {
type: 'line',
data: {
labels: /*[[${labels}]]*/[],
datasets: [
{
label: 'Rates',
data: /*[[${historicalRates}]]*/[],
borderColor: '#2E7D32',
backgroundColor: 'rgba(46,125,50,0.15)',
borderWidth: 2,
fill: true,
tension: 0
},
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
callback: v => `$${v.toLocaleString()}`
}
}
}
}
});
/*]]>*/
</script>
</body> </body>
</html> </html>

View File

@ -77,142 +77,103 @@
<body id="page-top"> <body id="page-top">
<div id="wrapper"> <div id="wrapper">
<!--Sidebar - Navigation-->
<div th:replace="~{fragments/sidebar-nav :: sidebar-nav}"></div> <div th:replace="~{fragments/sidebar-nav :: sidebar-nav}"></div>
<div class="d-flex flex-column" id="content-wrapper"> <div class="d-flex flex-column" id="content-wrapper">
<div id="content"> <div id="content">
<!-- Topbar -->
<div th:replace="~{fragments/topbar :: topbar}"></div> <div th:replace="~{fragments/topbar :: topbar}"></div>
<div class="container-fluid"> <div class="container-fluid">
<div class="card shadow"> <div class="card shadow">
<div class="d-inline-flex align-items-center card-header py-3"> <div class="row align-items-center card-header py-3">
<p class="text-primary m-0 fw-bold w-100">Transactions</p> <div class="col-md-4">
<a th:href="@{/addTransaction}" class="d-inline-flex btn btn-primary"> <p class="text-primary m-0 fw-bold">Transactions</p>
<i class="bi bi-plus"></i>Add </div>
</a> <div class="col-md-4 text-md-center">
<input hx-get="/searchTransactions" hx-trigger="input changed delay:500ms"
hx-target="#table-container" hx-include="#itemSize" name="searchValue"
id="searchValue" type="search" class="form-control form-control-sm"
placeholder="Search...">
</div>
<div class="col-md-4 text-md-end">
<a th:href="@{/addTransaction}" class="btn btn-primary btn-sm">Add Transaction</a>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="mb-3">
<div class="col-md-6 text-nowrap"> <label class="form-label">Show
<div id="dataTable_length" class="dataTables_length" aria-controls="dataTable"> <select id="itemSize" name="pageSize" hx-get="/transactions-fragment"
<label class="form-label">Show&nbsp; hx-trigger="change" hx-target="#table-container" hx-include="#searchValue"
<select th:hx-get="@{/changePageSize}" hx-target="#dataTable" class="d-inline-block form-select form-select-sm" style="width: auto;">
hx-trigger="input changed" hx-swap="outerHtml" name="pageSizeCB" <option value="10">10</option>
class="d-inline-block form-select form-select-sm"> <option value="25">25</option>
<option value="10" selected="">10</option> <option value="50">50</option>
<option value="25">25</option> </select>
<option value="50">50</option> </label>
<option value="100">100</option>
</select>&nbsp;
</label>
</div>
</div>
<div class="col-md-6">
<div class="text-md-end dataTables_filter" id="dataTable_filter">
<label class="form-label">
<input hx-trigger="input changed delay:1s" hx-swap="outerHtml"
hx-target="#dataTable" hx-get="/searchTransactions" type="search"
class="form-control form-control-sm" aria-controls="dataTable"
name="searchValue" placeholder="Search">
</label>
</div>
</div>
</div>
<div class="table-responsive" style="overflow-x: auto;">
<table th:fragment="transactionTable" class="table my-0" id="dataTable">
<!-- Fixed column widths -->
<colgroup>
<col style="width: 130px;"> <!-- Money In -->
<col style="width: 130px;"> <!-- Money Out -->
<col style="width: 110px;"> <!-- Fees -->
<col style="width: 140px;"> <!-- Balance -->
<col style="width: 120px;"> <!-- Date -->
<col style="width: 160px;"> <!-- Category -->
<col style="width: 260px;"> <!-- Description -->
</colgroup>
<thead>
<tr>
<th class="numeric">Money In</th>
<th class="numeric">Money Out</th>
<th class="numeric">Fees</th>
<th class="numeric">Balance</th>
<th>Date</th>
<th>Category</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr th:each="transaction : ${transactions}"
th:with="rowClass=${(transaction.moneyIn + transaction.moneyOut + transaction.fee) < 0 ? 'txn-negative' : 'txn-positive'}"
th:class="${rowClass}">
<td class="numeric"
th:text="${#numbers.formatCurrency(transaction.moneyIn)}"></td>
<td class="numeric"
th:text="${#numbers.formatCurrency(transaction.moneyOut)}"></td>
<td class="numeric" th:text="${#numbers.formatCurrency(transaction.fee)}">
</td>
<td class="numeric"
th:text="${#numbers.formatCurrency(transaction.balance)}"></td>
<td th:text="${transaction.date}"></td>
<td th:text="${transaction.category}"></td>
<td class="description" th:text="${transaction.description}"></td>
</tr>
</tbody>
</table>
</div> </div>
<div th:fragment="tableInfo" class="row"> <div id="table-container" th:fragment="transactionList">
<div class="col-md-6 align-self-center"> <div class="table-responsive" style="overflow-x: auto;">
<p id="dataTable_info" class="dataTables_info" role="status" aria-live="polite" <table class="table my-0" id="dataTable">
th:text="'Showing ' + ${pageNum + 1} + ' to ' + ${pageSize} + ' of ' + ${transactionCount}"> <thead>
Showing 1 to 10 of 27</p> <tr>
</div> <th class="numeric">Money In</th>
<div class="col-md-6"> <th class="numeric">Money Out</th>
<nav <th class="numeric">Balance</th>
class="d-lg-flex justify-content-lg-end dataTables_paginate paging_simple_numbers"> <th>Date</th>
<ul class="pagination"> <th>Category</th>
<li th:classappend="${(pageNum + 1 == 1) ? 'disabled' : ''}" <th>Description</th>
class="page-item"> </tr>
<a th:hx-get="@{/previousPage}" hx-trigger="click" </thead>
hx-target="#dataTable" hx-swap="outerHtml" class="page-link btn" <tbody>
aria-label="Previous">
<span aria-hidden="true">« Previous</span> <tr th:each="transaction : ${transactions}"
</a> th:with="rowClass=${(transaction.moneyIn + transaction.moneyOut + transaction.fee) < 0 ? 'txn-negative' : 'txn-positive'}"
</li> th:class="${rowClass}">
<li th:classappend="${(pageNum == pageSize - 1) ? 'disabled' : ''}" <td class="numeric"
class="page-item"> th:text="${#numbers.formatCurrency(transaction.moneyIn)}"></td>
<a th:hx-get="@{/nextPage}" hx-trigger="click" hx-target="#dataTable" <td class="numeric"
hx-swap="outerHtml" class="page-link btn" aria-label="Next"> th:text="${#numbers.formatCurrency(transaction.moneyOut)}"></td>
<span aria-hidden="true">Next »</span></a> <td class="numeric"
</li> th:text="${#numbers.formatCurrency(transaction.fee)}">
</ul> </td>
</nav> <td class="numeric"
th:text="${#numbers.formatCurrency(transaction.balance)}"></td>
<td th:text="${transaction.date}"></td>
<td th:text="${transaction.category}"></td>
<td class="description" th:text="${transaction.description}"></td>
</tr>
</tbody>
</table>
</div> </div>
<nav class="mt-3">
<ul class="pagination">
<li class="page-item" th:classappend="${pageNum == 0} ? 'disabled'">
<button class="page-link" hx-get="/transactions-fragment"
th:hx-vals="${'{&quot;pageNum&quot;:' + (pageNum - 1) + '}'}"
hx-include="#searchValue, #itemSize" hx-target="#table-container">«
Previous</button>
</li>
<li class="page-item disabled">
<span class="page-link">Page [[${pageNum + 1}]] of [[${totalPages}]]</span>
</li>
<li class="page-item"
th:classappend="${pageNum + 1 >= totalPages} ? 'disabled'">
<button class="page-link" hx-get="/transactions-fragment"
th:hx-vals="${'{&quot;pageNum&quot;:' + (pageNum + 1) + '}'}"
hx-include="#searchValue, #itemSize" hx-target="#table-container">Next
»</button>
</li>
</ul>
</nav>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!--Footer-->
<div th:replace="~{fragments/footer :: footer}"></div>
</div> </div>
<a class="border rounded d-inline scroll-to-top" href="#page-top">
<span class="material-icons">keyboard_arrow_up</span>
</a>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>