Added standard form components, account creation and editing.
This commit is contained in:
parent
ae94dcebe6
commit
ace83d49ba
|
@ -11,6 +11,7 @@ import handy_http_handlers.path_handler;
|
||||||
import profile.service;
|
import profile.service;
|
||||||
import account.model;
|
import account.model;
|
||||||
import util.money;
|
import util.money;
|
||||||
|
import account.data;
|
||||||
|
|
||||||
/// The data the API provides for an Account entity.
|
/// The data the API provides for an Account entity.
|
||||||
struct AccountResponse {
|
struct AccountResponse {
|
||||||
|
@ -79,6 +80,25 @@ void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse r
|
||||||
writeJsonBody(response, AccountResponse.of(account));
|
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) {
|
void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
ulong accountId = request.getPathParamAs!ulong("accountId");
|
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||||
auto ds = getProfileDataSource(request);
|
auto ds = getProfileDataSource(request);
|
||||||
|
|
|
@ -70,7 +70,8 @@ SQL",
|
||||||
newData.numberSuffix,
|
newData.numberSuffix,
|
||||||
newData.name,
|
newData.name,
|
||||||
newData.currency.code,
|
newData.currency.code,
|
||||||
newData.description
|
newData.description,
|
||||||
|
id
|
||||||
);
|
);
|
||||||
return this.findById(id).orElseThrow("Account doesn't exist");
|
return this.findById(id).orElseThrow("Account doesn't exist");
|
||||||
});
|
});
|
||||||
|
|
|
@ -49,6 +49,7 @@ HttpRequestHandler mapApiHandlers() {
|
||||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
|
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
|
||||||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
|
a.map(HttpMethod.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
|
||||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount);
|
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);
|
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleDeleteAccount);
|
||||||
|
|
||||||
import transaction.api;
|
import transaction.api;
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ModalWrapper from './components/ModalWrapper.vue';
|
import GlobalAlertModal from './components/GlobalAlertModal.vue';
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<RouterView></RouterView>
|
<RouterView></RouterView>
|
||||||
|
|
||||||
<!-- Global alert modal used by util/alert.ts -->
|
<!-- Global alert modal used by util/alert.ts -->
|
||||||
<ModalWrapper id="global-alert-modal">
|
<GlobalAlertModal id="global-alert-modal" />
|
||||||
<p id="global-alert-modal-text">This is an alert!</p>
|
|
||||||
</ModalWrapper>
|
|
||||||
</template>
|
</template>
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
@ -1,6 +1,43 @@
|
||||||
import { ApiClient } from './base'
|
import { ApiClient } from './base'
|
||||||
import type { Profile } from './profile'
|
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 {
|
export interface Account {
|
||||||
id: number
|
id: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
@ -40,6 +77,10 @@ export class AccountApiClient extends ApiClient {
|
||||||
return super.postJson(this.path, data)
|
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> {
|
async deleteAccount(id: number): Promise<void> {
|
||||||
return super.delete(this.path + '/' + id)
|
return super.delete(this.path + '/' + id)
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,40 +42,9 @@ a:hover {
|
||||||
text-decoration: underline;
|
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 {
|
.app-module-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
padding: 1rem;
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,10 +1,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
defineProps<{ buttonStyle?: string, icon?: string }>()
|
defineProps<{
|
||||||
|
buttonStyle?: string,
|
||||||
|
icon?: string,
|
||||||
|
buttonType?: "button" | "submit" | "reset" | undefined,
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
defineEmits(['click'])
|
defineEmits(['click'])
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<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">
|
<span v-if="icon">
|
||||||
<font-awesome-icon :icon="'fa-' + icon" style="margin-right: 0.5rem; margin-left: -0.5rem;"></font-awesome-icon>
|
<font-awesome-icon :icon="'fa-' + icon" style="margin-right: 0.5rem; margin-left: -0.5rem;"></font-awesome-icon>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -29,7 +29,7 @@ defineExpose({ show, close })
|
||||||
|
|
||||||
<div class="app-modal-dialog-actions">
|
<div class="app-modal-dialog-actions">
|
||||||
<slot name="buttons">
|
<slot name="buttons">
|
||||||
<AppButton style="secondary" @click="close()">Close</AppButton>
|
<AppButton button-style="secondary" @click="close()">Close</AppButton>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
|
@ -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>
|
|
@ -0,0 +1,8 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineEmits<{ 'submit': void }>()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="e => $emit('submit')">
|
||||||
|
<slot></slot>
|
||||||
|
</form>
|
||||||
|
</template>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ProfileApiClient, type Profile } from '@/api/profile';
|
import { ProfileApiClient, type Profile } from '@/api/profile';
|
||||||
import AppButton from '@/components/AppButton.vue';
|
import AppButton from '@/components/AppButton.vue';
|
||||||
|
import AppPage from '@/components/AppPage.vue';
|
||||||
import ModalWrapper from '@/components/ModalWrapper.vue';
|
import ModalWrapper from '@/components/ModalWrapper.vue';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useProfileStore } from '@/stores/profile-store';
|
import { useProfileStore } from '@/stores/profile-store';
|
||||||
|
@ -51,9 +52,7 @@ async function addProfile() {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="app-page-container">
|
<AppPage title="Select a Profile">
|
||||||
<h1 class="app-page-title">Select a Profile</h1>
|
|
||||||
|
|
||||||
<div class="profile-card" v-for="profile in profiles" :key="profile.name" @click="selectProfile(profile)">
|
<div class="profile-card" v-for="profile in profiles" :key="profile.name" @click="selectProfile(profile)">
|
||||||
<span>{{ profile.name }}</span>
|
<span>{{ profile.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -74,7 +73,7 @@ async function addProfile() {
|
||||||
<AppButton button-style="secondary" @click="addProfileModal?.close()">Cancel</AppButton>
|
<AppButton button-style="secondary" @click="addProfileModal?.close()">Cancel</AppButton>
|
||||||
</template>
|
</template>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
</div>
|
</AppPage>
|
||||||
</template>
|
</template>
|
||||||
<style lang="css">
|
<style lang="css">
|
||||||
.profile-card {
|
.profile-card {
|
||||||
|
|
|
@ -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>
|
|
@ -1,8 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AccountApiClient, type Account } from '@/api/account'
|
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 { useProfileStore } from '@/stores/profile-store'
|
||||||
import { onMounted, ref, type Ref } from 'vue'
|
import { onMounted, ref, type Ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
|
|
||||||
const accounts: Ref<Account[]> = ref([])
|
const accounts: Ref<Account[]> = ref([])
|
||||||
|
@ -19,8 +23,8 @@ onMounted(async () => {
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="app-module">
|
<HomeModule title="Accounts">
|
||||||
<h2 class="app-module-header">Accounts</h2>
|
<template v-slot:default>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -32,13 +36,21 @@ onMounted(async () => {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="account in accounts" :key="account.id">
|
<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.currency }}</td>
|
||||||
<td>...{{ account.numberSuffix }}</td>
|
<td>...{{ account.numberSuffix }}</td>
|
||||||
<td>{{ account.type }}</td>
|
<td>{{ account.type }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</template>
|
||||||
<style lang="css"></style>
|
<style lang="css"></style>
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import { ProfileApiClient } from '@/api/profile';
|
import { ProfileApiClient } from '@/api/profile';
|
||||||
import AppButton from '@/components/AppButton.vue';
|
import AppButton from '@/components/AppButton.vue';
|
||||||
import ConfirmModal from '@/components/ConfirmModal.vue';
|
import ConfirmModal from '@/components/ConfirmModal.vue';
|
||||||
|
import HomeModule from '@/components/HomeModule.vue';
|
||||||
import { useProfileStore } from '@/stores/profile-store';
|
import { useProfileStore } from '@/stores/profile-store';
|
||||||
import { useTemplateRef } from 'vue';
|
import { useTemplateRef } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
@ -23,20 +24,18 @@ async function deleteProfile() {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="app-module" style="display: flex; flex-direction: column; justify-content: space-between;">
|
<HomeModule title="Profile">
|
||||||
<div>
|
<template v-slot:default>
|
||||||
<h2 class="app-module-header">Profile</h2>
|
|
||||||
<p>Your currently selected profile is: {{ profileStore.state?.name }}</p>
|
<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">
|
<ConfirmModal ref="confirmDeleteModal">
|
||||||
<p>Are you sure you want to delete this profile?</p>
|
<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>
|
<p>This will permanently remove all data associated with this profile, and this cannot be undone!</p>
|
||||||
</ConfirmModal>
|
</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>
|
</template>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Page, PageRequest } from '@/api/pagination';
|
import type { Page, PageRequest } from '@/api/pagination';
|
||||||
import { TransactionApiClient, type Transaction } from '@/api/transaction';
|
import { TransactionApiClient, type Transaction } from '@/api/transaction';
|
||||||
|
import HomeModule from '@/components/HomeModule.vue';
|
||||||
import PaginationControls from '@/components/PaginationControls.vue';
|
import PaginationControls from '@/components/PaginationControls.vue';
|
||||||
import { useProfileStore } from '@/stores/profile-store';
|
import { useProfileStore } from '@/stores/profile-store';
|
||||||
import { onMounted, ref, type Ref } from 'vue';
|
import { onMounted, ref, type Ref } from 'vue';
|
||||||
|
@ -23,8 +24,8 @@ async function fetchPage(pageRequest: PageRequest) {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="app-module">
|
<HomeModule title="Transactions">
|
||||||
<h2 class="app-module-header">Transactions</h2>
|
<template v-slot:default>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -45,6 +46,7 @@ async function fetchPage(pageRequest: PageRequest) {
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<PaginationControls :page="transactions" @update="pr => fetchPage(pr)" />
|
<PaginationControls :page="transactions" @update="pr => fetchPage(pr)"></PaginationControls>
|
||||||
</div>
|
</template>
|
||||||
|
</HomeModule>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -6,6 +6,8 @@ import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vu
|
||||||
import UserHomePage from '@/pages/UserHomePage.vue'
|
import UserHomePage from '@/pages/UserHomePage.vue'
|
||||||
import ProfilesPage from '@/pages/ProfilesPage.vue'
|
import ProfilesPage from '@/pages/ProfilesPage.vue'
|
||||||
import { useProfileStore } from '@/stores/profile-store'
|
import { useProfileStore } from '@/stores/profile-store'
|
||||||
|
import AccountPage from '@/pages/AccountPage.vue'
|
||||||
|
import EditAccountPage from '@/pages/forms/EditAccountPage.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
@ -32,10 +34,31 @@ const router = createRouter({
|
||||||
meta: { title: 'Profiles' },
|
meta: { title: 'Profiles' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/profiles/:name',
|
path: 'profiles/:name',
|
||||||
|
beforeEnter: profileSelected,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
component: async () => ProfilePage,
|
component: async () => ProfilePage,
|
||||||
meta: { title: (to: RouteLocationNormalized) => 'Profile ' + to.params.name },
|
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' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
export function showAlert(message: string): Promise<void> {
|
export function showAlert(message: string): Promise<void> {
|
||||||
const modal: HTMLDialogElement = document.getElementById(
|
getText().innerText = message
|
||||||
'global-alert-modal',
|
getOkButton().style.display = 'none'
|
||||||
) as HTMLDialogElement
|
getCancelButton().style.display = 'none'
|
||||||
const modalText: HTMLParagraphElement = document.getElementById(
|
getCloseButton().style.display = ''
|
||||||
'global-alert-modal-text',
|
|
||||||
) as HTMLParagraphElement
|
|
||||||
|
|
||||||
modalText.innerText = message
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
const modal = getModal()
|
||||||
modal.showModal()
|
modal.showModal()
|
||||||
modal.addEventListener(
|
modal.addEventListener(
|
||||||
'close',
|
'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
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue