Added standard form components, account creation and editing.

This commit is contained in:
Andrew Lalis 2025-08-04 21:35:24 -04:00
parent ae94dcebe6
commit ace83d49ba
24 changed files with 583 additions and 112 deletions

View File

@ -11,6 +11,7 @@ import handy_http_handlers.path_handler;
import profile.service;
import account.model;
import util.money;
import account.data;
/// The data the API provides for an Account entity.
struct AccountResponse {
@ -79,6 +80,25 @@ void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse r
writeJsonBody(response, AccountResponse.of(account));
}
void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request);
auto ds = getProfileDataSource(request);
AccountRepository repo = ds.getAccountRepository();
auto account = repo.findById(accountId).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
Account updated = repo.update(accountId, Account(
account.id,
account.createdAt,
account.archived,
AccountType.fromId(payload.type),
payload.numberSuffix,
payload.name,
Currency.ofCode(payload.currency),
payload.description
));
writeJsonBody(response, AccountResponse.of(updated));
}
void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request);

View File

@ -70,7 +70,8 @@ SQL",
newData.numberSuffix,
newData.name,
newData.currency.code,
newData.description
newData.description,
id
);
return this.findById(id).orElseThrow("Account doesn't exist");
});

View File

@ -49,6 +49,7 @@ HttpRequestHandler mapApiHandlers() {
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
a.map(HttpMethod.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount);
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleUpdateAccount);
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleDeleteAccount);
import transaction.api;

View File

@ -1,13 +1,10 @@
<script setup lang="ts">
import ModalWrapper from './components/ModalWrapper.vue';
import GlobalAlertModal from './components/GlobalAlertModal.vue';
</script>
<template>
<RouterView></RouterView>
<!-- Global alert modal used by util/alert.ts -->
<ModalWrapper id="global-alert-modal">
<p id="global-alert-modal-text">This is an alert!</p>
</ModalWrapper>
<GlobalAlertModal id="global-alert-modal" />
</template>
<style scoped></style>

View File

@ -1,6 +1,43 @@
import { ApiClient } from './base'
import type { Profile } from './profile'
export interface AccountType {
id: string
name: string
debitsPositive: boolean
}
export abstract class AccountTypes {
public static readonly CHECKING: AccountType = {
id: 'CHECKING',
name: 'Checking',
debitsPositive: true,
}
public static readonly SAVINGS: AccountType = {
id: 'SAVINGS',
name: 'Savings',
debitsPositive: true,
}
public static readonly CREDIT_CARD: AccountType = {
id: 'CREDIT_CARD',
name: 'Credit Card',
debitsPositive: false,
}
public static readonly BROKERAGE: AccountType = {
id: 'BROKERAGE',
name: 'Brokerage',
debitsPositive: true,
}
public static of(id: string): AccountType {
if (id === 'CHECKING') return AccountTypes.CHECKING
if (id === 'SAVINGS') return AccountTypes.SAVINGS
if (id === 'CREDIT_CARD') return AccountTypes.CREDIT_CARD
if (id === 'BROKERAGE') return AccountTypes.BROKERAGE
throw new Error('Unknown account type: ' + id)
}
}
export interface Account {
id: number
createdAt: string
@ -40,6 +77,10 @@ export class AccountApiClient extends ApiClient {
return super.postJson(this.path, data)
}
async updateAccount(id: number, data: AccountCreationPayload): Promise<Account> {
return super.putJson(this.path + '/' + id, data)
}
async deleteAccount(id: number): Promise<void> {
return super.delete(this.path + '/' + id)
}

View File

@ -42,40 +42,9 @@ a:hover {
text-decoration: underline;
}
.app-page-container {
max-width: 600px;
margin-left: auto;
margin-right: auto;
padding: 0.5rem;
padding-bottom: 1rem;
background-color: var(--bg-page);
border-bottom-left-radius: 2rem;
border-bottom-right-radius: 2rem;
}
.app-page-title {
margin: 0;
font-size: 28px;
font-weight: 500;
}
.app-module-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
padding: 1rem;
}
.app-module {
min-width: 300px;
min-height: 200px;
flex-grow: 1;
background-color: var(--bg-secondary);
border-radius: 0.5rem;
padding: 0.5rem;
}
.app-module-header {
margin: 0;
}

View File

