feat(web): enhance transaction display and account management features

This commit is contained in:
Stevan Freeborn
2026-03-10 17:46:21 -05:00
parent 3408f6ea4f
commit 4d1c01776f
12 changed files with 616 additions and 274 deletions
@@ -19,6 +19,7 @@ internal static class Endpoint
var user = await appDbContext.Users
.Include(u => u.Institutions)
.ThenInclude(i => i.Accounts)
.ThenInclude(a => a.Metadata)
.FirstOrDefaultAsync(u => u.Id == userId);
if (user is null)
@@ -24,6 +24,7 @@ internal sealed record Response
internal sealed record AccountDto
{
public string Id { get; init; } = string.Empty;
public string ProviderId { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
[JsonConstructor]
@@ -36,6 +37,9 @@ internal sealed record AccountDto
return new()
{
Id = account.Id.ToString(),
ProviderId = account.Metadata is PlaidAccountMetadata plaidMetadata
? plaidMetadata.PlaidId
: string.Empty,
Name = account.Name,
};
}
+18
View File
@@ -24,6 +24,24 @@ input {
color: inherit;
}
select {
font-family: inherit;
font-size: inherit;
color: inherit;
background-color: inherit;
}
option {
font-family: inherit;
font-size: inherit;
color: inherit;
background-color: inherit;
}
option :hover {
background-color: var(--bg-element);
}
a {
text-decoration: none;
color: inherit;
@@ -0,0 +1,21 @@
<template>
<span class="spinner" />
</template>
<style scoped>
.spinner {
display: inline-block;
width: 0.75rem;
height: 0.75rem;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+52 -25
View File
@@ -39,23 +39,29 @@
>
<LeftArrowIcon />
</button>
<nav>
<ul>
<li>
<RouterLink to="/">Accounts</RouterLink>
</li>
<li>
<RouterLink to="/transactions">Transactions</RouterLink>
</li>
</ul>
</nav>
<button
class="logout-button"
type="button"
@click="handleLogout"
>
Logout
</button>
<div class="sidebar-content">
<div class="top">
<nav>
<ul>
<li>
<RouterLink to="/accounts">Accounts</RouterLink>
</li>
<li>
<RouterLink to="/transactions">Transactions</RouterLink>
</li>
</ul>
</nav>
</div>
<div class="bottom">
<button
class="logout-button"
type="button"
@click="handleLogout"
>
Logout
</button>
</div>
</div>
</aside>
</template>
@@ -82,6 +88,35 @@
}
}
aside.collapsed {
width: calc(0.125rem + var(--button-size) / 2);
}
aside.collapsed .toggle-button {
transform: rotate(180deg);
}
aside.collapsed .sidebar-content {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.sidebar-content {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
gap: 3rem;
flex: 1;
overflow: hidden;
opacity: 1;
visibility: visible;
transition-property: opacity, visibility;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
}
.logout-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
@@ -104,14 +139,6 @@
transition-timing-function: var(--transition-function);
}
aside.collapsed {
width: calc(0.125rem + var(--button-size) / 2);
}
aside.collapsed .toggle-button {
transform: rotate(180deg);
}
.toggle-button svg {
--size: 1rem;
width: var(--size);
@@ -22,5 +22,9 @@
main {
flex: 1;
padding: 1rem;
overflow: auto;
scroll-behavior: smooth;
scrollbar-color: var(--bg-surface) var(--bg-app);
scrollbar-gutter: stable;
}
</style>
@@ -1,17 +1,72 @@
<script setup lang="ts">
import type { Transaction } from '@/services/transactionService';
import type { Transaction } from '@/services/transactionService';
defineProps<{
transaction: Transaction
transaction: Transaction;
}>();
function formatDate(date: string) {
const formatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
return formatter.format(new Date(date));
}
function formatAmount(amount: number): string {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
});
return formatter.format(amount);
}
</script>
<template>
<div>
<div class="card">
<div class="top">
<div class="left">
<div>{{ transaction.merchantName }}</div>
<div class="date">{{ formatDate(transaction.date) }}</div>
</div>
<div class="right">
<div>{{ formatAmount(transaction.amount) }}</div>
</div>
</div>
<div class="bottom">
<p>{{ transaction.description }}</p>
</div>
</div>
</template>
<style scoped>
.card {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--bg-surface);
border-radius: 0.25rem;
padding: 1rem;
}
.top {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.date,
.bottom {
font-size: 0.875rem;
color: #666666;
}
</style>
+20 -2
View File
@@ -23,6 +23,9 @@ const router = createRouter({
{
path: 'login',
component: () => import('../views/LoginView.vue'),
meta: {
title: 'Login',
},
},
],
},
@@ -60,15 +63,30 @@ const router = createRouter({
children: [
{
path: '/',
component: () => import('../views/HomeView.vue'),
redirect: '/accounts',
},
{
path: '/accounts',
component: () => import('../views/AccountsView.vue'),
meta: {
title: 'Accounts',
},
},
{
path: '/transactions',
component: () => import('../views/TransactionsView.vue'),
}
meta: {
title: 'Transactions',
},
},
],
},
],
});
router.beforeEach((to, _, next) => {
document.title = to.meta.title ? `FiscalOS - ${to.meta.title}` : 'FiscalOS';
next();
});
export default router;
@@ -136,8 +136,9 @@ type LinkTokenResponse = {
type Account = {
id: string;
providerId: string;
name: string;
}
};
export type Institution = {
id: string;
+405
View File
@@ -0,0 +1,405 @@
<script setup lang="ts">
import CircleSpinner from '@/components/CircleSpinner.vue';
import { useInstitutionService } from '@/composables/useInstitutionService';
import type { AvailableAccount, Institution } from '@/services/institutionService';
import { useUserStore } from '@/stores/userStore';
import {
usePlaidLink,
type PlaidLinkOnSuccessMetadata,
type PlaidLinkOptions,
} from '@jcss/vue-plaid-link';
import { nextTick, ref, watch } from 'vue';
type InstitutionData =
| {
status: 'loading';
}
| { status: 'loaded'; data: Institution[] }
| { status: 'errored'; errors: Error[] };
const institutionsData = ref<InstitutionData>({ status: 'loading' });
const targetInstitutionOfAdd = ref<Institution | null>(null);
const availableAccounts = ref<AvailableAccount[]>([]);
const targetAccountOfAdd = ref<AvailableAccount | null>(null);
const loadingInstitutionId = ref<string | null>(null);
const accountSelectRefs = ref<HTMLSelectElement[]>([]);
const addingAccount = ref(false);
const plaidOptions = ref<PlaidLinkOptions>({
token: '',
onSuccess: handleSuccess,
});
const userStore = useUserStore();
const institutionService = useInstitutionService(userStore);
const { open } = usePlaidLink(plaidOptions);
async function handleSuccess(publicToken: string, metadata: PlaidLinkOnSuccessMetadata) {
if (metadata.institution == null) {
alert('Institution information is missing from Plaid response. Please try again.');
return;
}
const connectResult = await institutionService.connect(
publicToken,
metadata.institution.institution_id
);
if (connectResult.err) {
alert(connectResult.val.map(e => e.message).join('\n'));
return;
}
institutionsData.value = { status: 'loading' };
}
async function institutionsDataWatcher(data: InstitutionData) {
if (data.status !== 'loading') {
return;
}
const institutionsResult = await institutionService.getInstitutions();
if (institutionsResult.err) {
institutionsData.value = {
status: 'errored',
errors: institutionsResult.val,
};
return;
}
institutionsData.value = {
status: 'loaded',
data: institutionsResult.val,
};
}
watch(institutionsData, institutionsDataWatcher, { immediate: true });
async function handleAddInstitutionClick() {
const linkTokenResult = await institutionService.createLinkToken();
if (linkTokenResult.err) {
alert(linkTokenResult.val.map(e => e.message).join('\n'));
return;
}
plaidOptions.value = {
...plaidOptions.value,
token: linkTokenResult.val.linkToken,
};
await nextTick();
open();
}
async function handleAddAccountClick(institution: Institution) {
loadingInstitutionId.value = institution.id;
const accountsResult = await institutionService.getAvailableAccounts(institution.id);
loadingInstitutionId.value = null;
if (accountsResult.err) {
alert('Failed to retrieve accounts to add');
return;
}
const notAlreadyAddedAccounts = accountsResult.val.filter(a => {
return institution.accounts.some(ac => ac.providerId === a.providerId) === false;
});
availableAccounts.value = notAlreadyAddedAccounts;
targetInstitutionOfAdd.value = institution;
await nextTick();
if (institutionsData.value.status === 'loaded') {
const index = institutionsData.value.data.findIndex(i => i.id === institution.id);
accountSelectRefs.value[index]?.focus();
}
}
async function handleAccountAddClick() {
if (targetAccountOfAdd.value === null) {
return;
}
addingAccount.value = true;
const request = {
plaidInstitutionId: targetAccountOfAdd.value.providerInstitutionId,
plaidAccountId: targetAccountOfAdd.value.providerId,
plaidAccountName: targetAccountOfAdd.value.providerName,
};
try {
const res = await fetch('/api/accounts', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userStore.user?.token}`,
},
body: JSON.stringify(request),
});
if (res.ok === false) {
alert('Add failed');
addingAccount.value = false;
return;
}
targetInstitutionOfAdd.value = null;
addingAccount.value = false;
institutionsData.value = { status: 'loading' };
} catch (error) {
addingAccount.value = false;
console.error(error);
}
}
</script>
<template>
<div class="header">
<h1>Accounts</h1>
<button
class="add-institution-button"
type="button"
@click="handleAddInstitutionClick"
>
Add Institution
</button>
</div>
<div
v-if="institutionsData.status === 'loaded'"
class="institutions-container"
>
<div
class="institution-container"
v-for="institution in institutionsData.data"
:key="institution.id"
>
<div class="institution-card">
<div>
<div>{{ institution.name }}</div>
</div>
<div>
<button
class="add-account-button"
type="button"
:disabled="loadingInstitutionId === institution.id"
@click="handleAddAccountClick(institution)"
>
<CircleSpinner v-if="loadingInstitutionId === institution.id" />
<span v-else>Add Account</span>
</button>
</div>
</div>
<div class="accounts-list">
<div
class="account-row"
v-for="accounts in institution.accounts"
:key="accounts.id"
>
<div class="account-card">{{ accounts.name }}</div>
</div>
</div>
<div
class="add-account-card"
v-if="targetInstitutionOfAdd != null && targetInstitutionOfAdd.id === institution.id"
>
<div class="select-account-container">
<label for="account-select">Select account to add:</label>
<select
id="account-select"
ref="accountSelectRefs"
v-model="targetAccountOfAdd"
>
<option
:value="null"
disabled
hidden
>
Select an account
</option>
<option
v-for="account in availableAccounts"
:key="account.providerId"
:value="account"
>
{{ account.providerName }}
</option>
</select>
</div>
<button
class="add-account-button"
type="button"
:disabled="addingAccount"
@click="handleAccountAddClick"
>
<CircleSpinner v-if="addingAccount" />
<span v-else>Add</span>
</button>
</div>
</div>
</div>
<div v-if="institutionsData.status === 'errored'">Failed to load institutions</div>
</template>
<style scoped>
.header {
display: flex;
gap: 1rem;
align-items: center;
}
.institutions-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.institution-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.institution-card,
.account-card,
.add-account-card {
display: flex;
align-items: center;
padding: 1rem;
border-radius: 0.25rem;
background: var(--bg-surface);
}
.institution-card,
.account-card {
position: relative;
}
.institution-card > div {
flex: 1;
}
.institution-card > div:last-of-type {
display: flex;
justify-content: flex-end;
}
.institution-card::before {
content: '';
position: absolute;
top: 50%;
left: -0.25rem;
transform: translateY(-50%);
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--brand-primary);
z-index: 1;
}
.institution-card::after {
content: '';
position: absolute;
top: 50%;
left: -1px;
height: calc(50% + 0.5rem);
width: 2px;
background: var(--brand-primary);
}
.add-account-card {
margin-left: 2rem;
}
.add-account-card {
gap: 0.5rem;
}
.accounts-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.account-row {
position: relative;
padding-left: 2rem;
}
.account-row::before {
content: '';
position: absolute;
top: 0;
left: -1px;
height: calc(100% + 0.5rem);
width: 2px;
background: var(--brand-primary);
}
.account-row:not(:first-child)::before {
top: -0.5rem;
height: calc(100% + 1rem);
}
.account-row:last-child::before {
height: 50%;
}
.account-row:not(:first-child):last-child::before {
top: -0.5rem;
height: calc(50% + 0.5rem);
}
.account-row::after {
content: '';
position: absolute;
top: 50%;
left: -1px;
width: calc(2rem + 1px);
height: 2px;
background: var(--brand-primary);
transform: translateY(-50%);
}
.account-card::before {
content: '';
position: absolute;
top: 50%;
left: -0.25rem;
transform: translateY(-50%);
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--brand-primary);
z-index: 1;
}
.select-account-container {
display: flex;
gap: 0.25rem;
align-items: center;
}
.select-account-container > select {
padding: 0.25rem;
border-radius: 0.25rem;
}
.add-institution-button,
.add-account-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.add-account-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
</style>
-210
View File
@@ -1,210 +0,0 @@
<script setup lang="ts">
import { useInstitutionService } from '@/composables/useInstitutionService';
import type { AvailableAccount, Institution } from '@/services/institutionService';
import { useUserStore } from '@/stores/userStore';
import { usePlaidLink, type PlaidLinkOptions } from '@jcss/vue-plaid-link';
import { nextTick, ref, watch } from 'vue';
const userStore = useUserStore();
const institutionService = useInstitutionService(userStore);
type InstitutionData =
| {
status: 'loading';
}
| { status: 'loaded'; data: Institution[] }
| { status: 'errored'; errors: Error[] };
const institutionsData = ref<InstitutionData>({ status: 'loading' });
const plaidOptions = ref<PlaidLinkOptions>({
token: '',
onSuccess: async (publicToken, metadata) => {
if (metadata.institution == null) {
alert('Institution information is missing from Plaid response. Please try again.');
return;
}
const connectResult = await institutionService.connect(
publicToken,
metadata.institution.institution_id
);
if (connectResult.err) {
alert(connectResult.val.map(e => e.message).join('\n'));
return;
}
institutionsData.value = { status: 'loading' };
},
onLoad: () => console.log('loaded'),
onExit: () => console.log('exit'),
});
const { open } = usePlaidLink(plaidOptions);
watch(
institutionsData,
async data => {
if (data.status !== 'loading') {
return;
}
const institutionsResult = await institutionService.getInstitutions();
if (institutionsResult.err) {
institutionsData.value = {
status: 'errored',
errors: institutionsResult.val,
};
return;
}
institutionsData.value = {
status: 'loaded',
data: institutionsResult.val,
};
},
{ immediate: true }
);
async function handleAddInstitutionClick() {
const linkTokenResult = await institutionService.createLinkToken();
if (linkTokenResult.err) {
alert(linkTokenResult.val.map(e => e.message).join('\n'));
return;
}
plaidOptions.value = {
...plaidOptions.value,
token: linkTokenResult.val.linkToken,
};
await nextTick();
open();
}
const targetInstitutionOfAdd = ref<Institution | null>(null);
const availableAccounts = ref<AvailableAccount[]>([]);
const targetAccountOfAdd = ref<AvailableAccount | null>(null);
async function handleAddAccountClick(institution: Institution) {
const accountsResult = await institutionService.getAvailableAccounts(institution.id);
if (accountsResult.err) {
alert('Failed to retrieve accounts to add');
return;
}
availableAccounts.value = accountsResult.val;
targetInstitutionOfAdd.value = institution;
}
async function handleAccountAddClick() {
if (targetAccountOfAdd.value === null) {
return;
}
const request = {
plaidInstitutionId: targetAccountOfAdd.value.providerInstitutionId,
plaidAccountId: targetAccountOfAdd.value.providerId,
plaidAccountName: targetAccountOfAdd.value.providerName,
accountAvailableBalance: targetAccountOfAdd.value.availableBalance,
accountCurrencyCode: targetAccountOfAdd.value.currencyCode,
};
try {
const res = await fetch('/api/accounts', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userStore.user?.token}`,
},
body: JSON.stringify(request),
});
if (res.ok === false) {
alert('Add failed');
return;
}
targetInstitutionOfAdd.value = null;
institutionsData.value = { status: 'loading' };
} catch (error) {
console.error(error);
}
}
</script>
<template>
<div>
<button class="add-institution-button" type="button" @click="handleAddInstitutionClick">
Add Institution
</button>
</div>
<div v-if="institutionsData.status === 'loaded'" class="institutions-container">
<div v-for="institution in institutionsData.data" :key="institution.id">
<div class="institution-card">
<div>
<div>{{ institution.name }}</div>
</div>
<div>
<button class="add-account-button" type="button" @click="handleAddAccountClick(institution)">
Add Account
</button>
</div>
</div>
<div v-if="targetInstitutionOfAdd != null && targetInstitutionOfAdd.id === institution.id">
<select v-model="targetAccountOfAdd">
<option v-for="account in availableAccounts" :key="account.providerId" :value="account">
{{ account.providerName }}
</option>
</select>
<button type="button" @click="handleAccountAddClick">
Add
</button>
</div>
<div v-for="accounts in institution.accounts" :key="accounts.id">
<div>{{ accounts.name }}</div>
</div>
</div>
</div>
<div v-if="institutionsData.status === 'errored'">Failed to load institutions</div>
</template>
<style scoped>
.institutions-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
}
.institution-card {
display: flex;
align-items: center;
padding: 1rem;
border-radius: 0.25rem;
background: var(--bg-surface);
}
.institution-card>div {
flex: 1;
}
.institution-card>div:last-of-type {
display: flex;
justify-content: flex-end;
}
.add-institution-button,
.add-account-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
</style>
+29 -31
View File
@@ -1,47 +1,45 @@
<script setup lang="ts">
import { useTransactionService } from '@/composables/useTransactionService';
import type { Transaction } from '@/services/transactionService';
import { useUserStore } from '@/stores/userStore';
import { onMounted, ref } from 'vue';
import TransactionCard from '@/components/TransactionCard.vue';
import { useTransactionService } from '@/composables/useTransactionService';
import type { Transaction } from '@/services/transactionService';
import { useUserStore } from '@/stores/userStore';
import { onMounted, ref } from 'vue';
const userStore = useUserStore();
const transactionService = useTransactionService(userStore);
const userStore = useUserStore();
const transactionService = useTransactionService(userStore);
const transactions = ref<Transaction[]>([]);
const transactions = ref<Transaction[]>([]);
onMounted(async () => {
const result = await transactionService.get();
onMounted(async () => {
const result = await transactionService.get();
if (result.err) {
alert(result.val.map(e => e.message).join('\n'));
return;
}
if (result.err) {
alert(result.val.map(e => e.message).join('\n'));
return;
}
transactions.value = result.val.items;
});
transactions.value = result.val.items;
});
</script>
<template>
<div>
<h1>Transactions</h1>
<ul>
<li v-for="transaction in transactions" :key="transaction.id">
<div class="card">
<div class="top">
<div>
<div>{{ transaction.merchantName }}</div>
</div>
<div>
<div>{{ transaction.amount }}</div>
</div>
</div>
<div class="bottom">
<p>{{ transaction.description }}</p>
</div>
</div>
<ul class="transactions">
<li
v-for="transaction in transactions"
:key="transaction.id"
>
<TransactionCard :transaction="transaction" />
</li>
</ul>
</div>
</template>
<style scoped></style>
<style scoped>
.transactions {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>