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",
"handy-http-data": "1.3.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-transport": "1.7.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

View File

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

View File

@ -13,8 +13,15 @@ Optional!T toOptional(T)(Nullable!T value) {
Nullable!T toNullable(T)(Optional!T value) {
if (value.isNull) {
return Nullable!T.init;
return Nullable!(T)();
} else {
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) {
T[] items;
PageRequest pageRequest;
}
Page!U mapItems(T, U)(in Page!T page, U function(const(T)) fn) {
import std.algorithm : map;
import std.array : array;
return Page!U(
page.items.map!fn.array,
page.pageRequest
);
Page!U mapTo(U)(U function(T) fn) {
import std.algorithm : map;
import std.array : array;
return Page!(U)(this.items.map!(fn).array, this.pageRequest);
}
}

View File

@ -46,12 +46,12 @@ a:hover {
max-width: 600px;
margin-left: auto;
margin-right: auto;
padding: 0.5em;
padding-bottom: 1em;
padding: 0.5rem;
padding-bottom: 1rem;
background-color: var(--bg-page);
border-bottom-left-radius: 2em;
border-bottom-right-radius: 2em;
border-bottom-left-radius: 2rem;
border-bottom-right-radius: 2rem;
}
.app-page-title {
@ -64,7 +64,7 @@ a:hover {
display: flex;
flex-wrap: wrap;
gap: 20px;
padding: 1em;
padding: 1rem;
}
.app-module {
@ -72,10 +72,10 @@ a:hover {
min-height: 200px;
flex-grow: 1;
background-color: var(--bg-secondary);
border-radius: 0.5em;
padding: 0.5em;
border-radius: 0.5rem;
padding: 0.5rem;
}
.app-module > h2 {
.app-module-header {
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">
import { ref, type Ref } from 'vue';
import AppButton from './AppButton.vue';
const dialog: Ref<HTMLDialogElement | null> = ref(null)
function show() {
dialog.value?.showModal()
function show(): Promise<string | undefined> {
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>
<template>
<Teleport to="body">
@ -16,10 +26,9 @@ defineExpose({ show })
<slot></slot>
<div class="app-modal-dialog-actions">
<span @click="dialog?.close()" class="app-modal-dialog-close-button">
<font-awesome-icon icon="fa-xmark"></font-awesome-icon>
Close
</span>
<slot name="buttons">
<AppButton style="secondary" @click="close('close')">Close</AppButton>
</slot>
</div>
</dialog>
</Teleport>
@ -28,21 +37,19 @@ defineExpose({ show })
.app-modal-dialog {
background-color: var(--bg-secondary);
color: inherit;
border-radius: 1em;
border-radius: 1rem;
border-width: 0;
min-width: 300px;
max-width: 500px;
padding: 1rem;
}
.app-modal-dialog-header {
margin-top: 0;
}
.app-modal-dialog-actions {
text-align: right;
}
.app-modal-dialog-close-button {
cursor: pointer;
}
.app-modal-dialog-close-button:hover {
color: var(--theme-secondary);
margin-top: 0.5rem;
}
</style>

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ProfileApiClient, type Profile } from '@/api/profile';
import AppButton from '@/components/AppButton.vue';
import ModalWrapper from '@/components/ModalWrapper.vue';
import { useAuthStore } from '@/stores/auth-store';
import { useProfileStore } from '@/stores/profile-store';
@ -10,7 +11,8 @@ const profileStore = useProfileStore()
const router = useRouter()
const profiles: Ref<Profile[]> = ref([])
const testModal = useTemplateRef('testModal')
const addProfileModal = useTemplateRef('addProfileModal')
const newProfileName = ref('')
onMounted(async () => {
authStore.$subscribe(async (_, state) => {
@ -19,18 +21,34 @@ onMounted(async () => {
}
})
await fetchProfiles()
})
async function fetchProfiles() {
const client = new ProfileApiClient()
try {
profiles.value = (await client.getProfiles()).sort((a, b) => a.name.localeCompare(b.name))
} catch {
profiles.value = []
}
})
}
function selectProfile(profile: Profile) {
profileStore.onProfileSelected(profile)
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>
<template>
<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)">
<span>{{ profile.name }}</span>
</div>
<div class="profile-card" @click="testModal?.show()">
<div class="profile-card" @click="addProfileModal?.show()">
<span>Add a new profile...</span>
</div>
<ModalWrapper ref="testModal">
<p>This is my modal!</p>
<ModalWrapper ref="addProfileModal">
<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>
</div>
</template>

View File

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

View File

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

View File

@ -1,15 +1,42 @@
<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 { useTemplateRef } from 'vue';
import { useRouter } from 'vue-router';
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>
<template>
<div class="app-module">
<h2>Profile</h2>
<p>Your currently selected profile is: {{ profileStore.state?.name }}</p>
<RouterLink to="/profiles">
<font-awesome-icon icon="fa-folder-open"></font-awesome-icon>
Select a different profile
</RouterLink>
<div class="app-module" style="display: flex; flex-direction: column; justify-content: space-between;">
<div>
<h2 class="app-module-header">Profile</h2>
<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>

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>