Added confirm modal, ability to add profiles.

This commit is contained in:
Andrew Lalis 2025-08-03 10:46:01 -04:00
parent c0835383df
commit 4f5a46d187
15 changed files with 265 additions and 54 deletions

1
finnow-api/.gdbinit Normal file
View File

@ -0,0 +1 @@
handle SIG34 nostop noprint pass noignore

View File

@ -6,7 +6,7 @@
"dxml": "0.4.4", "dxml": "0.4.4",
"handy-http-data": "1.3.0", "handy-http-data": "1.3.0",
"handy-http-handlers": "1.1.0", "handy-http-handlers": "1.1.0",
"handy-http-primitives": "1.8.0", "handy-http-primitives": "1.8.1",
"handy-http-starter": "1.5.0", "handy-http-starter": "1.5.0",
"handy-http-transport": "1.7.0", "handy-http-transport": "1.7.0",
"handy-http-websockets": "1.2.0", "handy-http-websockets": "1.2.0",

View File

@ -1,4 +1,4 @@
-- This schema is included at compile-time into data : SqliteDataSource. -- This schema is included at compile-time into source/profile/data_impl_sqlite.d SqliteProfileDataSource
-- Basic/Utility Entities -- Basic/Utility Entities

View File

@ -3,6 +3,7 @@ module transaction.api;
import handy_http_primitives; import handy_http_primitives;
import handy_http_data.json; import handy_http_data.json;
import handy_http_handlers.path_handler; import handy_http_handlers.path_handler;
import slf4d;
import transaction.model; import transaction.model;
import transaction.data; import transaction.data;
@ -16,15 +17,18 @@ import util.data;
immutable DEFAULT_TRANSACTION_PAGE = PageRequest(0, 10, [Sort("created_at", SortDir.DESC)]); immutable DEFAULT_TRANSACTION_PAGE = PageRequest(0, 10, [Sort("created_at", SortDir.DESC)]);
struct TransactionResponse { struct TransactionResponse {
import std.typecons : Nullable, nullable; import asdf : serdeTransformOut;
ulong id; ulong id;
string timestamp; string timestamp;
string addedAt; string addedAt;
ulong amount; ulong amount;
string currency; string currency;
string description; string description;
Nullable!ulong vendorId; @serdeTransformOut!serializeOptional
Nullable!ulong categoryId; Optional!ulong vendorId;
@serdeTransformOut!serializeOptional
Optional!ulong categoryId;
static TransactionResponse of(in Transaction tx) { static TransactionResponse of(in Transaction tx) {
return TransactionResponse( return TransactionResponse(
@ -34,8 +38,8 @@ struct TransactionResponse {
tx.amount, tx.amount,
tx.currency.code.idup, tx.currency.code.idup,
tx.description, tx.description,
tx.vendorId.toNullable, tx.vendorId,
tx.categoryId.toNullable tx.categoryId
); );
} }
} }
@ -44,7 +48,8 @@ void getTransactions(ref ServerHttpRequest request, ref ServerHttpResponse respo
ProfileDataSource ds = getProfileDataSource(request); ProfileDataSource ds = getProfileDataSource(request);
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
Page!Transaction page = ds.getTransactionRepository().findAll(pr); Page!Transaction page = ds.getTransactionRepository().findAll(pr);
writeJsonBody(response, page.mapItems(&TransactionResponse.of)); Page!TransactionResponse responsePage = page.mapTo!()(&TransactionResponse.of);
writeJsonBody(response, responsePage);
} }
void getVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) { void getVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) {

View File

@ -13,8 +13,15 @@ Optional!T toOptional(T)(Nullable!T value) {
Nullable!T toNullable(T)(Optional!T value) { Nullable!T toNullable(T)(Optional!T value) {
if (value.isNull) { if (value.isNull) {
return Nullable!T.init; return Nullable!(T)();
} else { } else {
return Nullable!T(value.value); return Nullable!T(value.value);
} }
} }
auto serializeOptional(T)(Optional!T value) {
if (value.isNull) {
return Nullable!T();
}
return Nullable!T(value.value);
}

View File

@ -97,13 +97,10 @@ struct PageRequest {
struct Page(T) { struct Page(T) {
T[] items; T[] items;
PageRequest pageRequest; PageRequest pageRequest;
}
Page!U mapItems(T, U)(in Page!T page, U function(const(T)) fn) { Page!U mapTo(U)(U function(T) fn) {
import std.algorithm : map; import std.algorithm : map;
import std.array : array; import std.array : array;
return Page!U( return Page!(U)(this.items.map!(fn).array, this.pageRequest);
page.items.map!fn.array, }
page.pageRequest
);
} }

View File

@ -46,12 +46,12 @@ a:hover {
max-width: 600px; max-width: 600px;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
padding: 0.5em; padding: 0.5rem;
padding-bottom: 1em; padding-bottom: 1rem;
background-color: var(--bg-page); background-color: var(--bg-page);
border-bottom-left-radius: 2em; border-bottom-left-radius: 2rem;
border-bottom-right-radius: 2em; border-bottom-right-radius: 2rem;
} }
.app-page-title { .app-page-title {
@ -64,7 +64,7 @@ a:hover {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 20px; gap: 20px;
padding: 1em; padding: 1rem;
} }
.app-module { .app-module {
@ -72,10 +72,10 @@ a:hover {
min-height: 200px; min-height: 200px;
flex-grow: 1; flex-grow: 1;
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
border-radius: 0.5em; border-radius: 0.5rem;
padding: 0.5em; padding: 0.5rem;
} }
.app-module > h2 { .app-module-header {
margin: 0; margin: 0;
} }

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
defineProps<{ buttonStyle?: string, icon?: string }>()
defineEmits(['click'])
</script>
<template>
<button class="app-button" :class="{ 'app-button-secondary': buttonStyle === 'secondary' }" @click="$emit('click')">
<span v-if="icon">
<font-awesome-icon :icon="'fa-' + icon" style="margin-right: 0.5rem; margin-left: -0.5rem;"></font-awesome-icon>
</span>
<slot></slot>
</button>
</template>
<style lang="css">
.app-button {
background-color: #111827;
border: 1px solid transparent;
border-radius: .75rem;
box-sizing: border-box;
color: #FFFFFF;
cursor: pointer;
flex: 0 0 auto;
font-family: "OpenSans", sans-serif;
font-size: 1rem;
font-weight: 600;
line-height: 1rem;
padding: .75rem 1.5rem;
text-align: center;
text-decoration: none #6B7280 solid;
text-decoration-thickness: auto;
transition-duration: .2s;
transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(.4, 0, 0.2, 1);
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
width: auto;
margin: 0.25rem;
}
.app-button:hover {
background-color: #374151;
}
.app-button:focus {
box-shadow: none;
outline: 2px solid transparent;
outline-offset: 2px;
}
.app-button:active {
background-color: #3b4968;
}
@media (min-width: 768px) {
.app-button {
padding: .75rem 1.5rem;
}
}
.app-button-secondary {
background-color: #1d2330;
}
</style>

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import AppButton from './AppButton.vue';
import ModalWrapper from './ModalWrapper.vue';
const modal = useTemplateRef('modal')
async function confirm(): Promise<boolean> {
const result = await modal.value?.show()
return result === 'confirm'
}
defineExpose({ confirm })
</script>
<template>
<ModalWrapper ref="modal">
<template v-slot:default>
<h3 class="app-modal-dialog-header">Confirm</h3>
<slot>
<p>Are you sure you want to proceed?</p>
</slot>
</template>
<template v-slot:buttons>
<AppButton @click="modal?.close('confirm')">Ok</AppButton>
<AppButton button-style="secondary" @click="modal?.close()">Cancel</AppButton>
</template>
</ModalWrapper>
</template>

View File

@ -1,13 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, type Ref } from 'vue'; import { ref, type Ref } from 'vue';
import AppButton from './AppButton.vue';
const dialog: Ref<HTMLDialogElement | null> = ref(null) const dialog: Ref<HTMLDialogElement | null> = ref(null)
function show() { function show(): Promise<string | undefined> {
dialog.value?.showModal() return new Promise(resolve => {
dialog.value?.showModal()
dialog.value?.addEventListener('close', () => {
resolve(dialog.value?.returnValue)
}, { once: true })
})
} }
defineExpose({ show }) function close(returnValue?: string) {
dialog.value?.close(returnValue)
}
defineExpose({ show, close })
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
@ -16,10 +26,9 @@ defineExpose({ show })
<slot></slot> <slot></slot>
<div class="app-modal-dialog-actions"> <div class="app-modal-dialog-actions">
<span @click="dialog?.close()" class="app-modal-dialog-close-button"> <slot name="buttons">
<font-awesome-icon icon="fa-xmark"></font-awesome-icon> <AppButton style="secondary" @click="close('close')">Close</AppButton>
Close </slot>
</span>
</div> </div>
</dialog> </dialog>
</Teleport> </Teleport>
@ -28,21 +37,19 @@ defineExpose({ show })
.app-modal-dialog { .app-modal-dialog {
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
color: inherit; color: inherit;
border-radius: 1em; border-radius: 1rem;
border-width: 0; border-width: 0;
min-width: 300px; min-width: 300px;
max-width: 500px; max-width: 500px;
padding: 1rem;
}
.app-modal-dialog-header {
margin-top: 0;
} }
.app-modal-dialog-actions { .app-modal-dialog-actions {
text-align: right; text-align: right;
} margin-top: 0.5rem;
.app-modal-dialog-close-button {
cursor: pointer;
}
.app-modal-dialog-close-button:hover {
color: var(--theme-secondary);
} }
</style> </style>

View File

@ -1,5 +1,6 @@
<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 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';
@ -10,7 +11,8 @@ const profileStore = useProfileStore()
const router = useRouter() const router = useRouter()
const profiles: Ref<Profile[]> = ref([]) const profiles: Ref<Profile[]> = ref([])
const testModal = useTemplateRef('testModal') const addProfileModal = useTemplateRef('addProfileModal')
const newProfileName = ref('')
onMounted(async () => { onMounted(async () => {
authStore.$subscribe(async (_, state) => { authStore.$subscribe(async (_, state) => {
@ -19,18 +21,34 @@ onMounted(async () => {
} }
}) })
await fetchProfiles()
})
async function fetchProfiles() {
const client = new ProfileApiClient() const client = new ProfileApiClient()
try { try {
profiles.value = (await client.getProfiles()).sort((a, b) => a.name.localeCompare(b.name)) profiles.value = (await client.getProfiles()).sort((a, b) => a.name.localeCompare(b.name))
} catch { } catch {
profiles.value = [] profiles.value = []
} }
}) }
function selectProfile(profile: Profile) { function selectProfile(profile: Profile) {
profileStore.onProfileSelected(profile) profileStore.onProfileSelected(profile)
router.push('/') router.push('/')
} }
async function addProfile() {
try {
const api = new ProfileApiClient()
api.createProfile(newProfileName.value)
newProfileName.value = ''
addProfileModal.value?.close()
await fetchProfiles()
} catch (err) {
console.error(err)
}
}
</script> </script>
<template> <template>
<div class="app-page-container"> <div class="app-page-container">
@ -39,12 +57,22 @@ function selectProfile(profile: Profile) {
<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>
<div class="profile-card" @click="testModal?.show()"> <div class="profile-card" @click="addProfileModal?.show()">
<span>Add a new profile...</span> <span>Add a new profile...</span>
</div> </div>
<ModalWrapper ref="testModal"> <ModalWrapper ref="addProfileModal">
<p>This is my modal!</p> <template v-slot:default>
<h3 class="app-modal-dialog-header">Add Profile</h3>
<div>
<label for="new-profile-name-input">Enter the name of your new profile.</label>
<input type="text" minlength="3" id="new-profile-name-input" v-model="newProfileName" />
</div>
</template>
<template v-slot:buttons>
<AppButton @click="addProfile()">Add</AppButton>
<AppButton button-style="secondary" @click="addProfileModal?.close()">Cancel</AppButton>
</template>
</ModalWrapper> </ModalWrapper>
</div> </div>
</template> </template>

View File

@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import ProfileModule from './home/ProfileModule.vue'; import ProfileModule from './home/ProfileModule.vue';
import AccountsModule from './home/AccountsModule.vue'; import AccountsModule from './home/AccountsModule.vue';
import TransactionsModule from './home/TransactionsModule.vue';
</script> </script>
<template> <template>
<div class="app-module-container"> <div class="app-module-container">
<ProfileModule /> <ProfileModule />
<AccountsModule /> <AccountsModule />
<TransactionsModule />
</div> </div>
</template> </template>

View File

@ -20,7 +20,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="app-module"> <div class="app-module">
<h2>Accounts</h2> <h2 class="app-module-header">Accounts</h2>
<table> <table>
<thead> <thead>
<tr> <tr>

View File

@ -1,15 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import { ProfileApiClient } from '@/api/profile';
import AppButton from '@/components/AppButton.vue';
import ConfirmModal from '@/components/ConfirmModal.vue';
import { useProfileStore } from '@/stores/profile-store'; import { useProfileStore } from '@/stores/profile-store';
import { useTemplateRef } from 'vue';
import { useRouter } from 'vue-router';
const profileStore = useProfileStore() const profileStore = useProfileStore()
const router = useRouter()
const confirmDeleteModal = useTemplateRef('confirmDeleteModal')
async function deleteProfile() {
if (profileStore.state && await confirmDeleteModal.value?.confirm()) {
const api = new ProfileApiClient()
try {
await api.deleteProfile(profileStore.state.name)
await router.replace('/profiles')
} catch (err) {
console.error(err)
}
}
}
</script> </script>
<template> <template>
<div class="app-module"> <div class="app-module" style="display: flex; flex-direction: column; justify-content: space-between;">
<h2>Profile</h2> <div>
<p>Your currently selected profile is: {{ profileStore.state?.name }}</p> <h2 class="app-module-header">Profile</h2>
<RouterLink to="/profiles"> <p>Your currently selected profile is: {{ profileStore.state?.name }}</p>
<font-awesome-icon icon="fa-folder-open"></font-awesome-icon> </div>
Select a different profile
</RouterLink> <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> </div>
</template> </template>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import type { Page } from '@/api/pagination';
import { TransactionApiClient, type Transaction } from '@/api/transaction';
import { useProfileStore } from '@/stores/profile-store';
import { onMounted, ref, type Ref } from 'vue';
const profileStore = useProfileStore()
const transactions: Ref<Page<Transaction>> = ref({ items: [], pageRequest: { page: 0, size: 10, sorts: [] } })
onMounted(async () => {
if (!profileStore.state) return
const api = new TransactionApiClient(profileStore.state)
try {
transactions.value = await api.getTransactions(transactions.value.pageRequest)
} catch (err) {
console.error(err)
}
})
</script>
<template>
<div class="app-module">
<h2 class="app-module-header">Transactions</h2>
<table>
<thead>
<tr>
<th>Date</th>
<th>Amount</th>
<th>Currency</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions.items" :key="tx.id">
<td>
{{ new Date(tx.timestamp).toLocaleDateString() }}
</td>
<td>{{ tx.amount }}</td>
<td>{{ tx.currency }}</td>
<td>{{ tx.description }}</td>
</tr>
</tbody>
</table>
</div>
</template>