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
|
||||
.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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user