Add project files.
This commit is contained in:
1 parent
fd9f318d6a
commit
0ce077ec11
98 files changed
+65707
No files matched your search
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["BudgetApp.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<ReconnectModal />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="@Assets["js/chart-interop.js"]"></script>
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="auth-layout">
|
||||
<div class="auth-container">
|
||||
@Body
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
.auth-layout {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #052767 0%, #3a0647 100%);
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<span class="text-muted me-3">@context.User.Identity?.Name</span>
|
||||
<a href="/logout">Logout</a>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
|
||||
<article class="content px-4">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
@@ -0,0 +1,109 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
article {
|
||||
padding-left: 1rem !important;
|
||||
padding-right: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
|
||||
article {
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">💰 Budgeteer</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<nav class="nav flex-column">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dashboard
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="accounts">
|
||||
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Accounts
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="transactions">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Transactions
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="categories">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Categories
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="settings">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Settings
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3 mt-auto">
|
||||
<NavLink class="nav-link" href="logout">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Logout
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
|
||||
|
||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||
<div class="components-reconnect-container">
|
||||
<div class="components-rejoining-animation" aria-hidden="true">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<p class="components-reconnect-first-attempt-visible">
|
||||
Rejoining the server...
|
||||
</p>
|
||||
<p class="components-reconnect-repeated-attempt-visible">
|
||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||
</p>
|
||||
<p class="components-reconnect-failed-visible">
|
||||
Failed to rejoin.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||
Retry
|
||||
</button>
|
||||
<p class="components-pause-visible">
|
||||
The session has been paused by the server.
|
||||
</p>
|
||||
<p class="components-resume-failed-visible">
|
||||
Failed to resume the session.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,157 @@
|
||||
.components-reconnect-first-attempt-visible,
|
||||
.components-reconnect-repeated-attempt-visible,
|
||||
.components-reconnect-failed-visible,
|
||||
.components-pause-visible,
|
||||
.components-resume-failed-visible,
|
||||
.components-rejoining-animation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
||||
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-failed,
|
||||
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
#components-reconnect-modal {
|
||||
background-color: white;
|
||||
width: 20rem;
|
||||
margin: 20vh auto;
|
||||
padding: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
||||
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
||||
&[open]
|
||||
|
||||
{
|
||||
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#components-reconnect-modal::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-slideUp {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeInOpacity {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeOutOpacity {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.components-reconnect-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#components-reconnect-modal p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button {
|
||||
border: 0;
|
||||
background-color: #6b9ed2;
|
||||
color: white;
|
||||
padding: 4px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:hover {
|
||||
background-color: #3b6ea2;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:active {
|
||||
background-color: #6b9ed2;
|
||||
}
|
||||
|
||||
.components-rejoining-animation {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div {
|
||||
position: absolute;
|
||||
border: 3px solid #0087ff;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div:nth-child(2) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@keyframes components-rejoining-animation {
|
||||
0% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
4.9% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
5% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Set up event handlers
|
||||
const reconnectModal = document.getElementById("components-reconnect-modal");
|
||||
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
||||
|
||||
const retryButton = document.getElementById("components-reconnect-button");
|
||||
retryButton.addEventListener("click", retry);
|
||||
|
||||
const resumeButton = document.getElementById("components-resume-button");
|
||||
resumeButton.addEventListener("click", resume);
|
||||
|
||||
function handleReconnectStateChanged(event) {
|
||||
if (event.detail.state === "show") {
|
||||
reconnectModal.showModal();
|
||||
} else if (event.detail.state === "hide") {
|
||||
reconnectModal.close();
|
||||
} else if (event.detail.state === "failed") {
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
} else if (event.detail.state === "rejected") {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
|
||||
try {
|
||||
// Reconnect will asynchronously return:
|
||||
// - true to mean success
|
||||
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
||||
// - exception to mean we didn't reach the server (this can be sync or async)
|
||||
const successful = await Blazor.reconnect();
|
||||
if (!successful) {
|
||||
// We have been able to reach the server, but the circuit is no longer available.
|
||||
// We'll reload the page so the user can continue using the app as quickly as possible.
|
||||
const resumeSuccessful = await Blazor.resumeCircuit();
|
||||
if (!resumeSuccessful) {
|
||||
location.reload();
|
||||
} else {
|
||||
reconnectModal.close();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// We got an exception, server is currently unavailable
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
}
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
try {
|
||||
const successful = await Blazor.resumeCircuit();
|
||||
if (!successful) {
|
||||
location.reload();
|
||||
}
|
||||
} catch {
|
||||
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function retryWhenDocumentBecomesVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
await retry();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
@page "/accounts"
|
||||
@attribute [Authorize]
|
||||
@inject AccountService AccountService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Accounts</PageTitle>
|
||||
|
||||
<h3 class="mb-4">Accounts</h3>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Add Account</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<EditForm Model="formModel" OnValidSubmit="AddAccount" FormName="addAccount">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Account Name</label>
|
||||
<InputText @bind-Value="formModel.Name" class="form-control" placeholder="e.g. Current Account" />
|
||||
<ValidationMessage For="() => formModel.Name" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Current Balance</label>
|
||||
<InputNumber @bind-Value="formModel.CurrentBalance" class="form-control numeric-input" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Currency</label>
|
||||
<InputSelect @bind-Value="formModel.CurrencyCode" class="form-select">
|
||||
@foreach (var kvp in Account.CurrencySymbols)
|
||||
{
|
||||
<option value="@kvp.Key">@kvp.Key (@kvp.Value)</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Colour</label>
|
||||
<InputText @bind-Value="formModel.Color" type="color" class="form-control form-control-color" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary w-100">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (accounts is null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (accounts.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No accounts yet. Add one above to get started.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
@foreach (var account in accounts)
|
||||
{
|
||||
<div class="col-md-4 col-sm-6 mb-3">
|
||||
<div class="card h-100" style="border-left: 4px solid @account.Color;">
|
||||
<div class="card-body">
|
||||
@if (editingAccountId == account.Id)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm mb-1">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="editName" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm mb-1">Currency</label>
|
||||
<select class="form-select form-select-sm" @bind="editCurrencyCode">
|
||||
@foreach (var kvp in Account.CurrencySymbols)
|
||||
{
|
||||
<option value="@kvp.Key">@kvp.Key (@kvp.Value)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm mb-1">Colour</label>
|
||||
<input type="color" class="form-control form-control-color form-control-sm" @bind="editColor" />
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-success btn-sm" @onclick="() => SaveAccountDetails(account.Id)">Save</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="CancelAccountEdit">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">@account.Name</h5>
|
||||
<small class="text-muted">@account.CurrencyCode</small>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="() => StartAccountEdit(account)">Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDeleteAccount(account)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (editingBalanceAccountId == account.Id)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">@account.GetSymbol()</span>
|
||||
<input type="number" class="form-control numeric-input" step="0.01"
|
||||
@bind="editingBalanceValue" />
|
||||
</div>
|
||||
<div class="d-flex gap-1 mt-2">
|
||||
<button class="btn btn-success btn-sm flex-fill" @onclick="() => SaveBalance(account.Id)">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm flex-fill" @onclick="CancelBalanceEdit">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h4 class="@(account.CurrentBalance >= 0 ? "text-success" : "text-danger") mb-1"
|
||||
style="cursor: pointer;" title="Click to edit balance"
|
||||
@onclick="() => StartBalanceEdit(account)">
|
||||
@account.FormatAmount(account.CurrentBalance)
|
||||
</h4>
|
||||
<small class="text-muted">Click balance to adjust</small>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showDeleteModal && pendingDeleteAccount is not null)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Account</h5>
|
||||
<button type="button" class="btn-close" @onclick="CancelDeleteAccount"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong>@pendingDeleteAccount.Name</strong>?</p>
|
||||
<p class="text-danger mb-0">This will permanently remove the account and all its transactions.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="CancelDeleteAccount">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="DeleteAccountConfirmed">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Account>? accounts;
|
||||
private AccountForm formModel = new();
|
||||
private string? userId;
|
||||
|
||||
// Balance editing state
|
||||
private int? editingBalanceAccountId;
|
||||
private decimal editingBalanceValue;
|
||||
|
||||
// Account details editing state
|
||||
private int? editingAccountId;
|
||||
private string editName = string.Empty;
|
||||
private string editColor = "#1b6ec2";
|
||||
private string editCurrencyCode = "GBP";
|
||||
|
||||
// Delete confirmation state
|
||||
private bool showDeleteModal;
|
||||
private Account? pendingDeleteAccount;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await LoadAccounts();
|
||||
}
|
||||
|
||||
private async Task LoadAccounts()
|
||||
{
|
||||
if (userId is not null)
|
||||
{
|
||||
accounts = await AccountService.GetAccountsAsync(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddAccount()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
var account = new Account
|
||||
{
|
||||
Name = formModel.Name,
|
||||
CurrentBalance = formModel.CurrentBalance,
|
||||
Color = formModel.Color,
|
||||
CurrencyCode = formModel.CurrencyCode,
|
||||
UserId = userId
|
||||
};
|
||||
await AccountService.CreateAccountAsync(account);
|
||||
formModel = new AccountForm();
|
||||
await LoadAccounts();
|
||||
}
|
||||
|
||||
private void ConfirmDeleteAccount(Account account)
|
||||
{
|
||||
pendingDeleteAccount = account;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
private async Task DeleteAccountConfirmed()
|
||||
{
|
||||
if (userId is null || pendingDeleteAccount is null) return;
|
||||
|
||||
showDeleteModal = false;
|
||||
await AccountService.DeleteAccountAsync(pendingDeleteAccount.Id, userId);
|
||||
pendingDeleteAccount = null;
|
||||
await LoadAccounts();
|
||||
}
|
||||
|
||||
private void CancelDeleteAccount()
|
||||
{
|
||||
showDeleteModal = false;
|
||||
pendingDeleteAccount = null;
|
||||
}
|
||||
|
||||
private void StartBalanceEdit(Account account)
|
||||
{
|
||||
editingBalanceAccountId = account.Id;
|
||||
editingBalanceValue = account.CurrentBalance;
|
||||
}
|
||||
|
||||
private void CancelBalanceEdit()
|
||||
{
|
||||
editingBalanceAccountId = null;
|
||||
}
|
||||
|
||||
private async Task SaveBalance(int accountId)
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
await AccountService.UpdateBalanceAsync(accountId, userId, editingBalanceValue);
|
||||
editingBalanceAccountId = null;
|
||||
await LoadAccounts();
|
||||
}
|
||||
|
||||
private void StartAccountEdit(Account account)
|
||||
{
|
||||
editingAccountId = account.Id;
|
||||
editName = account.Name;
|
||||
editColor = account.Color;
|
||||
editCurrencyCode = account.CurrencyCode;
|
||||
}
|
||||
|
||||
private void CancelAccountEdit()
|
||||
{
|
||||
editingAccountId = null;
|
||||
}
|
||||
|
||||
private async Task SaveAccountDetails(int accountId)
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
await AccountService.UpdateAccountDetailsAsync(accountId, userId, editName, editColor, editCurrencyCode);
|
||||
editingAccountId = null;
|
||||
await LoadAccounts();
|
||||
}
|
||||
|
||||
private sealed class AccountForm
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Account name is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public decimal CurrentBalance { get; set; }
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(7)]
|
||||
public string Color { get; set; } = "#1b6ec2";
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(3)]
|
||||
public string CurrencyCode { get; set; } = "GBP";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
@page "/categories"
|
||||
@attribute [Authorize]
|
||||
@inject CategoryService CategoryService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Categories</PageTitle>
|
||||
|
||||
<h3 class="mb-4">Categories</h3>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Add Category</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<EditForm Model="formModel" OnValidSubmit="AddCategory" FormName="addCategory">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Category Name</label>
|
||||
<InputText @bind-Value="formModel.Name" class="form-control" placeholder="e.g. Groceries, Rent, Salary" />
|
||||
<ValidationMessage For="() => formModel.Name" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Icon</label>
|
||||
<button type="button" class="btn btn-outline-secondary w-100" style="font-size: 1.4rem; height: 38px; padding: 0;"
|
||||
@onclick="OpenAddIconPicker">@(string.IsNullOrEmpty(formModel.Icon) ? "📁" : formModel.Icon)</button>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="submit" class="btn btn-primary w-100">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (categories is null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (categories.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No categories yet. Add one above to organise your transactions.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 g-3">
|
||||
@foreach (var category in categories)
|
||||
{
|
||||
<div class="col">
|
||||
<div class="card h-100 text-center">
|
||||
@if (editingCategoryId == category.Id)
|
||||
{
|
||||
<div class="card-body">
|
||||
<button type="button" class="btn btn-outline-secondary" style="font-size: 2rem; width: 52px; height: 52px; padding: 0;"
|
||||
@onclick="OpenEditIconPicker">@(string.IsNullOrEmpty(editIcon) ? "📁" : editIcon)</button>
|
||||
<input type="text" class="form-control form-control-sm mt-2 text-center" @bind="editCategoryName" />
|
||||
<div class="mt-2 d-flex gap-1 justify-content-center">
|
||||
<button class="btn btn-success btn-sm" @onclick="() => SaveCategoryEdit(category.Id)">Save</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="CancelCategoryEdit">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card-body">
|
||||
<div style="font-size: 2rem;">@(string.IsNullOrEmpty(category.Icon) ? "📁" : category.Icon)</div>
|
||||
<h6 class="card-title mt-2 mb-0">@category.Name</h6>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-0 pt-0">
|
||||
<div class="d-flex gap-1 justify-content-center">
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="() => StartCategoryEdit(category)">Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => DeleteCategory(category.Id)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showIconModal)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);" @onclick="CloseIconModal">
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm" @onclick:stopPropagation="true">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">Pick an Icon</h6>
|
||||
<button type="button" class="btn-close" @onclick="CloseIconModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-flex flex-wrap gap-1 justify-content-center">
|
||||
@foreach (var emoji in AvailableIcons)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary" style="font-size: 1.4rem; width: 42px; height: 42px; padding: 0;"
|
||||
@onclick="() => SelectIcon(emoji)">@emoji</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Category>? categories;
|
||||
private CategoryForm formModel = new();
|
||||
private string? userId;
|
||||
|
||||
// Edit state
|
||||
private int? editingCategoryId;
|
||||
private string editCategoryName = string.Empty;
|
||||
private string editIcon = string.Empty;
|
||||
|
||||
// Icon modal state
|
||||
private bool showIconModal;
|
||||
private string iconPickerTarget = "add"; // "add" or "edit"
|
||||
|
||||
private static readonly string[] AvailableIcons =
|
||||
[
|
||||
"🛒", "🏠", "💡", "🚗", "🍽️", "🎬", "🏥", "🛡️", "💰", "🏦",
|
||||
"📱", "🛍️", "📚", "✈️", "🎁", "🏋️", "🎮", "🎵", "🐾", "☕",
|
||||
"🍕", "👶", "💼", "🔧", "📦", "💊", "🎨", "🌿", "🏢", "📰",
|
||||
"💳", "🧾", "📋", "🔌", "🚌", "🚀", "🎂", "🧹", "📸", "💎"
|
||||
];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
private async Task LoadCategories()
|
||||
{
|
||||
if (userId is not null)
|
||||
{
|
||||
await CategoryService.EnsureDefaultCategoriesAsync(userId);
|
||||
categories = await CategoryService.GetCategoriesAsync(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddCategory()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
var category = new Category
|
||||
{
|
||||
Name = formModel.Name,
|
||||
Icon = formModel.Icon,
|
||||
UserId = userId
|
||||
};
|
||||
await CategoryService.CreateCategoryAsync(category);
|
||||
formModel = new CategoryForm();
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
private void StartCategoryEdit(Category category)
|
||||
{
|
||||
editingCategoryId = category.Id;
|
||||
editCategoryName = category.Name;
|
||||
editIcon = category.Icon;
|
||||
}
|
||||
|
||||
private void CancelCategoryEdit()
|
||||
{
|
||||
editingCategoryId = null;
|
||||
}
|
||||
|
||||
private async Task SaveCategoryEdit(int id)
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
await CategoryService.UpdateCategoryAsync(id, userId, editCategoryName, editIcon);
|
||||
editingCategoryId = null;
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
private async Task DeleteCategory(int id)
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
await CategoryService.DeleteCategoryAsync(id, userId);
|
||||
await LoadCategories();
|
||||
}
|
||||
|
||||
private void OpenAddIconPicker()
|
||||
{
|
||||
iconPickerTarget = "add";
|
||||
showIconModal = true;
|
||||
}
|
||||
|
||||
private void OpenEditIconPicker()
|
||||
{
|
||||
iconPickerTarget = "edit";
|
||||
showIconModal = true;
|
||||
}
|
||||
|
||||
private void CloseIconModal() => showIconModal = false;
|
||||
|
||||
private void SelectIcon(string emoji)
|
||||
{
|
||||
if (iconPickerTarget == "add")
|
||||
formModel.Icon = emoji;
|
||||
else
|
||||
editIcon = emoji;
|
||||
|
||||
showIconModal = false;
|
||||
}
|
||||
|
||||
private sealed class CategoryForm
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Category name is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(10)]
|
||||
public string Icon { get; set; } = "📁";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
@page "/"
|
||||
@attribute [Authorize]
|
||||
@inject DashboardService DashboardService
|
||||
@inject TransactionService TransactionService
|
||||
@inject AccountService AccountService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IJSRuntime JS
|
||||
@implements IAsyncDisposable
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Dashboard</PageTitle>
|
||||
|
||||
<h3 class="mb-4">Dashboard</h3>
|
||||
|
||||
@if (data is null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (data.Accounts.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<h5>Welcome!</h5>
|
||||
<p>Get started by <a href="/accounts">adding an account</a> and then <a href="/transactions">recording transactions</a>.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex flex-wrap gap-2 mb-3" style="overflow-x: auto;">
|
||||
@foreach (var account in data.Accounts)
|
||||
{
|
||||
<div class="border rounded px-3 py-2 balance-chip" style="border-left: 3px solid @account.Color !important; min-width: 180px;">
|
||||
<small class="text-muted">@account.Name</small>
|
||||
@if (editingBalanceAccountId == account.Id)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<span class="text-muted" style="font-size: 0.85rem;">@account.GetSymbol()</span>
|
||||
<input type="number" class="form-control form-control-sm numeric-input" step="0.01"
|
||||
style="width: 90px;" @bind="editingBalanceValue" />
|
||||
<button class="btn btn-success btn-sm px-1" @onclick="() => SaveBalance(account.Id)" title="Save">✓</button>
|
||||
<button class="btn btn-outline-secondary btn-sm px-1" @onclick="CancelBalanceEdit" title="Cancel">✕</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="cursor: pointer;" title="Click to edit balance"
|
||||
@onclick="() => StartBalanceEdit(account)">
|
||||
<span class="fw-semibold @(account.CurrentBalance >= 0 ? "text-success" : "text-danger")">
|
||||
@account.FormatAmount(account.CurrentBalance)
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
|
||||
<small class="text-muted fw-semibold">Window:</small>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn @(windowPreset == "7d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetWindow(7)">7d</button>
|
||||
<button class="btn @(windowPreset == "30d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetWindow(30)">30d</button>
|
||||
<button class="btn @(windowPreset == "90d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetWindow(90)">90d</button>
|
||||
<button class="btn @(windowPreset == "1y" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetWindow(365)">1y</button>
|
||||
</div>
|
||||
<input type="date" class="form-control form-control-sm" style="width: auto;"
|
||||
@bind="windowStart" @bind:after="OnWindowDateChanged" />
|
||||
<span class="text-muted">to</span>
|
||||
<input type="date" class="form-control form-control-sm" style="width: auto;"
|
||||
@bind="windowEnd" @bind:after="OnWindowDateChanged" />
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Balance Trend</h5>
|
||||
<div style="height: clamp(200px, 40vw, 350px); position: relative;">
|
||||
<canvas id="balanceChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Edit / Add Modal ── *@
|
||||
@if (showModal)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@(editingTransactionId is null ? "Add Transaction" : "Edit Transaction")</h5>
|
||||
<button type="button" class="btn-close" @onclick="CloseModal"></button>
|
||||
</div>
|
||||
<EditForm Model="modalForm" OnValidSubmit="SaveModal" FormName="dashboardModal">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<InputText @bind-Value="modalForm.Description" class="form-control" />
|
||||
<ValidationMessage For="() => modalForm.Description" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Amount</label>
|
||||
<InputNumber @bind-Value="modalForm.Amount" class="form-control numeric-input" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Date</label>
|
||||
<InputDate @bind-Value="modalForm.Date" class="form-control" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Type</label>
|
||||
<InputSelect @bind-Value="modalForm.Type" class="form-select">
|
||||
<option value="@TransactionType.Expense">Expense</option>
|
||||
<option value="@TransactionType.Income">Income</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Account</label>
|
||||
<InputSelect @bind-Value="modalForm.AccountId" class="form-select">
|
||||
@foreach (var a in data.Accounts)
|
||||
{
|
||||
<option value="@a.Id">@a.Name</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Category</label>
|
||||
<InputSelect @bind-Value="modalForm.CategoryId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
@foreach (var c in data.Categories)
|
||||
{
|
||||
<option value="@c.Id">@(string.IsNullOrEmpty(c.Icon) ? "" : c.Icon + " ")@c.Name</option>
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Recurrence</label>
|
||||
<InputSelect @bind-Value="modalForm.RecurrenceType" class="form-select">
|
||||
<option value="@RecurrenceType.None">None</option>
|
||||
<option value="@RecurrenceType.Daily">Daily</option>
|
||||
<option value="@RecurrenceType.Weekly">Weekly</option>
|
||||
<option value="@RecurrenceType.Monthly">Monthly</option>
|
||||
<option value="@RecurrenceType.Yearly">Yearly</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
@if (modalForm.RecurrenceType != RecurrenceType.None)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Recurrence End Date <small class="text-muted">(max 1 year, defaults to 1 year if blank)</small></label>
|
||||
<InputDate @bind-Value="modalForm.RecurrenceEndDate" class="form-control" max="@modalForm.Date.AddYears(1).ToString("yyyy-MM-dd")" />
|
||||
<ValidationMessage For="() => modalForm.RecurrenceEndDate" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="CloseModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<ul class="nav nav-tabs card-header-tabs">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "recent" ? "active" : "")" @onclick='() => activeTab = "recent"'>
|
||||
Recent <span class="badge bg-secondary ms-1">@data.RecentTransactions.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "upcoming" ? "active" : "")" @onclick='() => activeTab = "upcoming"'>
|
||||
Upcoming <span class="badge bg-secondary ms-1">@data.UpcomingTransactions.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="btn btn-sm btn-primary" @onclick="OpenAddModal">+ Add</button>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
|
||||
@if (activeTab == "recent")
|
||||
{
|
||||
@if (data.RecentTransactions.Count == 0)
|
||||
{
|
||||
<p class="p-3 text-muted mb-0">No recent transactions.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-group list-group-flush">
|
||||
@foreach (var t in data.RecentTransactions.Skip((recentPage - 1) * PageSize).Take(PageSize))
|
||||
{
|
||||
<div class="list-group-item list-group-item-action txn-row @(selectedTransactionId == t.Id ? "txn-row-selected" : "")"
|
||||
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
|
||||
@onclick="() => SelectTransaction(t.Id)">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="me-3" style="min-width: 0;">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="fw-semibold text-truncate">@t.Description</span>
|
||||
@if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null)
|
||||
{
|
||||
<span class="badge bg-info bg-opacity-10 text-info" style="font-size: 0.7rem;">🔁 @GetRecurrenceLabel(t)</span>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 mt-1 flex-wrap" style="font-size: 0.8rem;">
|
||||
<span class="text-muted">@t.Date.ToString("MMM dd, yyyy")</span>
|
||||
<span class="badge" style="background-color: @t.Account.Color; font-size: 0.7rem;">@t.Account.Name</span>
|
||||
@if (t.Category is not null)
|
||||
{
|
||||
<span class="text-muted">@(string.IsNullOrEmpty(t.Category.Icon) ? "" : t.Category.Icon + " ")@t.Category.Name</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end text-nowrap" style="flex-shrink: 0;">
|
||||
<div class="@(t.Type == TransactionType.Income ? "text-success" : "text-danger") fw-semibold" style="font-size: 1.05rem;">
|
||||
@(t.Type == TransactionType.Income ? "+" : "−")@t.Account.FormatAmount(t.Amount)
|
||||
</div>
|
||||
<small class="text-muted" style="font-size: 0.7rem;">@(t.Type == TransactionType.Income ? "Income" : "Expense")</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (selectedTransactionId == t.Id)
|
||||
{
|
||||
<div class="mt-2 pt-2 border-top d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (RecentTotalPages > 1)
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(recentPage <= 1 ? true : (bool?)null)" @onclick="() => recentPage--">← Prev</button>
|
||||
<small class="text-muted">Page @recentPage of @RecentTotalPages</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(recentPage >= RecentTotalPages ? true : (bool?)null)" @onclick="() => recentPage++">Next →</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (data.UpcomingTransactions.Count == 0)
|
||||
{
|
||||
<p class="p-3 text-muted mb-0">No upcoming transactions.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-group list-group-flush">
|
||||
@foreach (var t in data.UpcomingTransactions.Skip((upcomingPage - 1) * PageSize).Take(PageSize))
|
||||
{
|
||||
<div class="list-group-item list-group-item-action txn-row @(selectedTransactionId == t.Id ? "txn-row-selected" : "")"
|
||||
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
|
||||
@onclick="() => SelectTransaction(t.Id)">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="me-3" style="min-width: 0;">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="fw-semibold text-truncate">@t.Description</span>
|
||||
@if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null)
|
||||
{
|
||||
<span class="badge bg-info bg-opacity-10 text-info" style="font-size: 0.7rem;">🔁 @GetRecurrenceLabel(t)</span>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 mt-1 flex-wrap" style="font-size: 0.8rem;">
|
||||
<span class="text-muted">@t.Date.ToString("MMM dd, yyyy")</span>
|
||||
<span class="badge" style="background-color: @t.Account.Color; font-size: 0.7rem;">@t.Account.Name</span>
|
||||
@if (t.Category is not null)
|
||||
{
|
||||
<span class="text-muted">@(string.IsNullOrEmpty(t.Category.Icon) ? "" : t.Category.Icon + " ")@t.Category.Name</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end text-nowrap" style="flex-shrink: 0;">
|
||||
<div class="@(t.Type == TransactionType.Income ? "text-success" : "text-danger") fw-semibold" style="font-size: 1.05rem;">
|
||||
@(t.Type == TransactionType.Income ? "+" : "−")@t.Account.FormatAmount(t.Amount)
|
||||
</div>
|
||||
@if (projectedBalances.TryGetValue(t.Id, out var projected))
|
||||
{
|
||||
<small class="@(projected >= 0 ? "text-success" : "text-danger")" style="font-size: 0.7rem;">
|
||||
Projected: @t.Account.FormatAmount(projected)
|
||||
</small>
|
||||
}
|
||||
else
|
||||
{
|
||||
<small class="text-muted" style="font-size: 0.7rem;">@(t.Type == TransactionType.Income ? "Income" : "Expense")</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (selectedTransactionId == t.Id)
|
||||
{
|
||||
<div class="mt-2 pt-2 border-top d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OpenEditModal(t)" @onclick:stopPropagation="true">✏️ Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)" @onclick:stopPropagation="true">🗑️ Delete</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (UpcomingTotalPages > 1)
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(upcomingPage <= 1 ? true : (bool?)null)" @onclick="() => upcomingPage--">← Prev</button>
|
||||
<small class="text-muted">Page @upcomingPage of @UpcomingTotalPages</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(upcomingPage >= UpcomingTotalPages ? true : (bool?)null)" @onclick="() => upcomingPage++">Next →</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showDeleteModal && pendingDeleteTransaction is not null)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Recurring Transaction</h5>
|
||||
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
<strong>@pendingDeleteTransaction.Description</strong> is part of a recurring series.
|
||||
</p>
|
||||
<p>Would you like to delete just this transaction, or all future transactions in the series?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
||||
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private DashboardData? data;
|
||||
private string? userId;
|
||||
private int? selectedTransactionId;
|
||||
private string activeTab = "recent";
|
||||
private Dictionary<int, decimal> projectedBalances = new();
|
||||
|
||||
// Modal state
|
||||
private bool showModal;
|
||||
private int? editingTransactionId;
|
||||
private ModalTransactionForm modalForm = new();
|
||||
|
||||
// Chart redraw tracking
|
||||
private int chartDataVersion;
|
||||
private int lastRenderedChartVersion;
|
||||
|
||||
// Time window
|
||||
private DateTime windowStart = DateTime.Today.AddDays(-30);
|
||||
private DateTime windowEnd = DateTime.Today.AddDays(30);
|
||||
private string windowPreset = "30d";
|
||||
|
||||
// Pagination
|
||||
private int recentPage = 1;
|
||||
private int upcomingPage = 1;
|
||||
private const int PageSize = 10;
|
||||
private int RecentTotalPages => data is null ? 0 : (int)Math.Ceiling(data.RecentTransactions.Count / (double)PageSize);
|
||||
private int UpcomingTotalPages => data is null ? 0 : (int)Math.Ceiling(data.UpcomingTransactions.Count / (double)PageSize);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
if (userId is not null)
|
||||
{
|
||||
await RefreshData();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (data?.ChartData.Datasets.Count > 0 && chartDataVersion != lastRenderedChartVersion)
|
||||
{
|
||||
lastRenderedChartVersion = chartDataVersion;
|
||||
|
||||
var datasets = data.ChartData.Datasets.Select(ds => new
|
||||
{
|
||||
label = ds.Label,
|
||||
color = ds.Color,
|
||||
data = ds.Data.Select(d => (double)d).ToArray(),
|
||||
currencySymbol = ds.CurrencySymbol
|
||||
}).ToArray();
|
||||
|
||||
var dayLabels = data.ChartData.DayTransactionLabels
|
||||
.Select(d => d.ToArray()).ToArray();
|
||||
|
||||
int highlightIndex = GetHighlightIndex();
|
||||
|
||||
await JS.InvokeVoidAsync("chartInterop.createOrUpdateChart",
|
||||
"balanceChart",
|
||||
data.ChartData.Labels.ToArray(),
|
||||
datasets,
|
||||
dayLabels,
|
||||
highlightIndex,
|
||||
data.CurrencySymbol);
|
||||
}
|
||||
}
|
||||
|
||||
private int GetHighlightIndex()
|
||||
{
|
||||
if (selectedTransactionId is null || data is null) return -1;
|
||||
|
||||
var txn = data.RecentTransactions.FirstOrDefault(t => t.Id == selectedTransactionId)
|
||||
?? data.UpcomingTransactions.FirstOrDefault(t => t.Id == selectedTransactionId);
|
||||
if (txn is null) return -1;
|
||||
|
||||
var label = txn.Date.ToString("MMM dd");
|
||||
return data.ChartData.Labels.IndexOf(label);
|
||||
}
|
||||
|
||||
private async Task SelectTransaction(int id)
|
||||
{
|
||||
selectedTransactionId = selectedTransactionId == id ? null : id;
|
||||
|
||||
if (data?.ChartData.Datasets.Count > 0)
|
||||
{
|
||||
int highlightIndex = GetHighlightIndex();
|
||||
await JS.InvokeVoidAsync("chartInterop.highlightDate", "balanceChart", highlightIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Balance editing state
|
||||
private int? editingBalanceAccountId;
|
||||
private decimal editingBalanceValue;
|
||||
|
||||
private void StartBalanceEdit(Account account)
|
||||
{
|
||||
editingBalanceAccountId = account.Id;
|
||||
editingBalanceValue = account.CurrentBalance;
|
||||
}
|
||||
|
||||
private void CancelBalanceEdit()
|
||||
{
|
||||
editingBalanceAccountId = null;
|
||||
}
|
||||
|
||||
private async Task SaveBalance(int accountId)
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
await AccountService.UpdateBalanceAsync(accountId, userId, editingBalanceValue);
|
||||
editingBalanceAccountId = null;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private void OpenAddModal()
|
||||
{
|
||||
editingTransactionId = null;
|
||||
modalForm = new ModalTransactionForm
|
||||
{
|
||||
AccountId = data?.Accounts.FirstOrDefault()?.Id ?? 0
|
||||
};
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
private void OpenEditModal(Transaction t)
|
||||
{
|
||||
editingTransactionId = t.Id;
|
||||
modalForm = new ModalTransactionForm
|
||||
{
|
||||
Description = t.Description,
|
||||
Amount = t.Amount,
|
||||
Date = t.Date,
|
||||
Type = t.Type,
|
||||
AccountId = t.AccountId,
|
||||
CategoryId = t.CategoryId,
|
||||
RecurrenceType = t.RecurrenceType,
|
||||
RecurrenceEndDate = t.RecurrenceEndDate
|
||||
};
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
private void CloseModal() => showModal = false;
|
||||
|
||||
private async Task SaveModal()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
if (editingTransactionId is null)
|
||||
{
|
||||
var transaction = new Transaction
|
||||
{
|
||||
Description = modalForm.Description,
|
||||
Amount = modalForm.Amount,
|
||||
Date = modalForm.Date,
|
||||
Type = modalForm.Type,
|
||||
AccountId = modalForm.AccountId,
|
||||
CategoryId = modalForm.CategoryId,
|
||||
RecurrenceType = modalForm.RecurrenceType,
|
||||
RecurrenceEndDate = modalForm.RecurrenceEndDate
|
||||
};
|
||||
await TransactionService.CreateTransactionAsync(transaction);
|
||||
}
|
||||
else
|
||||
{
|
||||
await TransactionService.UpdateTransactionAsync(
|
||||
editingTransactionId.Value, userId,
|
||||
modalForm.Description, modalForm.Amount, modalForm.Date,
|
||||
modalForm.Type, modalForm.AccountId, modalForm.CategoryId,
|
||||
modalForm.RecurrenceType, modalForm.RecurrenceEndDate);
|
||||
}
|
||||
|
||||
showModal = false;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
// Delete confirmation state
|
||||
private bool showDeleteModal;
|
||||
private Transaction? pendingDeleteTransaction;
|
||||
|
||||
private void ConfirmDelete(Transaction t)
|
||||
{
|
||||
pendingDeleteTransaction = t;
|
||||
if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null)
|
||||
{
|
||||
showDeleteModal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = DeleteSingle();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSingle()
|
||||
{
|
||||
if (userId is null || pendingDeleteTransaction is null) return;
|
||||
showDeleteModal = false;
|
||||
await TransactionService.DeleteTransactionAsync(pendingDeleteTransaction.Id, userId);
|
||||
pendingDeleteTransaction = null;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private async Task DeleteSeries()
|
||||
{
|
||||
if (userId is null || pendingDeleteTransaction is null) return;
|
||||
showDeleteModal = false;
|
||||
await TransactionService.DeleteTransactionSeriesAsync(pendingDeleteTransaction.Id, userId);
|
||||
pendingDeleteTransaction = null;
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
showDeleteModal = false;
|
||||
pendingDeleteTransaction = null;
|
||||
}
|
||||
|
||||
private static string GetRecurrenceLabel(Transaction t)
|
||||
{
|
||||
if (t.RecurrenceType != RecurrenceType.None)
|
||||
return t.RecurrenceType.ToString();
|
||||
return t.ParentTransaction?.RecurrenceType.ToString() ?? "Recurring";
|
||||
}
|
||||
|
||||
private void ComputeProjectedBalances()
|
||||
{
|
||||
projectedBalances.Clear();
|
||||
if (data is null) return;
|
||||
|
||||
var accountBalances = data.Accounts.ToDictionary(a => a.Id, a => a.CurrentBalance);
|
||||
|
||||
foreach (var t in data.UpcomingTransactions)
|
||||
{
|
||||
if (!accountBalances.ContainsKey(t.AccountId)) continue;
|
||||
|
||||
accountBalances[t.AccountId] += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
|
||||
projectedBalances[t.Id] = accountBalances[t.AccountId];
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
if (userId is null) return;
|
||||
data = await DashboardService.GetDashboardDataAsync(userId, windowStart, windowEnd);
|
||||
chartDataVersion++;
|
||||
ComputeProjectedBalances();
|
||||
recentPage = 1;
|
||||
upcomingPage = 1;
|
||||
}
|
||||
|
||||
private async Task SetWindow(int days)
|
||||
{
|
||||
windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => "custom" };
|
||||
windowStart = DateTime.Today.AddDays(-days);
|
||||
windowEnd = DateTime.Today.AddDays(days);
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
private async Task OnWindowDateChanged()
|
||||
{
|
||||
windowPreset = "custom";
|
||||
await RefreshData();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("chartInterop.destroyChart", "balanceChart");
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ModalTransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Range(0.01, (double)decimal.MaxValue, ErrorMessage = "Amount must be greater than zero")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public DateTime Date { get; set; } = DateTime.Today;
|
||||
|
||||
public TransactionType Type { get; set; } = TransactionType.Expense;
|
||||
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public int? CategoryId { get; set; }
|
||||
|
||||
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.None;
|
||||
|
||||
public DateTime? RecurrenceEndDate { get; set; }
|
||||
|
||||
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
||||
{
|
||||
if (RecurrenceType != RecurrenceType.None && RecurrenceEndDate.HasValue)
|
||||
{
|
||||
var maxEnd = Date.AddYears(1);
|
||||
if (RecurrenceEndDate.Value > maxEnd)
|
||||
{
|
||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||
"Recurrence end date cannot be more than 1 year from the transaction date.",
|
||||
[nameof(RecurrenceEndDate)]);
|
||||
}
|
||||
if (RecurrenceEndDate.Value <= Date)
|
||||
{
|
||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||
"Recurrence end date must be after the transaction date.",
|
||||
[nameof(RecurrenceEndDate)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@page "/login"
|
||||
@layout AuthLayout
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Budgeteer — Sign In</PageTitle>
|
||||
|
||||
<h3 class="text-center mb-4">Sign In</h3>
|
||||
|
||||
<EditForm Model="Model" OnValidSubmit="HandleLogin" FormName="login">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<InputText @bind-Value="Model.Email" class="form-control" placeholder="you@example.com" />
|
||||
<ValidationMessage For="() => Model.Email" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<InputText @bind-Value="Model.Password" type="password" class="form-control" />
|
||||
<ValidationMessage For="() => Model.Password" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
||||
</EditForm>
|
||||
|
||||
<p class="text-center mt-3">
|
||||
Don't have an account? <a href="/register">Register</a>
|
||||
</p>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromForm]
|
||||
private LoginModel Model { get; set; } = new();
|
||||
|
||||
private string? errorMessage;
|
||||
|
||||
private async Task HandleLogin()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
var result = await SignInManager.PasswordSignInAsync(
|
||||
Model.Email, Model.Password, isPersistent: false, lockoutOnFailure: false);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = "Invalid email or password.";
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LoginModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
||||
[System.ComponentModel.DataAnnotations.EmailAddress]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@page "/logout"
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await SignInManager.SignOutAsync();
|
||||
Navigation.NavigateTo("/login", forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
@@ -0,0 +1,83 @@
|
||||
@page "/register"
|
||||
@layout AuthLayout
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Budgeteer — Register</PageTitle>
|
||||
|
||||
<h3 class="text-center mb-4">Create Account</h3>
|
||||
|
||||
<EditForm Model="Model" OnValidSubmit="HandleRegister" FormName="register">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<InputText @bind-Value="Model.Email" class="form-control" placeholder="you@example.com" />
|
||||
<ValidationMessage For="() => Model.Email" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<InputText @bind-Value="Model.Password" type="password" class="form-control" placeholder="At least 8 characters" />
|
||||
<ValidationMessage For="() => Model.Password" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm Password</label>
|
||||
<InputText @bind-Value="Model.ConfirmPassword" type="password" class="form-control" />
|
||||
<ValidationMessage For="() => Model.ConfirmPassword" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">Register</button>
|
||||
</EditForm>
|
||||
|
||||
<p class="text-center mt-3">
|
||||
Already have an account? <a href="/login">Sign in</a>
|
||||
</p>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromForm]
|
||||
private RegisterModel Model { get; set; } = new();
|
||||
|
||||
private string? errorMessage;
|
||||
|
||||
private async Task HandleRegister()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
var user = new ApplicationUser { UserName = Model.Email, Email = Model.Email };
|
||||
var result = await UserManager.CreateAsync(user, Model.Password);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SignInManager.SignInAsync(user, isPersistent: false);
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RegisterModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
||||
[System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Invalid email address")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
||||
[System.ComponentModel.DataAnnotations.MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")]
|
||||
[System.ComponentModel.DataAnnotations.Compare(nameof(Password), ErrorMessage = "Passwords do not match")]
|
||||
public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
@page "/settings"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject ApplicationDbContext DbContext
|
||||
@inject NavigationManager Navigation
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Settings</PageTitle>
|
||||
|
||||
<h3 class="mb-4">Settings</h3>
|
||||
|
||||
@* ── Change Password ── *@
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Change Password</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (passwordSuccess)
|
||||
{
|
||||
<div class="alert alert-success">Password changed successfully.</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(passwordError))
|
||||
{
|
||||
<div class="alert alert-danger">@passwordError</div>
|
||||
}
|
||||
|
||||
<EditForm Model="passwordForm" OnValidSubmit="ChangePassword" FormName="changePassword">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Current Password</label>
|
||||
<InputText @bind-Value="passwordForm.CurrentPassword" type="password" class="form-control" />
|
||||
<ValidationMessage For="() => passwordForm.CurrentPassword" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">New Password</label>
|
||||
<InputText @bind-Value="passwordForm.NewPassword" type="password" class="form-control" />
|
||||
<ValidationMessage For="() => passwordForm.NewPassword" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Confirm New Password</label>
|
||||
<InputText @bind-Value="passwordForm.ConfirmNewPassword" type="password" class="form-control" />
|
||||
<ValidationMessage For="() => passwordForm.ConfirmNewPassword" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Update Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Delete Account ── *@
|
||||
<div class="card border-danger">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0">Delete Account</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-danger fw-semibold">
|
||||
This will permanently delete your account and all associated data (accounts, transactions, and categories). This action cannot be undone.
|
||||
</p>
|
||||
|
||||
@if (!string.IsNullOrEmpty(deleteError))
|
||||
{
|
||||
<div class="alert alert-danger">@deleteError</div>
|
||||
}
|
||||
|
||||
@if (!confirmingDelete)
|
||||
{
|
||||
<button class="btn btn-outline-danger" @onclick="() => confirmingDelete = true">
|
||||
Delete My Account
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
<strong>Are you sure?</strong> Type your password to confirm.
|
||||
</div>
|
||||
<div class="input-group mb-3" style="max-width: 400px;">
|
||||
<input type="password" class="form-control" placeholder="Enter your password" @bind="deletePassword" />
|
||||
<button class="btn btn-danger" @onclick="DeleteAccount">Confirm Delete</button>
|
||||
<button class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ChangePasswordForm passwordForm = new();
|
||||
private bool passwordSuccess;
|
||||
private string? passwordError;
|
||||
|
||||
private bool confirmingDelete;
|
||||
private string deletePassword = string.Empty;
|
||||
private string? deleteError;
|
||||
|
||||
private async Task ChangePassword()
|
||||
{
|
||||
passwordSuccess = false;
|
||||
passwordError = null;
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userIdClaim is null) return;
|
||||
|
||||
var user = await UserManager.FindByIdAsync(userIdClaim);
|
||||
if (user is null) return;
|
||||
|
||||
var result = await UserManager.ChangePasswordAsync(user, passwordForm.CurrentPassword, passwordForm.NewPassword);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
passwordSuccess = true;
|
||||
passwordForm = new ChangePasswordForm();
|
||||
}
|
||||
else
|
||||
{
|
||||
passwordError = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteAccount()
|
||||
{
|
||||
deleteError = null;
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userIdClaim is null) return;
|
||||
|
||||
var user = await UserManager.FindByIdAsync(userIdClaim);
|
||||
if (user is null) return;
|
||||
|
||||
var passwordValid = await UserManager.CheckPasswordAsync(user, deletePassword);
|
||||
if (!passwordValid)
|
||||
{
|
||||
deleteError = "Incorrect password.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete all user data explicitly in correct dependency order
|
||||
await DbContext.Transactions
|
||||
.Where(t => t.Account.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await DbContext.Accounts
|
||||
.Where(a => a.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await DbContext.Categories
|
||||
.Where(c => c.UserId == userIdClaim)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// Delete identity user
|
||||
await UserManager.DeleteAsync(user);
|
||||
await SignInManager.SignOutAsync();
|
||||
|
||||
Navigation.NavigateTo("/login", forceLoad: true);
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
confirmingDelete = false;
|
||||
deletePassword = string.Empty;
|
||||
deleteError = null;
|
||||
}
|
||||
|
||||
private sealed class ChangePasswordForm
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Current password is required")]
|
||||
public string CurrentPassword { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "New password is required")]
|
||||
[System.ComponentModel.DataAnnotations.MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your new password")]
|
||||
[System.ComponentModel.DataAnnotations.Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match")]
|
||||
public string ConfirmNewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
@page "/transactions"
|
||||
@attribute [Authorize]
|
||||
@inject TransactionService TransactionService
|
||||
@inject AccountService AccountService
|
||||
@inject CategoryService CategoryService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Transactions</PageTitle>
|
||||
|
||||
<h3 class="mb-4">Transactions</h3>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Add Transaction</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (accountsList is not null && accountsList.Count == 0)
|
||||
{
|
||||
<div class="alert alert-warning mb-0">
|
||||
You need to <a href="/accounts">create an account</a> before adding transactions.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="formModel" OnValidSubmit="AddTransaction" FormName="addTransaction">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Description</label>
|
||||
<InputText @bind-Value="formModel.Description" class="form-control" placeholder="e.g. Food shopping" />
|
||||
<ValidationMessage For="() => formModel.Description" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Amount</label>
|
||||
<InputNumber @bind-Value="formModel.Amount" class="form-control numeric-input" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Date</label>
|
||||
<InputDate @bind-Value="formModel.Date" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Type</label>
|
||||
<InputSelect @bind-Value="formModel.Type" class="form-select">
|
||||
<option value="@TransactionType.Expense">Expense</option>
|
||||
<option value="@TransactionType.Income">Income</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Account</label>
|
||||
<InputSelect @bind-Value="formModel.AccountId" class="form-select">
|
||||
@if (accountsList is not null)
|
||||
{
|
||||
@foreach (var account in accountsList)
|
||||
{
|
||||
<option value="@account.Id">@account.Name</option>
|
||||
}
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Category</label>
|
||||
<InputSelect @bind-Value="formModel.CategoryId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
@if (categoriesList is not null)
|
||||
{
|
||||
@foreach (var category in categoriesList)
|
||||
{
|
||||
<option value="@category.Id">@(string.IsNullOrEmpty(category.Icon) ? "" : category.Icon + " ")@category.Name</option>
|
||||
}
|
||||
}
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Recurrence</label>
|
||||
<InputSelect @bind-Value="formModel.RecurrenceType" class="form-select">
|
||||
<option value="@RecurrenceType.None">None</option>
|
||||
<option value="@RecurrenceType.Daily">Daily</option>
|
||||
<option value="@RecurrenceType.Weekly">Weekly</option>
|
||||
<option value="@RecurrenceType.Monthly">Monthly</option>
|
||||
<option value="@RecurrenceType.Yearly">Yearly</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
@if (formModel.RecurrenceType != RecurrenceType.None)
|
||||
{
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">End Date <small class="text-muted">(max 1yr)</small></label>
|
||||
<InputDate @bind-Value="formModel.RecurrenceEndDate" class="form-control" max="@formModel.Date.AddYears(1).ToString("yyyy-MM-dd")" />
|
||||
<ValidationMessage For="() => formModel.RecurrenceEndDate" />
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (transactions is null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (transactions.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">No transactions yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<label class="form-label mb-0 text-nowrap">Account:</label>
|
||||
<select class="form-select form-select-sm" style="max-width: 200px;" @onchange="OnAccountFilterChanged">
|
||||
<option value="">All Accounts</option>
|
||||
@if (accountsList is not null)
|
||||
{
|
||||
@foreach (var a in accountsList)
|
||||
{
|
||||
<option value="@a.Id" selected="@(filterAccountId == a.Id)">@a.Name</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th role="button" class="user-select-none" @onclick='() => SetSort("date")'>Date @SortIndicator("date")</th>
|
||||
<th role="button" class="user-select-none" @onclick='() => SetSort("description")'>Description @SortIndicator("description")</th>
|
||||
<th>Account</th>
|
||||
<th>Category</th>
|
||||
<th>Recurrence</th>
|
||||
<th role="button" class="user-select-none text-end" @onclick='() => SetSort("amount")'>Amount @SortIndicator("amount")</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var t in filteredTransactions)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.Date.ToString("MMM dd, yyyy")</td>
|
||||
<td>@t.Description</td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: @t.Account.Color;">
|
||||
@t.Account.Name
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (t.Category is not null)
|
||||
{
|
||||
@if (!string.IsNullOrEmpty(t.Category.Icon))
|
||||
{
|
||||
<span class="me-1">@t.Category.Icon</span>
|
||||
}
|
||||
@t.Category.Name
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (t.RecurrenceType != RecurrenceType.None)
|
||||
{
|
||||
<span class="badge bg-info">🔁 @t.RecurrenceType</span>
|
||||
}
|
||||
else if (t.ParentTransactionId is not null)
|
||||
{
|
||||
<span class="badge bg-info">🔁 @(t.ParentTransaction?.RecurrenceType.ToString() ?? "Recurring")</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end @(t.Type == TransactionType.Income ? "text-success" : "text-danger") fw-semibold">
|
||||
@(t.Type == TransactionType.Income ? "+" : "-")@t.Account.FormatAmount(t.Amount)
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => ConfirmDelete(t)">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showDeleteModal && pendingDeleteTransaction is not null)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Recurring Transaction</h5>
|
||||
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
<strong>@pendingDeleteTransaction.Description</strong> is part of a recurring series.
|
||||
</p>
|
||||
<p>Would you like to delete just this transaction, or all future transactions in the series?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
||||
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Transaction>? transactions;
|
||||
private List<Account>? accountsList;
|
||||
private List<Category>? categoriesList;
|
||||
private TransactionForm formModel = new();
|
||||
private string? userId;
|
||||
private int? filterAccountId;
|
||||
|
||||
// Sort state
|
||||
private string sortColumn = "date";
|
||||
private bool sortAscending = true;
|
||||
|
||||
private List<Transaction> filteredTransactions
|
||||
{
|
||||
get
|
||||
{
|
||||
var list = (transactions ?? []).Where(t => t.Date.Date >= DateTime.Today);
|
||||
if (filterAccountId is not null)
|
||||
list = list.Where(t => t.AccountId == filterAccountId);
|
||||
|
||||
list = sortColumn switch
|
||||
{
|
||||
"description" => sortAscending
|
||||
? list.OrderBy(t => t.Description, StringComparer.OrdinalIgnoreCase)
|
||||
: list.OrderByDescending(t => t.Description, StringComparer.OrdinalIgnoreCase),
|
||||
"amount" => sortAscending
|
||||
? list.OrderBy(t => t.Amount)
|
||||
: list.OrderByDescending(t => t.Amount),
|
||||
_ => sortAscending
|
||||
? list.OrderBy(t => t.Date)
|
||||
: list.OrderByDescending(t => t.Date),
|
||||
};
|
||||
|
||||
return list.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAccountFilterChanged(ChangeEventArgs e)
|
||||
{
|
||||
filterAccountId = int.TryParse(e.Value?.ToString(), out var id) ? id : null;
|
||||
}
|
||||
|
||||
private void SetSort(string column)
|
||||
{
|
||||
if (sortColumn == column)
|
||||
sortAscending = !sortAscending;
|
||||
else
|
||||
{
|
||||
sortColumn = column;
|
||||
sortAscending = true;
|
||||
}
|
||||
}
|
||||
|
||||
private MarkupString SortIndicator(string column)
|
||||
{
|
||||
if (sortColumn != column)
|
||||
return new MarkupString("<span class=\"text-muted opacity-50\">⇅</span>");
|
||||
return new MarkupString(sortAscending ? "▲" : "▼");
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
transactions = await TransactionService.GetTransactionsAsync(userId);
|
||||
accountsList = await AccountService.GetAccountsAsync(userId);
|
||||
categoriesList = await CategoryService.GetCategoriesAsync(userId);
|
||||
|
||||
if (accountsList.Count > 0 && formModel.AccountId == 0)
|
||||
{
|
||||
formModel.AccountId = accountsList[0].Id;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddTransaction()
|
||||
{
|
||||
if (userId is null) return;
|
||||
|
||||
var transaction = new Transaction
|
||||
{
|
||||
Description = formModel.Description,
|
||||
Amount = formModel.Amount,
|
||||
Date = formModel.Date,
|
||||
Type = formModel.Type,
|
||||
AccountId = formModel.AccountId,
|
||||
CategoryId = formModel.CategoryId,
|
||||
RecurrenceType = formModel.RecurrenceType,
|
||||
RecurrenceEndDate = formModel.RecurrenceEndDate
|
||||
};
|
||||
await TransactionService.CreateTransactionAsync(transaction);
|
||||
formModel = new TransactionForm
|
||||
{
|
||||
AccountId = accountsList?.FirstOrDefault()?.Id ?? 0
|
||||
};
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
// Delete confirmation state
|
||||
private bool showDeleteModal;
|
||||
private Transaction? pendingDeleteTransaction;
|
||||
|
||||
private void ConfirmDelete(Transaction t)
|
||||
{
|
||||
pendingDeleteTransaction = t;
|
||||
if (t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null)
|
||||
{
|
||||
showDeleteModal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = DeleteSingle();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSingle()
|
||||
{
|
||||
if (userId is null || pendingDeleteTransaction is null) return;
|
||||
showDeleteModal = false;
|
||||
await TransactionService.DeleteTransactionAsync(pendingDeleteTransaction.Id, userId);
|
||||
pendingDeleteTransaction = null;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task DeleteSeries()
|
||||
{
|
||||
if (userId is null || pendingDeleteTransaction is null) return;
|
||||
showDeleteModal = false;
|
||||
await TransactionService.DeleteTransactionSeriesAsync(pendingDeleteTransaction.Id, userId);
|
||||
pendingDeleteTransaction = null;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private void CancelDelete()
|
||||
{
|
||||
showDeleteModal = false;
|
||||
pendingDeleteTransaction = null;
|
||||
}
|
||||
|
||||
private sealed class TransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")]
|
||||
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than zero")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public DateTime Date { get; set; } = DateTime.Today;
|
||||
|
||||
public TransactionType Type { get; set; } = TransactionType.Expense;
|
||||
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public int? CategoryId { get; set; }
|
||||
|
||||
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.None;
|
||||
|
||||
public DateTime? RecurrenceEndDate { get; set; }
|
||||
|
||||
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
||||
{
|
||||
if (RecurrenceType != RecurrenceType.None && RecurrenceEndDate.HasValue)
|
||||
{
|
||||
var maxEnd = Date.AddYears(1);
|
||||
if (RecurrenceEndDate.Value > maxEnd)
|
||||
{
|
||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||
"Recurrence end date cannot be more than 1 year from the transaction date.",
|
||||
[nameof(RecurrenceEndDate)]);
|
||||
}
|
||||
if (RecurrenceEndDate.Value <= Date)
|
||||
{
|
||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||
"Recurrence end date must be after the transaction date.",
|
||||
[nameof(RecurrenceEndDate)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@page "/weather"
|
||||
@attribute [StreamRendering]
|
||||
|
||||
<PageTitle>Weather</PageTitle>
|
||||
|
||||
<h1>Weather</h1>
|
||||
|
||||
<p>This component demonstrates showing data.</p>
|
||||
|
||||
@if (forecasts == null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th aria-label="Temperature in Celsius">Temp. (C)</th>
|
||||
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
|
||||
<th>Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var forecast in forecasts)
|
||||
{
|
||||
<tr>
|
||||
<td>@forecast.Date.ToShortDateString()</td>
|
||||
<td>@forecast.TemperatureC</td>
|
||||
<td>@forecast.TemperatureF</td>
|
||||
<td>@forecast.Summary</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private WeatherForecast[]? forecasts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Simulate asynchronous loading to demonstrate streaming rendering
|
||||
await Task.Delay(500);
|
||||
|
||||
var startDate = DateOnly.FromDateTime(DateTime.Now);
|
||||
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
|
||||
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
||||
{
|
||||
Date = startDate.AddDays(index),
|
||||
TemperatureC = Random.Shared.Next(-20, 55),
|
||||
Summary = summaries[Random.Shared.Next(summaries.Length)]
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private class WeatherForecast
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public int TemperatureC { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,15 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using BudgetApp
|
||||
@using BudgetApp.Components
|
||||
@using BudgetApp.Components.Layout
|
||||
@using BudgetApp.Data
|
||||
@using BudgetApp.Services
|
||||
Reference in new issue
Block a user