feat(web): enhance transaction display and account management features
This commit is contained in:
@@ -19,6 +19,7 @@ internal static class Endpoint
|
|||||||
var user = await appDbContext.Users
|
var user = await appDbContext.Users
|
||||||
.Include(u => u.Institutions)
|
.Include(u => u.Institutions)
|
||||||
.ThenInclude(i => i.Accounts)
|
.ThenInclude(i => i.Accounts)
|
||||||
|
.ThenInclude(a => a.Metadata)
|
||||||
.FirstOrDefaultAsync(u => u.Id == userId);
|
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ internal sealed record Response
|
|||||||
internal sealed record AccountDto
|
internal sealed record AccountDto
|
||||||
{
|
{
|
||||||
public string Id { get; init; } = string.Empty;
|
public string Id { get; init; } = string.Empty;
|
||||||
|
public string ProviderId { get; init; } = string.Empty;
|
||||||
public string Name { get; init; } = string.Empty;
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
[JsonConstructor]
|
[JsonConstructor]
|
||||||
@@ -36,6 +37,9 @@ internal sealed record AccountDto
|
|||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
Id = account.Id.ToString(),
|
Id = account.Id.ToString(),
|
||||||
|
ProviderId = account.Metadata is PlaidAccountMetadata plaidMetadata
|
||||||
|
? plaidMetadata.PlaidId
|
||||||
|
: string.Empty,
|
||||||
Name = account.Name,
|
Name = account.Name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,24 @@ input {
|
|||||||
color: inherit;
|
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 {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
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>
|
||||||
@@ -39,23 +39,29 @@
|
|||||||
>
|
>
|
||||||
<LeftArrowIcon />
|
<LeftArrowIcon />
|
||||||
</button>
|
</button>
|
||||||
<nav>
|
<div class="sidebar-content">
|
||||||
<ul>
|
<div class="top">
|
||||||
<li>
|
<nav>
|
||||||
<RouterLink to="/">Accounts</RouterLink>
|
<ul>
|
||||||
</li>
|
<li>
|
||||||
<li>
|
<RouterLink to="/accounts">Accounts</RouterLink>
|
||||||
<RouterLink to="/transactions">Transactions</RouterLink>
|
</li>
|
||||||
</li>
|
<li>
|
||||||
</ul>
|
<RouterLink to="/transactions">Transactions</RouterLink>
|
||||||
</nav>
|
</li>
|
||||||
<button
|
</ul>
|
||||||
class="logout-button"
|
</nav>
|
||||||
type="button"
|
</div>
|
||||||
@click="handleLogout"
|
<div class="bottom">
|
||||||
>
|
<button
|
||||||
Logout
|
class="logout-button"
|
||||||
</button>
|
type="button"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</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 {
|
.logout-button {
|
||||||
background: var(--bg-element);
|
background: var(--bg-element);
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
@@ -104,14 +139,6 @@
|
|||||||
transition-timing-function: var(--transition-function);
|
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 {
|
.toggle-button svg {
|
||||||
--size: 1rem;
|
--size: 1rem;
|
||||||
width: var(--size);
|
width: var(--size);
|
||||||
|
|||||||
@@ -22,5 +22,9 @@
|
|||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-color: var(--bg-surface) var(--bg-app);
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,17 +1,72 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Transaction } from '@/services/transactionService';
|
import type { Transaction } from '@/services/transactionService';
|
||||||
|
|
||||||
defineProps<{
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: 'login',
|
path: 'login',
|
||||||
component: () => import('../views/LoginView.vue'),
|
component: () => import('../views/LoginView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Login',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -60,15 +63,30 @@ const router = createRouter({
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('../views/HomeView.vue'),
|
redirect: '/accounts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/accounts',
|
||||||
|
component: () => import('../views/AccountsView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Accounts',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/transactions',
|
path: '/transactions',
|
||||||
component: () => import('../views/TransactionsView.vue'),
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -136,8 +136,9 @@ type LinkTokenResponse = {
|
|||||||
|
|
||||||
type Account = {
|
type Account = {
|
||||||
id: string;
|
id: string;
|
||||||
|
providerId: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type Institution = {
|
export type Institution = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -1,47 +1,45 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTransactionService } from '@/composables/useTransactionService';
|
import TransactionCard from '@/components/TransactionCard.vue';
|
||||||
import type { Transaction } from '@/services/transactionService';
|
import { useTransactionService } from '@/composables/useTransactionService';
|
||||||
import { useUserStore } from '@/stores/userStore';
|
import type { Transaction } from '@/services/transactionService';
|
||||||
import { onMounted, ref } from 'vue';
|
import { useUserStore } from '@/stores/userStore';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const transactionService = useTransactionService(userStore);
|
const transactionService = useTransactionService(userStore);
|
||||||
|
|
||||||
const transactions = ref<Transaction[]>([]);
|
const transactions = ref<Transaction[]>([]);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const result = await transactionService.get();
|
const result = await transactionService.get();
|
||||||
|
|
||||||
if (result.err) {
|
if (result.err) {
|
||||||
alert(result.val.map(e => e.message).join('\n'));
|
alert(result.val.map(e => e.message).join('\n'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
transactions.value = result.val.items;
|
transactions.value = result.val.items;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1>Transactions</h1>
|
<h1>Transactions</h1>
|
||||||
<ul>
|
<ul class="transactions">
|
||||||
<li v-for="transaction in transactions" :key="transaction.id">
|
<li
|
||||||
<div class="card">
|
v-for="transaction in transactions"
|
||||||
<div class="top">
|
:key="transaction.id"
|
||||||
<div>
|
>
|
||||||
<div>{{ transaction.merchantName }}</div>
|
<TransactionCard :transaction="transaction" />
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div>{{ transaction.amount }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bottom">
|
|
||||||
<p>{{ transaction.description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.transactions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user