@ -1,10 +1,16 @@
<script setup lang="ts">
defineProps<{ buttonStyle?: string, icon?: string }>()
defineProps<{
buttonStyle?: string,
icon?: string,
buttonType?: "button" | "submit" | "reset" | undefined,
disabled?: boolean
}>()
defineEmits(['click'])
</script>
<template>
<button class="app-button" :class="{ 'app-button-secondary': buttonStyle === 'secondary' }" @click="$emit('click')">
<button class="app-button" :class="{ 'app-button-secondary': buttonStyle === 'secondary' }" @click="$emit('click')"
:type="buttonType" :disabled="disabled ?? false">
<span v-if="icon">
<font-awesome-icon :icon="'fa-' + icon" style="margin-right: 0.5rem; margin-left: -0.5rem;"></font-awesome-icon>
</span>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
defineProps<{ title: string }>()
</script>
<template>
<div class="app-page">
<h1 class="app-page-title">{{ title }}</h1>
<slot></slot>
</div>
</template>
<style lang="css">
.app-page {
max-width: 600px;
margin-left: auto;
margin-right: auto;
padding: 0.5rem;
padding-bottom: 1rem;
background-color: var(--bg-page);
border-bottom-left-radius: 2rem;
border-bottom-right-radius: 2rem;
}
.app-page-title {
margin-top: 0;
margin-bottom: 0.5rem;
font-size: 28px;
font-weight: 700;
}
</style>

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import ModalWrapper from './ModalWrapper.vue';
import AppButton from './AppButton.vue';
const globalAlertModal = useTemplateRef('global-alert-modal')
</script>
<template>
<ModalWrapper id="global-alert-modal" ref="global-alert-modal">
<template v-slot:default>
<p id="global-alert-modal-text">This is an alert!</p>
</template>
<template v-slot:buttons>
<AppButton id="global-alert-modal-ok-button" @click="globalAlertModal?.close('ok')">Ok</AppButton>
<AppButton id="global-alert-modal-close-button" button-style="secondary"
@click="globalAlertModal?.close('close')">Close</AppButton>
<AppButton id="global-alert-modal-cancel-button" button-style="secondary"
@click="globalAlertModal?.close('cancel')">Cancel</AppButton>
</template>
</ModalWrapper>
</template>

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
defineProps<{ title: string }>()
</script>
<template>
<div class="app-module">
<div>
<h2 class="app-module-header">{{ title }}</h2>
<slot></slot>
</div>
<div class="app-module-actions">
<slot name="actions"></slot>
</div>
</div>
</template>
<style lang="css">
.app-module {
min-width: 300px;
min-height: 200px;
flex-grow: 1;
background-color: var(--bg-secondary);
border-radius: 0.5rem;
padding: 0.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.app-module-header {
margin: 0;
}
.app-module-actions {
text-align: right;
}
</style>

View File

@ -29,7 +29,7 @@ defineExpose({ show, close })
<div class="app-modal-dialog-actions">
<slot name="buttons">
<AppButton style="secondary" @click="close()">Close</AppButton>
<AppButton button-style="secondary" @click="close()">Close</AppButton>
</slot>
</div>
</dialog>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
</script>
<template>
<table class="app-properties-table">
<tbody>
<slot></slot>
</tbody>
</table>
</template>
<style lang="css">
.app-properties-table {
border-collapse: collapse;
}
.app-properties-table th {
text-align: left;
vertical-align: top;
padding-right: 5px;
}
</style>

View File

@ -0,0 +1,8 @@
<script setup lang="ts">
defineEmits<{ 'submit': void }>()
</script>
<template>
<form @submit.prevent="e => $emit('submit')">
<slot></slot>
</form>
</template>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import AppButton from '../AppButton.vue';
defineEmits<{ 'cancel': void }>()
defineProps<{ submitText?: string, cancelText?: string, disabled?: boolean }>()
</script>
<template>
<div class="app-form-actions">
<AppButton button-type="submit" :disabled="disabled ?? false">{{ submitText ?? 'Submit' }}</AppButton>
<AppButton button-style="secondary" @click="$emit('cancel')" :disabled="disabled ?? false">{{ cancelText ?? 'Cancel'
}}</AppButton>
</div>
</template>
<style lang="css">
.app-form-actions {
text-align: right;
}
</style>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
defineProps<{
label: string
}>()
</script>
<template>
<div class="app-form-control">
<label>
{{ label }}
<slot></slot>
</label>
</div>
</template>
<style lang="css">
.app-form-control {
flex-grow: 1;
margin: 0.5rem;
}
.app-form-control>label {
display: flex;
flex-direction: column;
font-size: 0.9rem;
font-weight: 700;
}
</style>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
</script>
<template>
<div class="app-form-group">
<slot></slot>
</div>
</template>
<style lang="css">
.app-form-group {
display: flex;
flex-wrap: wrap;
margin-top: 1rem;
margin-bottom: 1rem;
}
</style>

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import { AccountApiClient, type Account } from '@/api/account';
import AppButton from '@/components/AppButton.vue';
import AppPage from '@/components/AppPage.vue';
import PropertiesTable from '@/components/PropertiesTable.vue';
import { useProfileStore } from '@/stores/profile-store';
import { showConfirm } from '@/util/alert';
import { onMounted, ref, type Ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute()
const router = useRouter()
const profileStore = useProfileStore()
const account: Ref<Account | null> = ref(null)
onMounted(async () => {
if (!profileStore.state) {
await router.replace('/')
return
}
const accountId = parseInt(route.params.id as string)
try {
const api = new AccountApiClient(profileStore.state)
account.value = await api.getAccount(accountId)
} catch (err) {
console.error(err)
await router.replace('/')
}
})
async function deleteAccount() {
if (!profileStore.state || !account.value) return
if (await showConfirm('Are you sure you want to delete this account? This will permanently remove the account and all associated transactions.')) {
try {
const api = new AccountApiClient(profileStore.state)
await api.deleteAccount(account.value.id)
await router.replace('/')
} catch (err) {
console.error(err)
}
}
}
</script>
<template>
<AppPage :title="account?.name ?? ''">
<PropertiesTable v-if="account">
<tr>
<th>ID</th>
<td>{{ account.id }}</td>
</tr>
<tr>
<th>Created at</th>
<td>{{ new Date(account.createdAt).toLocaleString() }}</td>
</tr>
<tr>
<th>Archived</th>
<td>{{ account.archived }}</td>
</tr>
<tr>
<th>Type</th>
<td>{{ account.type }}</td>
</tr>
<tr>
<th>Name</th>
<td>{{ account.name }}</td>
</tr>
<tr>
<th>Number Suffix</th>
<td>{{ account.numberSuffix }}</td>
</tr>
<tr>
<th>Currency</th>
<td>{{ account.currency }}</td>
</tr>
<tr>
<th>Description</th>
<td>{{ account.description }}</td>
</tr>
</PropertiesTable>
<div>
<AppButton icon="wrench"
@click="router.push(`/profiles/${profileStore.state?.name}/accounts/${account?.id}/edit`)">Edit</AppButton>
<AppButton icon="trash" @click="deleteAccount()">Delete</AppButton>
</div>
</AppPage>
</template>

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { ProfileApiClient, type Profile } from '@/api/profile';
import AppButton from '@/components/AppButton.vue';
import AppPage from '@/components/AppPage.vue';
import ModalWrapper from '@/components/ModalWrapper.vue';
import { useAuthStore } from '@/stores/auth-store';
import { useProfileStore } from '@/stores/profile-store';
@ -51,9 +52,7 @@ async function addProfile() {
}
</script>
<template>
<div class="app-page-container">
<h1 class="app-page-title">Select a Profile</h1>
<AppPage title="Select a Profile">
<div class="profile-card" v-for="profile in profiles" :key="profile.name" @click="selectProfile(profile)">
<span>{{ profile.name }}</span>
</div>
@ -74,7 +73,7 @@ async function addProfile() {
<AppButton button-style="secondary" @click="addProfileModal?.close()">Cancel</AppButton>
</template>
</ModalWrapper>
</div>
</AppPage>
</template>
<style lang="css">
.profile-card {

View File

@ -0,0 +1,112 @@
<script setup lang="ts">
import { AccountApiClient, AccountTypes, type Account, type AccountType } from '@/api/account';
import AppPage from '@/components/AppPage.vue';
import AppForm from '@/components/form/AppForm.vue';
import FormActions from '@/components/form/FormActions.vue';
import FormControl from '@/components/form/FormControl.vue';
import FormGroup from '@/components/form/FormGroup.vue';
import { useProfileStore } from '@/stores/profile-store';
import { computed, onMounted, ref, type Ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute()
const router = useRouter()
const profileStore = useProfileStore()
const existingAccount: Ref<Account | null> = ref(null)
const editing = computed(() => {
return existingAccount.value !== null || route.meta.title === 'Edit Account'
})
const loading = ref(false)
const accountName = ref('')
const accountType: Ref<AccountType> = ref(AccountTypes.CHECKING)
const accountNumberSuffix = ref('')
const currency = ref('USD')
const description = ref('')
onMounted(async () => {
console.log(route)
if (!profileStore.state) return
const accountIdStr = route.params.id
if (accountIdStr && typeof (accountIdStr) === 'string') {
const accountId = parseInt(accountIdStr)
try {
loading.value = true
const api = new AccountApiClient(profileStore.state)
existingAccount.value = await api.getAccount(accountId)
accountName.value = existingAccount.value.name
accountType.value = AccountTypes.of(existingAccount.value.type)
accountNumberSuffix.value = existingAccount.value.numberSuffix
currency.value = existingAccount.value.currency
description.value = existingAccount.value.description
} catch (err) {
console.error(err)
} finally {
loading.value = false
}
}
})
async function doSubmit() {
if (!profileStore.state) return
const payload = {
name: accountName.value,
description: description.value,
type: accountType.value.id,
currency: currency.value,
numberSuffix: accountNumberSuffix.value
}
try {
const api = new AccountApiClient(profileStore.state)
loading.value = true
const account = editing.value
? await api.updateAccount(existingAccount.value?.id ?? 0, payload)
: await api.createAccount(payload)
await router.replace(`/profiles/${profileStore.state.name}/accounts/${account.id}`)
} catch (err) {
console.error(err)
} finally {
loading.value = false
}
}
</script>
<template>
<AppPage :title="editing ? 'Edit Account' : 'Add Account'">
<AppForm @submit="doSubmit()">
<FormGroup>
<FormControl label="Account Name" style="max-width: 200px;">
<input v-model="accountName" :disabled="loading" />
</FormControl>
<FormControl label="Account Type">
<select id="account-type-select" v-model="accountType" :disabled="loading" required>
<option :value="AccountTypes.CHECKING">{{ AccountTypes.CHECKING.name }}</option>
<option :value="AccountTypes.SAVINGS">{{ AccountTypes.SAVINGS.name }}</option>
<option :value="AccountTypes.CREDIT_CARD">{{ AccountTypes.CREDIT_CARD.name }}</option>
<option :value="AccountTypes.BROKERAGE">{{ AccountTypes.BROKERAGE.name }}</option>
</select>
</FormControl>
<FormControl label="Currency">
<select id="currency-select" v-model="currency" :disabled="loading" required>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
</FormControl>
<FormControl label="Account Number Suffix" style="max-width: 200px;">
<input id="account-number-suffix-input" v-model="accountNumberSuffix" minlength="4" maxlength="4"
:disabled="loading" required />
</FormControl>
</FormGroup>
<FormGroup>
<FormControl label="Description">
<textarea id="description-textarea" v-model="description" :disabled="loading"></textarea>
</FormControl>
</FormGroup>
<FormActions @cancel="router.replace('/')" :disabled="loading" :submit-text="editing ? 'Save' : 'Add'" />
</AppForm>
</AppPage>
</template>

View File

@ -1,8 +1,12 @@
<script setup lang="ts">
import { AccountApiClient, type Account } from '@/api/account'
import AppButton from '@/components/AppButton.vue'
import HomeModule from '@/components/HomeModule.vue'
import { useProfileStore } from '@/stores/profile-store'
import { onMounted, ref, type Ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const profileStore = useProfileStore()
const accounts: Ref<Account[]> = ref([])
@ -19,8 +23,8 @@ onMounted(async () => {
})
</script>
<template>
<div class="app-module">
<h2 class="app-module-header">Accounts</h2>
<HomeModule title="Accounts">
<template v-slot:default>
<table>
<thead>
<tr>
@ -32,13 +36,21 @@ onMounted(async () => {
</thead>
<tbody>
<tr v-for="account in accounts" :key="account.id">
<td>{{ account.name }}</td>
<td>
<RouterLink :to="`/profiles/${profileStore.state?.name}/accounts/${account.id}`">{{ account.name }}
</RouterLink>
</td>
<td>{{ account.currency }}</td>
<td>...{{ account.numberSuffix }}</td>
<td>{{ account.type }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<template v-slot:actions>
<AppButton icon="plus" @click="router.push(`/profiles/${profileStore.state?.name}/add-account`)">Add Account
</AppButton>
</template>
</HomeModule>
</template>
<style lang="css"></style>

View File

@ -2,6 +2,7 @@
import { ProfileApiClient } from '@/api/profile';
import AppButton from '@/components/AppButton.vue';
import ConfirmModal from '@/components/ConfirmModal.vue';
import HomeModule from '@/components/HomeModule.vue';
import { useProfileStore } from '@/stores/profile-store';
import { useTemplateRef } from 'vue';
import { useRouter } from 'vue-router';
@ -23,20 +24,18 @@ async function deleteProfile() {
}
</script>
<template>
<div class="app-module" style="display: flex; flex-direction: column; justify-content: space-between;">
<div>
<h2 class="app-module-header">Profile</h2>
<HomeModule title="Profile">
<template v-slot:default>
<p>Your currently selected profile is: {{ profileStore.state?.name }}</p>
</div>
<div style="text-align: right;">
<AppButton icon="folder-open" @click="router.push('/profiles')">Choose another profile</AppButton>
<AppButton button-style="secondary" icon="trash" @click="deleteProfile()">Delete</AppButton>
</div>
<ConfirmModal ref="confirmDeleteModal">
<p>Are you sure you want to delete this profile?</p>
<p>This will permanently remove all data associated with this profile, and this cannot be undone!</p>
</ConfirmModal>
</div>
</template>
<template v-slot:actions>
<AppButton icon="folder-open" @click="router.push('/profiles')">Choose another profile</AppButton>
<AppButton button-style="secondary" icon="trash" @click="deleteProfile()">Delete</AppButton>
</template>
</HomeModule>
</template>

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Page, PageRequest } from '@/api/pagination';
import { TransactionApiClient, type Transaction } from '@/api/transaction';
import HomeModule from '@/components/HomeModule.vue';
import PaginationControls from '@/components/PaginationControls.vue';
import { useProfileStore } from '@/stores/profile-store';
import { onMounted, ref, type Ref } from 'vue';
@ -23,8 +24,8 @@ async function fetchPage(pageRequest: PageRequest) {
}
</script>
<template>
<div class="app-module">
<h2 class="app-module-header">Transactions</h2>
<HomeModule title="Transactions">
<template v-slot:default>
<table>
<thead>
<tr>
@ -45,6 +46,7 @@ async function fetchPage(pageRequest: PageRequest) {
</tr>
</tbody>
</table>
<PaginationControls :page="transactions" @update="pr => fetchPage(pr)" />
</div>
<PaginationControls :page="transactions" @update="pr => fetchPage(pr)"></PaginationControls>
</template>
</HomeModule>
</template>

View File

@ -6,6 +6,8 @@ import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vu
import UserHomePage from '@/pages/UserHomePage.vue'
import ProfilesPage from '@/pages/ProfilesPage.vue'
import { useProfileStore } from '@/stores/profile-store'
import AccountPage from '@/pages/AccountPage.vue'
import EditAccountPage from '@/pages/forms/EditAccountPage.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -32,10 +34,31 @@ const router = createRouter({
meta: { title: 'Profiles' },
},
{
path: '/profiles/:name',
path: 'profiles/:name',
beforeEnter: profileSelected,
children: [
{
path: '',
component: async () => ProfilePage,
meta: { title: (to: RouteLocationNormalized) => 'Profile ' + to.params.name },
},
{
path: 'accounts/:id',
component: async () => AccountPage,
meta: { title: 'Account' },
},
{
path: 'accounts/:id/edit',
component: async () => EditAccountPage,
meta: { title: 'Edit Account' },
},
{
path: 'add-account',
component: async () => EditAccountPage,
meta: { title: 'Add Account' },
},
],
},
],
},
],

View File

@ -1,13 +1,10 @@
export function showAlert(message: string): Promise<void> {
const modal: HTMLDialogElement = document.getElementById(
'global-alert-modal',
) as HTMLDialogElement
const modalText: HTMLParagraphElement = document.getElementById(
'global-alert-modal-text',
) as HTMLParagraphElement
modalText.innerText = message
getText().innerText = message
getOkButton().style.display = 'none'
getCancelButton().style.display = 'none'
getCloseButton().style.display = ''
return new Promise((resolve) => {
const modal = getModal()
modal.showModal()
modal.addEventListener(
'close',
@ -18,3 +15,35 @@ export function showAlert(message: string): Promise<void> {
)
})
}
export function showConfirm(message: string): Promise<boolean> {
getText().innerText = message
getOkButton().style.display = ''
getCancelButton().style.display = ''
getCloseButton().style.display = 'none'
return new Promise((resolve) => {
const modal = getModal()
modal.showModal()
modal.addEventListener('close', () => resolve(modal.returnValue === 'ok'), { once: true })
})
}
function getModal(): HTMLDialogElement {
return document.getElementById('global-alert-modal') as HTMLDialogElement
}
function getText(): HTMLParagraphElement {
return document.getElementById('global-alert-modal-text') as HTMLParagraphElement
}
function getOkButton(): HTMLButtonElement {
return document.getElementById('global-alert-modal-ok-button') as HTMLButtonElement
}
function getCancelButton(): HTMLButtonElement {
return document.getElementById('global-alert-modal-cancel-button') as HTMLButtonElement
}
function getCloseButton(): HTMLButtonElement {
return document.getElementById('global-alert-modal-close-button') as HTMLButtonElement
}