Added confirm modal, ability to add profiles.
This commit is contained in:
parent
c0835383df
commit
4f5a46d187
|
@ -0,0 +1 @@
|
|||
handle SIG34 nostop noprint pass noignore
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue