Reformatted all components, added VendorSelect.
Build and Deploy Web App / build-and-deploy (push) Failing after 11s Details

This commit is contained in:
andrewlalis 2025-09-20 11:31:03 -04:00
parent 943ce13be2
commit 100652d03a
53 changed files with 2176 additions and 1337 deletions

View File

@ -2,5 +2,6 @@
"$schema": "https://json.schemastore.org/prettierrc", "$schema": "https://json.schemastore.org/prettierrc",
"semi": false, "semi": false,
"singleQuote": true, "singleQuote": true,
"printWidth": 100 "printWidth": 100,
"singleAttributePerLine": true
} }

1277
web-app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,29 +16,30 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.0.0", "@fortawesome/fontawesome-svg-core": "^7.0.1",
"@fortawesome/free-regular-svg-icons": "^7.0.0", "@fortawesome/free-regular-svg-icons": "^7.0.1",
"@fortawesome/free-solid-svg-icons": "^7.0.0", "@fortawesome/free-solid-svg-icons": "^7.0.1",
"@fortawesome/vue-fontawesome": "^3.1.1", "@fortawesome/vue-fontawesome": "^3.1.2",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-router": "^4.5.1" "vue-router": "^4.5.1",
"vue3-select-component": "^0.12.1"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node22": "^22.0.2", "@tsconfig/node22": "^22.0.2",
"@types/node": "^22.16.5", "@types/node": "^22.18.6",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0", "@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.7.0", "@vue/tsconfig": "^0.7.0",
"eslint": "^9.31.0", "eslint": "^9.36.0",
"eslint-plugin-vue": "~10.3.0", "eslint-plugin-vue": "~10.3.0",
"jiti": "^2.4.2", "jiti": "^2.5.1",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"prettier": "3.6.2", "prettier": "3.6.2",
"typescript": "~5.8.0", "typescript": "~5.8.0",
"vite": "^7.0.6", "vite": "^7.1.6",
"vite-plugin-vue-devtools": "^8.0.0", "vite-plugin-vue-devtools": "^8.0.2",
"vue-tsc": "^3.0.4" "vue-tsc": "^3.0.7"
} }
} }

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import GlobalAlertModal from './components/GlobalAlertModal.vue'; import GlobalAlertModal from './components/GlobalAlertModal.vue'
import GlobalLoadingOverlay from './components/GlobalLoadingOverlay.vue'; import GlobalLoadingOverlay from './components/GlobalLoadingOverlay.vue'
</script> </script>
<template> <template>
<RouterView></RouterView> <RouterView></RouterView>

View File

@ -6,12 +6,12 @@ import { type Page, type PageRequest } from './pagination'
export interface TransactionVendor { export interface TransactionVendor {
id: number id: number
name: string name: string
description: string description: string | null
} }
export interface TransactionVendorPayload { export interface TransactionVendorPayload {
name: string name: string
description: string description: string | null
} }
export interface TransactionCategory { export interface TransactionCategory {

View File

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { AccountTypes, type Account } from '@/api/account'; import { AccountTypes, type Account } from '@/api/account'
import { formatMoney } from '@/api/data'; import { formatMoney } from '@/api/data'
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
import { computed } from 'vue'; import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router'
import AppBadge from './common/AppBadge.vue'; import AppBadge from './common/AppBadge.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -12,17 +12,19 @@ const route = useRoute()
const props = defineProps<{ account: Account }>() const props = defineProps<{ account: Account }>()
const accountType = computed(() => AccountTypes.of(props.account.type)) const accountType = computed(() => AccountTypes.of(props.account.type))
const isBalancePositive = computed(() => { const isBalancePositive = computed(() => {
return props.account.currentBalance !== null && ( return (
accountType.value.debitsPositive props.account.currentBalance !== null &&
(accountType.value.debitsPositive
? props.account.currentBalance > 0 ? props.account.currentBalance > 0
: props.account.currentBalance < 0 : props.account.currentBalance < 0)
) )
}) })
const isBalanceNegative = computed(() => { const isBalanceNegative = computed(() => {
return props.account.currentBalance !== null && ( return (
accountType.value.debitsPositive props.account.currentBalance !== null &&
(accountType.value.debitsPositive
? props.account.currentBalance < 0 ? props.account.currentBalance < 0
: props.account.currentBalance > 0 : props.account.currentBalance > 0)
) )
}) })
@ -32,18 +34,28 @@ function goToAccount() {
} }
</script> </script>
<template> <template>
<div class="account-card" @click="goToAccount()"> <div
class="account-card"
@click="goToAccount()"
>
<!-- A top row for the name on the left, and balance on the right. --> <!-- A top row for the name on the left, and balance on the right. -->
<div class="account-card-top-row"> <div class="account-card-top-row">
<div> <div>
<span class="font-bold" style="margin-right: 0.5rem">{{ account.name }}</span> <span
class="font-bold"
style="margin-right: 0.5rem"
>{{ account.name }}</span
>
<span class="font-mono font-size-xsmall">#{{ account.numberSuffix }}</span> <span class="font-mono font-size-xsmall">#{{ account.numberSuffix }}</span>
</div> </div>
<div class="font-mono font-size-small" :class="{ <div
'text-positive': isBalancePositive, class="font-mono font-size-small"
'text-negative': isBalanceNegative :class="{
}"> 'text-positive': isBalancePositive,
'text-negative': isBalanceNegative,
}"
>
<span v-if="account.currentBalance !== null"> <span v-if="account.currentBalance !== null">
{{ formatMoney(account.currentBalance, account.currency) }} {{ formatMoney(account.currentBalance, account.currency) }}
</span> </span>

View File

@ -1,15 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, useTemplateRef, type Ref } from 'vue'; import { ref, useTemplateRef, type Ref } from 'vue'
import AppForm from '@/components/common/form/AppForm.vue'; import AppForm from '@/components/common/form/AppForm.vue'
import FormControl from '@/components/common/form/FormControl.vue'; import FormControl from '@/components/common/form/FormControl.vue'
import FormGroup from '@/components/common/form/FormGroup.vue'; import FormGroup from '@/components/common/form/FormGroup.vue'
import ModalWrapper from '@/components/common/ModalWrapper.vue'; import ModalWrapper from '@/components/common/ModalWrapper.vue'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import { AccountApiClient, AccountValueRecordType, type Account, type AccountValueRecord, type AccountValueRecordCreationPayload } from '@/api/account'; import {
import { datetimeLocalToISO, getDatetimeLocalValueForNow } from '@/util/time'; AccountApiClient,
import FileSelector from '@/components/common/FileSelector.vue'; AccountValueRecordType,
import { useRoute } from 'vue-router'; type Account,
import { showConfirm } from '@/util/alert'; type AccountValueRecord,
type AccountValueRecordCreationPayload,
} from '@/api/account'
import { datetimeLocalToISO, getDatetimeLocalValueForNow } from '@/util/time'
import FileSelector from '@/components/common/FileSelector.vue'
import { useRoute } from 'vue-router'
import { showConfirm } from '@/util/alert'
const route = useRoute() const route = useRoute()
const props = defineProps<{ account: Account }>() const props = defineProps<{ account: Account }>()
@ -25,7 +31,8 @@ async function show(): Promise<AccountValueRecord | undefined> {
if (!modal.value) return Promise.resolve(undefined) if (!modal.value) return Promise.resolve(undefined)
timestamp.value = getDatetimeLocalValueForNow() timestamp.value = getDatetimeLocalValueForNow()
if (props.account.currentBalance !== null) { if (props.account.currentBalance !== null) {
amount.value = props.account.currentBalance / Math.pow(10, props.account.currency.fractionalDigits) amount.value =
props.account.currentBalance / Math.pow(10, props.account.currency.fractionalDigits)
} else { } else {
amount.value = 0 amount.value = 0
} }
@ -41,16 +48,22 @@ async function addValueRecord() {
const payload: AccountValueRecordCreationPayload = { const payload: AccountValueRecordCreationPayload = {
timestamp: datetimeLocalToISO(timestamp.value), timestamp: datetimeLocalToISO(timestamp.value),
type: AccountValueRecordType.BALANCE, type: AccountValueRecordType.BALANCE,
value: Math.round(amount.value * Math.pow(10, props.account.currency.fractionalDigits)) value: Math.round(amount.value * Math.pow(10, props.account.currency.fractionalDigits)),
} }
// Check and confirm with the user if the value they entered doesn't match the expected balance of the account. // Check and confirm with the user if the value they entered doesn't match the expected balance of the account.
if (props.account.currentBalance !== null && payload.value !== props.account.currentBalance) { if (props.account.currentBalance !== null && payload.value !== props.account.currentBalance) {
const result = await showConfirm("The balance you entered doesn't match the expected current balance for this account. Proceeding to add this value record will result in an incomplete account history and possible reconciliation errors. Are you sure you want to proceed?") const result = await showConfirm(
"The balance you entered doesn't match the expected current balance for this account. Proceeding to add this value record will result in an incomplete account history and possible reconciliation errors. Are you sure you want to proceed?",
)
if (!result) return if (!result) return
} }
const api = new AccountApiClient(route) const api = new AccountApiClient(route)
try { try {
savedValueRecord.value = await api.createValueRecord(props.account.id, payload, attachments.value) savedValueRecord.value = await api.createValueRecord(
props.account.id,
payload,
attachments.value,
)
modal.value?.close('saved') modal.value?.close('saved')
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -65,16 +78,25 @@ defineExpose({ show })
<template v-slot:default> <template v-slot:default>
<h2>Add Value Record</h2> <h2>Add Value Record</h2>
<p> <p>
Record the current value of this account, to act as a keyframe from Record the current value of this account, to act as a keyframe from which the account's
which the account's balance can be derived. balance can be derived.
</p> </p>
<AppForm> <AppForm>
<FormGroup> <FormGroup>
<FormControl label="Timestamp"> <FormControl label="Timestamp">
<input type="datetime-local" v-model="timestamp" step="1" style="min-width: 250px;" /> <input
type="datetime-local"
v-model="timestamp"
step="1"
style="min-width: 250px"
/>
</FormControl> </FormControl>
<FormControl label="Value"> <FormControl label="Value">
<input type="number" v-model="amount" step="0.01" /> <input
type="number"
v-model="amount"
step="0.01"
/>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
@ -85,7 +107,11 @@ defineExpose({ show })
</template> </template>
<template v-slot:buttons> <template v-slot:buttons>
<AppButton @click="addValueRecord()">Add</AppButton> <AppButton @click="addValueRecord()">Add</AppButton>
<AppButton button-style="secondary" @click="modal?.close()">Cancel</AppButton> <AppButton
button-style="secondary"
@click="modal?.close()"
>Cancel</AppButton
>
</template> </template>
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

@ -1,41 +1,73 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TransactionCategoryTree } from '@/api/transaction'; import type { TransactionCategoryTree } from '@/api/transaction'
import AppButton from './common/AppButton.vue'; import AppButton from './common/AppButton.vue'
import { computed, ref } from 'vue'; import { computed, ref } from 'vue'
const props = defineProps<{ const props = defineProps<{
category: TransactionCategoryTree category: TransactionCategoryTree
editable: boolean editable: boolean
}>() }>()
defineEmits<{ defineEmits<{
'edited': [number] edited: [number]
'deleted': [number] deleted: [number]
}>() }>()
const expanded = ref(false) const expanded = ref(false)
const canExpand = computed(() => props.category.children.length > 0) const canExpand = computed(() => props.category.children.length > 0)
</script> </script>
<template> <template>
<div class="category-display-item" :class="{ <div
'category-display-item-bg-1': category.depth % 2 === 0, class="category-display-item"
'category-display-item-bg-2': category.depth % 2 === 1 :class="{
}"> 'category-display-item-bg-1': category.depth % 2 === 0,
'category-display-item-bg-2': category.depth % 2 === 1,
}"
>
<div class="category-display-item-content"> <div class="category-display-item-content">
<div> <div>
<h4 class="category-display-item-title">{{ category.name }}</h4> <h4 class="category-display-item-title">{{ category.name }}</h4>
<p class="category-display-item-description">{{ category.description }}</p> <p class="category-display-item-description">{{ category.description }}</p>
</div> </div>
<div class="category-display-item-color-indicator" :style="{ 'background-color': '#' + category.color }"></div> <div
class="category-display-item-color-indicator"
:style="{ 'background-color': '#' + category.color }"
></div>
</div> </div>
<div v-if="editable" style="text-align: right;"> <div
<AppButton icon="chevron-down" v-if="canExpand && !expanded" @click="expanded = true" /> v-if="editable"
<AppButton icon="chevron-up" v-if="canExpand && expanded" @click="expanded = false" /> style="text-align: right"
<AppButton icon="wrench" @click="$emit('edited', category.id)" /> >
<AppButton icon="trash" @click="$emit('deleted', category.id)" /> <AppButton
icon="chevron-down"
v-if="canExpand && !expanded"
@click="expanded = true"
/>
<AppButton
icon="chevron-up"
v-if="canExpand && expanded"
@click="expanded = false"
/>
<AppButton
icon="wrench"
@click="$emit('edited', category.id)"
/>
<AppButton
icon="trash"
@click="$emit('deleted', category.id)"
/>
</div> </div>
<!-- Nested display item for each child: --> <!-- Nested display item for each child: -->
<div style="margin-left: 1rem;" v-if="canExpand && expanded"> <div
<CategoryDisplayItem v-for="child in category.children" :key="child.id" :category="child" :editable="editable" style="margin-left: 1rem"
@edited="c => $emit('edited', c)" @deleted="c => $emit('deleted', c)" /> v-if="canExpand && expanded"
>
<CategoryDisplayItem
v-for="child in category.children"
:key="child.id"
:category="child"
:editable="editable"
@edited="(c) => $emit('edited', c)"
@deleted="(c) => $emit('deleted', c)"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -1,17 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router'
import AppBadge from './common/AppBadge.vue'; import AppBadge from './common/AppBadge.vue'
interface CategoryInfo { interface CategoryInfo {
name: string name: string
color: string color: string
} }
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const props = defineProps<{ category: CategoryInfo, clickable?: boolean }>() const props = defineProps<{ category: CategoryInfo; clickable?: boolean }>()
function onClicked() { function onClicked() {
if (props.clickable) { if (props.clickable) {
@ -20,8 +19,14 @@ function onClicked() {
} }
</script> </script>
<template> <template>
<AppBadge @click="onClicked()" :style="{ 'cursor': clickable ? 'pointer' : 'inherit' }"> <AppBadge
<div class="category-label-color" :style="{ 'background-color': '#' + category.color }"></div> @click="onClicked()"
:style="{ cursor: clickable ? 'pointer' : 'inherit' }"
>
<div
class="category-label-color"
:style="{ 'background-color': '#' + category.color }"
></div>
{{ category.name }} {{ category.name }}
</AppBadge> </AppBadge>
</template> </template>

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
import { TransactionApiClient, type TransactionCategoryTree } from '@/api/transaction'; import { TransactionApiClient, type TransactionCategoryTree } from '@/api/transaction'
import { onMounted, ref, type Ref } from 'vue'; import { onMounted, ref, type Ref } from 'vue'
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
const model = defineModel<number | null>({ required: true }) const model = defineModel<number | null>({ required: true })
@ -12,8 +12,7 @@ const categories: Ref<TransactionCategoryTree[]> = ref([])
onMounted(() => { onMounted(() => {
const api = new TransactionApiClient(getSelectedProfile(route)) const api = new TransactionApiClient(getSelectedProfile(route))
api.getCategoriesFlattened() api.getCategoriesFlattened().then((c) => (categories.value = c))
.then(c => categories.value = c)
}) })
function getCategoryById(id: number): TransactionCategoryTree | null { function getCategoryById(id: number): TransactionCategoryTree | null {
@ -24,10 +23,23 @@ function getCategoryById(id: number): TransactionCategoryTree | null {
} }
</script> </script>
<template> <template>
<select v-model="model" @change="$emit('categorySelected', model === null ? null : getCategoryById(model))"> <select
<option v-for="category in categories" :key="category.id" :value="category.id"> v-model="model"
{{ "&nbsp;".repeat(4 * category.depth) + category.name }} @change="$emit('categorySelected', model === null ? null : getCategoryById(model))"
>
<option
v-for="category in categories"
:key="category.id"
:value="category.id"
>
{{ '&nbsp;'.repeat(4 * category.depth) + category.name }}
</option>
<option
v-if="required !== true"
:value="null"
:selected="model === null"
>
None
</option> </option>
<option v-if="required !== true" :value="null" :selected="model === null">None</option>
</select> </select>
</template> </template>

View File

@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { TransactionApiClient, type TransactionCategory } from '@/api/transaction'; import { TransactionApiClient, type TransactionCategory } from '@/api/transaction'
import ModalWrapper from '@/components/common/ModalWrapper.vue'; import ModalWrapper from '@/components/common/ModalWrapper.vue'
import { ref, useTemplateRef, type Ref } from 'vue'; import { ref, useTemplateRef, type Ref } from 'vue'
import AppForm from '@/components/common/form/AppForm.vue'; import AppForm from '@/components/common/form/AppForm.vue'
import FormGroup from '@/components/common/form/FormGroup.vue'; import FormGroup from '@/components/common/form/FormGroup.vue'
import FormControl from '@/components/common/form/FormControl.vue'; import FormControl from '@/components/common/form/FormControl.vue'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import CategorySelect from './CategorySelect.vue'; import CategorySelect from './CategorySelect.vue'
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router'
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
const route = useRoute() const route = useRoute()
const props = defineProps<{ const props = defineProps<{
@ -29,7 +29,7 @@ function show(): Promise<string | undefined> {
description.value = props.category?.description ?? '' description.value = props.category?.description ?? ''
if (props.category) { if (props.category) {
name.value = props.category.name name.value = props.category.name
description.value = props.category.description ?? "" description.value = props.category.description ?? ''
color.value = '#' + props.category.color color.value = '#' + props.category.color
parentId.value = props.category.parentId parentId.value = props.category.parentId
} else { } else {
@ -42,17 +42,19 @@ function show(): Promise<string | undefined> {
} }
function canSave() { function canSave() {
const inputValid = name.value.trim().length > 0 && const inputValid =
color.value.match('^#(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})$') name.value.trim().length > 0 && color.value.match('^#(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})$')
if (!inputValid) return false if (!inputValid) return false
if (props.category) { if (props.category) {
const prevDescription = props.category.description?.trim() ?? "" const prevDescription = props.category.description?.trim() ?? ''
const currentDescription = description.value.trim() const currentDescription = description.value.trim()
const descriptionChanged = prevDescription !== currentDescription const descriptionChanged = prevDescription !== currentDescription
return props.category.name.trim() !== name.value.trim() || return (
props.category.name.trim() !== name.value.trim() ||
descriptionChanged || descriptionChanged ||
props.category.color.trim().toLowerCase() !== color.value.trim().toLowerCase() || props.category.color.trim().toLowerCase() !== color.value.trim().toLowerCase() ||
props.category.parentId !== parentId.value props.category.parentId !== parentId.value
)
} }
return true return true
} }
@ -67,7 +69,7 @@ async function doSave() {
name: name.value.trim(), name: name.value.trim(),
description: desc, description: desc,
color: color.value.trim().substring(1), color: color.value.trim().substring(1),
parentId: parentId.value parentId: parentId.value,
} }
try { try {
let savedCategory = null let savedCategory = null
@ -93,12 +95,21 @@ defineExpose({ show })
<AppForm> <AppForm>
<FormGroup> <FormGroup>
<FormControl label="Name"> <FormControl label="Name">
<input type="text" v-model="name" /> <input
type="text"
v-model="name"
/>
</FormControl> </FormControl>
<FormControl label="Color"> <FormControl label="Color">
<input type="color" v-model="color" /> <input
type="color"
v-model="color"
/>
</FormControl> </FormControl>
<FormControl label="Description" style="min-width: 300px;"> <FormControl
label="Description"
style="min-width: 300px"
>
<textarea v-model="description"></textarea> <textarea v-model="description"></textarea>
</FormControl> </FormControl>
<FormControl label="Parent Category"> <FormControl label="Parent Category">
@ -108,8 +119,16 @@ defineExpose({ show })
</AppForm> </AppForm>
</template> </template>
<template v-slot:buttons> <template v-slot:buttons>
<AppButton :disabled="!canSave()" @click="doSave()">Save</AppButton> <AppButton
<AppButton button-style="secondary" @click="modal?.close()">Cancel</AppButton> :disabled="!canSave()"
@click="doSave()"
>Save</AppButton
>
<AppButton
button-style="secondary"
@click="modal?.close()"
>Cancel</AppButton
>
</template> </template>
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, useTemplateRef } from 'vue'; import { ref, useTemplateRef } from 'vue'
import AppForm from '@/components/common/form/AppForm.vue'; import AppForm from '@/components/common/form/AppForm.vue'
import FormControl from '@/components/common/form/FormControl.vue'; import FormControl from '@/components/common/form/FormControl.vue'
import FormGroup from '@/components/common/form/FormGroup.vue'; import FormGroup from '@/components/common/form/FormGroup.vue'
import ModalWrapper from '@/components/common/ModalWrapper.vue'; import ModalWrapper from '@/components/common/ModalWrapper.vue'
import { TransactionApiClient, type TransactionVendor } from '@/api/transaction'; import { TransactionApiClient, type TransactionVendor } from '@/api/transaction'
import AppButton from './common/AppButton.vue'; import AppButton from './common/AppButton.vue'
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router'
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
const route = useRoute() const route = useRoute()
const props = defineProps<{ const props = defineProps<{
@ -31,8 +31,10 @@ function canSave() {
const inputValid = name.value.trim().length > 0 const inputValid = name.value.trim().length > 0
if (!inputValid) return false if (!inputValid) return false
if (props.vendor) { if (props.vendor) {
return props.vendor.name.trim() !== name.value.trim() || return (
props.vendor.name.trim() !== name.value.trim() ||
props.vendor.description.trim() !== description.value.trim() props.vendor.description.trim() !== description.value.trim()
)
} }
return true return true
} }
@ -41,7 +43,7 @@ async function doSave() {
const api = new TransactionApiClient(getSelectedProfile(route)) const api = new TransactionApiClient(getSelectedProfile(route))
const payload = { const payload = {
name: name.value.trim(), name: name.value.trim(),
description: description.value.trim() description: description.value.trim(),
} }
try { try {
let savedVendor = null let savedVendor = null
@ -67,17 +69,31 @@ defineExpose({ show })
<AppForm> <AppForm>
<FormGroup> <FormGroup>
<FormControl label="Name"> <FormControl label="Name">
<input type="text" v-model="name" /> <input
type="text"
v-model="name"
/>
</FormControl> </FormControl>
<FormControl label="Description" style="min-width: 300px;"> <FormControl
label="Description"
style="min-width: 300px"
>
<textarea v-model="description"></textarea> <textarea v-model="description"></textarea>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
</AppForm> </AppForm>
</template> </template>
<template v-slot:buttons> <template v-slot:buttons>
<AppButton :disabled="!canSave()" @click="doSave()">Save</AppButton> <AppButton
<AppButton button-style="secondary" @click="modal?.close()">Cancel</AppButton> :disabled="!canSave()"
@click="doSave()"
>Save</AppButton
>
<AppButton
button-style="secondary"
@click="modal?.close()"
>Cancel</AppButton
>
</template> </template>
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

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

View File

@ -1,6 +1,5 @@
<!-- A globally-mounted loading overlay controlled by util/loader.ts. --> <!-- A globally-mounted loading overlay controlled by util/loader.ts. -->
<script setup lang="ts"> <script setup lang="ts"></script>
</script>
<template> <template>
<div class="loading-overlay"> <div class="loading-overlay">
<div class="loader-indicator"></div> <div class="loader-indicator"></div>

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ButtonBar from '@/components/common/ButtonBar.vue'; import ButtonBar from '@/components/common/ButtonBar.vue'
defineProps<{ title: string }>() defineProps<{ title: string }>()
</script> </script>

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TransactionDetailLineItem } from '@/api/transaction'; import type { TransactionDetailLineItem } from '@/api/transaction'
import AppButton from './common/AppButton.vue'; import AppButton from './common/AppButton.vue'
import { formatMoney, type Currency } from '@/api/data'; import { formatMoney, type Currency } from '@/api/data'
defineProps<{ defineProps<{
lineItem: TransactionDetailLineItem lineItem: TransactionDetailLineItem
@ -11,11 +11,10 @@ defineProps<{
}>() }>()
defineEmits<{ defineEmits<{
'deleted': void, deleted: void
'movedUp': void, movedUp: void
'movedDown': void movedDown: void
}>() }>()
</script> </script>
<template> <template>
<div class="line-item-card"> <div class="line-item-card">
@ -29,10 +28,24 @@ defineEmits<{
<span class="font-mono font-size-small"> <span class="font-mono font-size-small">
{{ formatMoney(lineItem.valuePerItem, currency) }} {{ formatMoney(lineItem.valuePerItem, currency) }}
</span> </span>
<AppButton icon="arrow-up" v-if="editable && lineItem.idx > 0" size="sm" @click="$emit('movedUp')" /> <AppButton
<AppButton icon="arrow-down" v-if="editable && totalCount !== undefined && lineItem.idx < totalCount - 1" icon="arrow-up"
size="sm" @click="$emit('movedDown')" /> v-if="editable && lineItem.idx > 0"
<AppButton icon="trash" size="sm" v-if="editable" @click="$emit('deleted')" /> size="sm"
@click="$emit('movedUp')"
/>
<AppButton
icon="arrow-down"
v-if="editable && totalCount !== undefined && lineItem.idx < totalCount - 1"
size="sm"
@click="$emit('movedDown')"
/>
<AppButton
icon="trash"
size="sm"
v-if="editable"
@click="$emit('deleted')"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -4,16 +4,16 @@ transaction. This editor shows a table of current line items, and includes a
modal for adding a new one. modal for adding a new one.
--> -->
<script setup lang="ts"> <script setup lang="ts">
import { type TransactionCategoryTree, type TransactionDetailLineItem } from '@/api/transaction'; import { type TransactionCategoryTree, type TransactionDetailLineItem } from '@/api/transaction'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import FormGroup from '@/components/common/form/FormGroup.vue'; import FormGroup from '@/components/common/form/FormGroup.vue'
import { floatMoneyToInteger, formatMoney, type Currency } from '@/api/data'; import { floatMoneyToInteger, formatMoney, type Currency } from '@/api/data'
import ModalWrapper from '@/components/common/ModalWrapper.vue'; import ModalWrapper from '@/components/common/ModalWrapper.vue'
import FormControl from '@/components/common/form/FormControl.vue'; import FormControl from '@/components/common/form/FormControl.vue'
import { computed, ref, type Ref, useTemplateRef } from 'vue'; import { computed, ref, type Ref, useTemplateRef } from 'vue'
import CategorySelect from './CategorySelect.vue'; import CategorySelect from './CategorySelect.vue'
import LineItemCard from './LineItemCard.vue'; import LineItemCard from './LineItemCard.vue'
import AppBadge from './common/AppBadge.vue'; import AppBadge from './common/AppBadge.vue'
const model = defineModel<TransactionDetailLineItem[]>({ required: true }) const model = defineModel<TransactionDetailLineItem[]>({ required: true })
const props = defineProps<{ const props = defineProps<{
@ -37,34 +37,31 @@ const selectedCategory: Ref<TransactionCategoryTree | null> = ref(null)
const addLineItemModal = useTemplateRef('addLineItemModal') const addLineItemModal = useTemplateRef('addLineItemModal')
function canAddLineItem() { function canAddLineItem() {
return addLineItemDescription.value.length > 0 && return addLineItemDescription.value.length > 0 && addLineItemQuantity.value > 0
addLineItemQuantity.value > 0
} }
function showAddLineItemModal() { function showAddLineItemModal() {
addLineItemDescription.value = '' addLineItemDescription.value = ''
addLineItemValuePerItem.value = 1.00 addLineItemValuePerItem.value = 1.0
addLineItemQuantity.value = 1 addLineItemQuantity.value = 1
addLineItemCategoryId.value = null addLineItemCategoryId.value = null
addLineItemModal.value?.show() addLineItemModal.value?.show()
} }
async function addLineItem() { async function addLineItem() {
const newIdx = model.value.length === 0 const newIdx = model.value.length === 0 ? 0 : Math.max(...model.value.map((i) => i.idx)) + 1
? 0
: Math.max(...model.value.map(i => i.idx)) + 1
model.value.push({ model.value.push({
idx: newIdx, idx: newIdx,
description: addLineItemDescription.value, description: addLineItemDescription.value,
quantity: addLineItemQuantity.value, quantity: addLineItemQuantity.value,
valuePerItem: floatMoneyToInteger(addLineItemValuePerItem.value, props.currency), valuePerItem: floatMoneyToInteger(addLineItemValuePerItem.value, props.currency),
category: selectedCategory.value category: selectedCategory.value,
}) })
addLineItemModal.value?.close() addLineItemModal.value?.close()
} }
function removeLineItem(idx: number) { function removeLineItem(idx: number) {
model.value = model.value.filter(i => i.idx !== idx) model.value = model.value.filter((i) => i.idx !== idx)
for (let i = 0; i < model.value.length; i++) { for (let i = 0; i < model.value.length; i++) {
model.value[i].idx = i model.value[i].idx = i
} }
@ -92,15 +89,30 @@ function moveItemDown(idx: number) {
</script> </script>
<template> <template>
<div> <div>
<LineItemCard v-for="item in model" :key="item.idx" :line-item="item" :currency="currency" <LineItemCard
:total-count="model.length" editable @deleted="removeLineItem(item.idx)" @moved-up="moveItemUp(item.idx)" v-for="item in model"
@moved-down="moveItemDown(item.idx)" /> :key="item.idx"
:line-item="item"
:currency="currency"
:total-count="model.length"
editable
@deleted="removeLineItem(item.idx)"
@moved-up="moveItemUp(item.idx)"
@moved-down="moveItemDown(item.idx)"
/>
<div> <div>
<AppButton icon="plus" @click="showAddLineItemModal()">Add Line Item</AppButton> <AppButton
<AppBadge v-if="model.length > 0" :class="{ icon="plus"
'text-positive': computedTotal === transactionAmount, @click="showAddLineItemModal()"
'text-negative': computedTotal !== transactionAmount >Add Line Item</AppButton
}"> >
<AppBadge
v-if="model.length > 0"
:class="{
'text-positive': computedTotal === transactionAmount,
'text-negative': computedTotal !== transactionAmount,
}"
>
Items Total: {{ formatMoney(computedTotal, currency) }} Items Total: {{ formatMoney(computedTotal, currency) }}
</AppBadge> </AppBadge>
</div> </div>
@ -114,19 +126,39 @@ function moveItemDown(idx: number) {
<textarea v-model="addLineItemDescription"></textarea> <textarea v-model="addLineItemDescription"></textarea>
</FormControl> </FormControl>
<FormControl label="Value Per Item"> <FormControl label="Value Per Item">
<input type="number" step="0.01" v-model="addLineItemValuePerItem" /> <input
type="number"
step="0.01"
v-model="addLineItemValuePerItem"
/>
</FormControl> </FormControl>
<FormControl label="Quantity"> <FormControl label="Quantity">
<input type="number" step="1" min="1" v-model="addLineItemQuantity" /> <input
type="number"
step="1"
min="1"
v-model="addLineItemQuantity"
/>
</FormControl> </FormControl>
<FormControl label="Category"> <FormControl label="Category">
<CategorySelect v-model="addLineItemCategoryId" @category-selected="c => selectedCategory = c" /> <CategorySelect
v-model="addLineItemCategoryId"
@category-selected="(c) => (selectedCategory = c)"
/>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
</template> </template>
<template v-slot:buttons> <template v-slot:buttons>
<AppButton @click="addLineItem()" :disabled="!canAddLineItem()">Add</AppButton> <AppButton
<AppButton button-style="secondary" @click="addLineItemModal?.close()">Cancel</AppButton> @click="addLineItem()"
:disabled="!canAddLineItem()"
>Add</AppButton
>
<AppButton
button-style="secondary"
@click="addLineItemModal?.close()"
>Cancel</AppButton
>
</template> </template>
</ModalWrapper> </ModalWrapper>
</div> </div>

View File

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts"></script>
</script>
<template> <template>
<table class="app-properties-table"> <table class="app-properties-table">
<tbody> <tbody>

View File

@ -1,15 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import AppBadge from './common/AppBadge.vue'; import AppBadge from './common/AppBadge.vue'
defineProps<{ tag: string, deletable?: boolean }>() defineProps<{ tag: string; deletable?: boolean }>()
defineEmits<{ deleted: void }>() defineEmits<{ deleted: void }>()
</script> </script>
<template> <template>
<AppBadge> <AppBadge>
<span class="tag-label-hashtag">#</span> <span class="tag-label-hashtag">#</span>
{{ tag }} {{ tag }}
<font-awesome-icon v-if="deletable" icon="fa-x" class="tag-label-delete" <font-awesome-icon
@click="$emit('deleted')"></font-awesome-icon> v-if="deletable"
icon="fa-x"
class="tag-label-delete"
@click="$emit('deleted')"
></font-awesome-icon>
</AppBadge> </AppBadge>
</template> </template>
<style lang="css"> <style lang="css">

View File

@ -1,26 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatMoney } from '@/api/data'; import { formatMoney } from '@/api/data'
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
import type { TransactionsListItem } from '@/api/transaction'; import type { TransactionsListItem } from '@/api/transaction'
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router'
import CategoryLabel from './CategoryLabel.vue'; import CategoryLabel from './CategoryLabel.vue'
import { computed, type Ref } from 'vue'; import { computed, type Ref } from 'vue'
import AppBadge from './common/AppBadge.vue'; import AppBadge from './common/AppBadge.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
type MoneyStyle = "positive" | "negative" | "neutral" type MoneyStyle = 'positive' | 'negative' | 'neutral'
const props = defineProps<{ tx: TransactionsListItem }>() const props = defineProps<{ tx: TransactionsListItem }>()
// Defines the style to use for money based on which accounts are involved. // Defines the style to use for money based on which accounts are involved.
const moneyStyle: Ref<MoneyStyle> = computed(() => { const moneyStyle: Ref<MoneyStyle> = computed(() => {
if (props.tx.debitedAccount !== null && props.tx.creditedAccount === null) { if (props.tx.debitedAccount !== null && props.tx.creditedAccount === null) {
return "positive" return 'positive'
} else if (props.tx.creditedAccount !== null && props.tx.debitedAccount === null) { } else if (props.tx.creditedAccount !== null && props.tx.debitedAccount === null) {
return "negative" return 'negative'
} }
return "neutral" return 'neutral'
}) })
function goToTransaction() { function goToTransaction() {
@ -29,7 +29,10 @@ function goToTransaction() {
} }
</script> </script>
<template> <template>
<div class="transaction-card" @click="goToTransaction()"> <div
class="transaction-card"
@click="goToTransaction()"
>
<!-- Top row contains timestamp and amount. --> <!-- Top row contains timestamp and amount. -->
<div class="transaction-card-top-row"> <div class="transaction-card-top-row">
<div> <div>
@ -39,16 +42,25 @@ function goToTransaction() {
</div> </div>
</div> </div>
<div> <div>
<div class="font-mono align-right font-size-small" :class="{ <div
'text-positive': moneyStyle === 'positive', class="font-mono align-right font-size-small"
'text-negative': moneyStyle === 'negative' :class="{
}"> 'text-positive': moneyStyle === 'positive',
'text-negative': moneyStyle === 'negative',
}"
>
{{ formatMoney(tx.amount, tx.currency) }} {{ formatMoney(tx.amount, tx.currency) }}
</div> </div>
<div v-if="tx.creditedAccount !== null" class="font-size-small text-muted"> <div
v-if="tx.creditedAccount !== null"
class="font-size-small text-muted"
>
Credited to <span class="text-normal font-bold">{{ tx.creditedAccount.name }}</span> Credited to <span class="text-normal font-bold">{{ tx.creditedAccount.name }}</span>
</div> </div>
<div v-if="tx.debitedAccount !== null" class="font-size-small text-muted"> <div
v-if="tx.debitedAccount !== null"
class="font-size-small text-muted"
>
Debited to <span class="text-normal font-bold">{{ tx.debitedAccount.name }}</span> Debited to <span class="text-normal font-bold">{{ tx.debitedAccount.name }}</span>
</div> </div>
</div> </div>
@ -61,7 +73,11 @@ function goToTransaction() {
<!-- Bottom row contains other links. --> <!-- Bottom row contains other links. -->
<div> <div>
<CategoryLabel :category="tx.category" v-if="tx.category" style="margin-left: 0" /> <CategoryLabel
:category="tx.category"
v-if="tx.category"
style="margin-left: 0"
/>
<AppBadge v-if="tx.vendor">{{ tx.vendor.name }}</AppBadge> <AppBadge v-if="tx.vendor">{{ tx.vendor.name }}</AppBadge>
</div> </div>
</div> </div>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import { getSelectedProfile } from '@/api/profile'
import { TransactionApiClient, type TransactionVendor } from '@/api/transaction'
import { onMounted, ref, watch, type Ref } from 'vue'
import { useRoute } from 'vue-router'
import VueSelect, { type Option } from 'vue3-select-component'
const model = defineModel<TransactionVendor | null>({ required: true })
const route = useRoute()
const options: Ref<Option<string>[]> = ref([])
const existingVendors: Ref<TransactionVendor[]> = ref([])
const selectedVendorName: Ref<string | null> = ref(null)
watch(model, (newValue) => {
if (newValue === null) {
selectedVendorName.value = null
} else {
selectedVendorName.value = newValue.name
}
})
onMounted(() => {
const api = new TransactionApiClient(getSelectedProfile(route))
if (model.value !== null) {
selectedVendorName.value = model.value?.name
}
api.getVendors().then((vendors) => {
existingVendors.value = vendors
options.value = vendors.map((v) => {
return { label: v.name, value: v.name }
})
})
})
function onOptionCreated(value: string) {
const existingVendor = existingVendors.value.find((obj) => obj.name === value)
if (existingVendor) {
selectedVendorName.value = existingVendor.name
model.value = existingVendor
return
}
// Clear out any previously-added custom value from the list of options.
options.value = options.value.filter((opt) =>
existingVendors.value.some((v) => v.name === opt.value),
)
// Then add the new custom value, select it, and set the model.
options.value.push({ label: value, value: value })
selectedVendorName.value = value
model.value = { id: -1, name: value, description: null }
}
function onOptionSelected() {
model.value = existingVendors.value.find((v) => v.name === selectedVendorName.value) ?? null
}
function onOptionDeselected() {
model.value = null
}
</script>
<template>
<VueSelect
class="vendor-select"
v-model="selectedVendorName"
:options="options"
placeholder="Select a vendor"
is-taggable
@option-created="onOptionCreated"
@option-selected="onOptionSelected"
@option-deselected="onOptionDeselected"
>
<template #taggable-no-options> Add a new vendor. </template>
</VueSelect>
</template>
<style lang="css">
.vendor-select {
--vs-line-height: 1;
}
</style>

View File

@ -1,14 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
type BadgeSize = "sm" | "md" | "lg"; type BadgeSize = 'sm' | 'md' | 'lg'
type BadgeStyle = "normal" | "positive" | "warning" | "negative" type BadgeStyle = 'normal' | 'positive' | 'warning' | 'negative'
defineProps<{ size?: BadgeSize, color?: BadgeStyle }>() defineProps<{ size?: BadgeSize; color?: BadgeStyle }>()
</script> </script>
<template> <template>
<span class="app-badge" :class="{ <span
'app-badge-sm': size === 'sm', class="app-badge"
'app-badge-lg': size === 'lg' :class="{
}"> 'app-badge-sm': size === 'sm',
'app-badge-lg': size === 'lg',
}"
>
<slot></slot> <slot></slot>
</span> </span>
</template> </template>
@ -22,7 +25,7 @@ defineProps<{ size?: BadgeSize, color?: BadgeStyle }>()
display: inline-block; display: inline-block;
} }
.app-badge+.app-badge { .app-badge + .app-badge {
margin-left: 0.25rem; margin-left: 0.25rem;
} }

View File

@ -1,38 +1,48 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue'
export type ButtonTheme = 'primary' | 'secondary'
export type ButtonTheme = "primary" | "secondary" export type ButtonType = 'button' | 'submit' | 'reset'
export type ButtonType = "button" | "submit" | "reset" export type ButtonSize = 'sm' | 'md' | 'lg'
export type ButtonSize = "sm" | "md" | "lg"
interface Props { interface Props {
theme?: ButtonTheme, theme?: ButtonTheme
size?: ButtonSize, size?: ButtonSize
icon?: string, icon?: string
type?: ButtonType, type?: ButtonType
disabled?: boolean disabled?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
buttonStyle: "primary", buttonStyle: 'primary',
buttonType: "button", buttonType: 'button',
size: "md", size: 'md',
disabled: false disabled: false,
}) })
defineEmits(['click']) defineEmits(['click'])
const buttonStyle = computed(() => ({ const buttonStyle = computed(() => ({
'app-button-theme-secondary': props.theme === "secondary", 'app-button-theme-secondary': props.theme === 'secondary',
'app-button-disabled': props.disabled, 'app-button-disabled': props.disabled,
'app-button-size-sm': props.size === "sm", 'app-button-size-sm': props.size === 'sm',
'app-button-size-lg': props.size === "lg" 'app-button-size-lg': props.size === 'lg',
})) }))
</script> </script>
<template> <template>
<button class="app-button" :class="buttonStyle" :type="type ?? 'button'" :disabled="disabled" @click="$emit('click')"> <button
class="app-button"
:class="buttonStyle"
:type="type ?? 'button'"
:disabled="disabled"
@click="$emit('click')"
>
<span v-if="icon"> <span v-if="icon">
<font-awesome-icon :icon="'fa-' + icon" <font-awesome-icon
:class="{ 'app-button-icon-with-text': $slots.default !== undefined, 'app-button-icon-without-text': $slots.default === undefined }"></font-awesome-icon> :icon="'fa-' + icon"
:class="{
'app-button-icon-with-text': $slots.default !== undefined,
'app-button-icon-without-text': $slots.default === undefined,
}"
></font-awesome-icon>
</span> </span>
<slot></slot> <slot></slot>
</button> </button>
@ -41,22 +51,22 @@ const buttonStyle = computed(() => ({
.app-button { .app-button {
background-color: #111827; background-color: #111827;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: .75rem; border-radius: 0.75rem;
box-sizing: border-box; box-sizing: border-box;
color: #FFFFFF; color: #ffffff;
cursor: pointer; cursor: pointer;
flex: 0 0 auto; flex: 0 0 auto;
font-family: "OpenSans", sans-serif; font-family: 'OpenSans', sans-serif;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
line-height: 1rem; line-height: 1rem;
padding: .75rem 1.5rem; padding: 0.75rem 1.5rem;
text-align: center; text-align: center;
text-decoration: none #6B7280 solid; text-decoration: none #6b7280 solid;
text-decoration-thickness: auto; text-decoration-thickness: auto;
transition-duration: .2s; transition-duration: 0.2s;
transition-property: background-color, border-color, color, fill, stroke; transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
touch-action: manipulation; touch-action: manipulation;

View File

@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { AttachmentApiClient } from '@/api/attachment'; import { AttachmentApiClient } from '@/api/attachment'
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store'
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router'
import AppButton from './AppButton.vue'; import AppButton from './AppButton.vue'
interface AttachmentInfo { interface AttachmentInfo {
filename: string filename: string
@ -14,8 +13,8 @@ interface AttachmentInfo {
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const props = defineProps<{ attachment: AttachmentInfo, disabled?: boolean }>() const props = defineProps<{ attachment: AttachmentInfo; disabled?: boolean }>()
defineEmits<{ "deleted": void }>() defineEmits<{ deleted: void }>()
function downloadFile() { function downloadFile() {
const api = new AttachmentApiClient(route) const api = new AttachmentApiClient(route)
@ -25,15 +24,25 @@ function downloadFile() {
</script> </script>
<template> <template>
<div class="attachment-row"> <div class="attachment-row">
<div style="display: flex; align-items: center; margin-left: 1rem;"> <div style="display: flex; align-items: center; margin-left: 1rem">
<div> <div>
<div>{{ attachment.filename }}</div> <div>{{ attachment.filename }}</div>
<div style="font-size: 0.75rem;">{{ attachment.contentType }}</div> <div style="font-size: 0.75rem">{{ attachment.contentType }}</div>
</div> </div>
</div> </div>
<div> <div>
<AppButton icon="download" type="button" @click="downloadFile()" v-if="attachment.id !== undefined" /> <AppButton
<AppButton v-if="!disabled" type="button" icon="trash" @click="$emit('deleted')" /> icon="download"
type="button"
@click="downloadFile()"
v-if="attachment.id !== undefined"
/>
<AppButton
v-if="!disabled"
type="button"
icon="trash"
@click="$emit('deleted')"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts"></script>
</script>
<template> <template>
<div class="button-bar"> <div class="button-bar">
<slot></slot> <slot></slot>

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateRef } from 'vue'; import { useTemplateRef } from 'vue'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import ModalWrapper from '@/components/common/ModalWrapper.vue'; import ModalWrapper from '@/components/common/ModalWrapper.vue'
const modal = useTemplateRef('modal') const modal = useTemplateRef('modal')
@ -22,7 +22,11 @@ defineExpose({ confirm })
</template> </template>
<template v-slot:buttons> <template v-slot:buttons>
<AppButton @click="modal?.close('confirm')">Ok</AppButton> <AppButton @click="modal?.close('confirm')">Ok</AppButton>
<AppButton button-style="secondary" @click="modal?.close()">Cancel</AppButton> <AppButton
button-style="secondary"
@click="modal?.close()"
>Cancel</AppButton
>
</template> </template>
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue'; import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import AttachmentRow from './AttachmentRow.vue'; import AttachmentRow from './AttachmentRow.vue'
interface ExistingFile { interface ExistingFile {
id: number id: number
@ -14,8 +14,8 @@ abstract class FileListItem {
constructor( constructor(
public readonly filename: string, public readonly filename: string,
public readonly contentType: string, public readonly contentType: string,
public readonly size: number public readonly size: number,
) { } ) {}
} }
class ExistingFileListItem extends FileListItem { class ExistingFileListItem extends FileListItem {
@ -35,13 +35,13 @@ class NewFileListItem extends FileListItem {
} }
interface Props { interface Props {
disabled?: boolean, disabled?: boolean
initialFiles?: ExistingFile[] initialFiles?: ExistingFile[]
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
disabled: false, disabled: false,
initialFiles: () => [] initialFiles: () => [],
}) })
const previousInitialFiles: Ref<ExistingFile[]> = ref([]) const previousInitialFiles: Ref<ExistingFile[]> = ref([])
const fileInput = useTemplateRef('fileInput') const fileInput = useTemplateRef('fileInput')
@ -53,27 +53,36 @@ const removedFiles = defineModel<number[]>('removed-files', { default: [] })
const files: Ref<FileListItem[]> = ref([]) const files: Ref<FileListItem[]> = ref([])
onMounted(() => { onMounted(() => {
files.value = props.initialFiles.map(f => new ExistingFileListItem(f)) files.value = props.initialFiles.map((f) => new ExistingFileListItem(f))
previousInitialFiles.value = [...props.initialFiles] previousInitialFiles.value = [...props.initialFiles]
// If input initial files change, reset the file selector to just those. // If input initial files change, reset the file selector to just those.
watch(() => props.initialFiles, () => { watch(
if (previousInitialFiles.value !== props.initialFiles) { () => props.initialFiles,
files.value = props.initialFiles.map(f => new ExistingFileListItem(f)) () => {
previousInitialFiles.value = [...props.initialFiles] if (previousInitialFiles.value !== props.initialFiles) {
} files.value = props.initialFiles.map((f) => new ExistingFileListItem(f))
}) previousInitialFiles.value = [...props.initialFiles]
}
},
)
// When our internal model changes, update the defined uploaded/removed files models. // When our internal model changes, update the defined uploaded/removed files models.
watch(() => files, () => { watch(
// Compute the set of uploaded files as just any newly uploaded file list item. () => files,
uploadedFiles.value = files.value.filter(f => f instanceof NewFileListItem).map(f => f.file) () => {
// Compute the set of removed files as those from the set of initial files whose ID is no longer present. // Compute the set of uploaded files as just any newly uploaded file list item.
const retainedExistingFileIds = files.value uploadedFiles.value = files.value
.filter(f => f instanceof ExistingFileListItem) .filter((f) => f instanceof NewFileListItem)
.map(f => f.id) .map((f) => f.file)
removedFiles.value = props.initialFiles // Compute the set of removed files as those from the set of initial files whose ID is no longer present.
.filter(f => !retainedExistingFileIds.includes(f.id)) const retainedExistingFileIds = files.value
.map(f => f.id) .filter((f) => f instanceof ExistingFileListItem)
}, { deep: true }) .map((f) => f.id)
removedFiles.value = props.initialFiles
.filter((f) => !retainedExistingFileIds.includes(f.id))
.map((f) => f.id)
},
{ deep: true },
)
}) })
function onFileInputChanged(e: Event) { function onFileInputChanged(e: Event) {
@ -105,14 +114,32 @@ function onFileDeleteClicked(idx: number) {
<template> <template>
<div class="file-selector"> <div class="file-selector">
<div @click.prevent=""> <div @click.prevent="">
<AttachmentRow v-for="file, idx in files" :key="idx" :attachment="file" @deleted="onFileDeleteClicked(idx)" /> <AttachmentRow
v-for="(file, idx) in files"
:key="idx"
:attachment="file"
@deleted="onFileDeleteClicked(idx)"
/>
</div> </div>
<div> <div>
<input id="fileInput" type="file" capture="environment" multiple @change="onFileInputChanged" <input
style="display: none;" ref="fileInput" :disabled="disabled" /> id="fileInput"
type="file"
capture="environment"
multiple
@change="onFileInputChanged"
style="display: none"
ref="fileInput"
:disabled="disabled"
/>
<label for="fileInput"> <label for="fileInput">
<AppButton icon="upload" type="button" @click="fileInput?.click()" :disabled="disabled">Select a File <AppButton
icon="upload"
type="button"
@click="fileInput?.click()"
:disabled="disabled"
>Select a File
</AppButton> </AppButton>
</label> </label>
</div> </div>

View File

@ -1,17 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, type Ref } from 'vue'; import { ref, type Ref } from 'vue'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
defineProps<{ id?: string }>() defineProps<{ id?: string }>()
const dialog: Ref<HTMLDialogElement | null> = ref(null) const dialog: Ref<HTMLDialogElement | null> = ref(null)
function show(): Promise<string | undefined> { function show(): Promise<string | undefined> {
return new Promise(resolve => { return new Promise((resolve) => {
dialog.value?.showModal() dialog.value?.showModal()
dialog.value?.addEventListener('close', () => { dialog.value?.addEventListener(
resolve(dialog.value?.returnValue) 'close',
}, { once: true }) () => {
resolve(dialog.value?.returnValue)
},
{ once: true },
)
}) })
} }
@ -23,13 +27,20 @@ defineExpose({ show, close })
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<dialog ref="dialog" class="app-modal-dialog" :id="id"> <dialog
ref="dialog"
class="app-modal-dialog"
:id="id"
>
<slot></slot> <slot></slot>
<div class="app-modal-dialog-actions"> <div class="app-modal-dialog-actions">
<slot name="buttons"> <slot name="buttons">
<AppButton button-style="secondary" @click="close()">Close</AppButton> <AppButton
button-style="secondary"
@click="close()"
>Close</AppButton
>
</slot> </slot>
</div> </div>
</dialog> </dialog>

View File

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Page, PageRequest } from '@/api/pagination'; import type { Page, PageRequest } from '@/api/pagination'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
const props = defineProps<{ page?: Page<unknown> }>() const props = defineProps<{ page?: Page<unknown> }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -13,7 +12,7 @@ function updatePage(newPage: number) {
emit('update', { emit('update', {
page: newPage, page: newPage,
size: props.page.pageRequest.size, size: props.page.pageRequest.size,
sorts: props.page.pageRequest.sorts sorts: props.page.pageRequest.sorts,
}) })
} }
@ -22,28 +21,44 @@ function incrementPage(step: number) {
emit('update', { emit('update', {
page: props.page.pageRequest.page + step, page: props.page.pageRequest.page + step,
size: props.page.pageRequest.size, size: props.page.pageRequest.size,
sorts: props.page.pageRequest.sorts sorts: props.page.pageRequest.sorts,
}) })
} }
</script> </script>
<template> <template>
<div> <div>
<div v-if="page && page.totalElements > 0"> <div v-if="page && page.totalElements > 0">
<AppButton size="sm" :disabled="!page || page.isFirst" @click="updatePage(1)"> <AppButton
size="sm"
:disabled="!page || page.isFirst"
@click="updatePage(1)"
>
First Page First Page
</AppButton> </AppButton>
<AppButton size="sm" :disabled="!page || page.isFirst" @click="incrementPage(-1)"> <AppButton
size="sm"
:disabled="!page || page.isFirst"
@click="incrementPage(-1)"
>
Previous Page Previous Page
</AppButton> </AppButton>
<span>Page {{ page?.pageRequest.page }} / {{ page?.totalPages }}</span> <span>Page {{ page?.pageRequest.page }} / {{ page?.totalPages }}</span>
<AppButton size="sm" :disabled="!page || page.isLast" @click="incrementPage(1)"> <AppButton
size="sm"
:disabled="!page || page.isLast"
@click="incrementPage(1)"
>
Next Page Next Page
</AppButton> </AppButton>
<AppButton size="sm" :disabled="!page || page.isLast" @click="updatePage(page?.totalPages ?? 0)"> <AppButton
size="sm"
:disabled="!page || page.isLast"
@click="updatePage(page?.totalPages ?? 0)"
>
Last Page Last Page
</AppButton> </AppButton>
</div> </div>

View File

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

View File

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

View File

@ -17,7 +17,7 @@ defineProps<{
margin: 0.5rem; margin: 0.5rem;
} }
.app-form-control>label { .app-form-control > label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-size: 0.9rem; font-size: 0.9rem;
@ -26,19 +26,19 @@ defineProps<{
/* Styles for different form controls under here: */ /* Styles for different form controls under here: */
.app-form-control>label>input { .app-form-control > label > input {
font-size: 16px; font-size: 16px;
font-family: 'OpenSans', sans-serif; font-family: 'OpenSans', sans-serif;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
} }
.app-form-control>label>textarea { .app-form-control > label > textarea {
font-size: 16px; font-size: 16px;
font-family: 'OpenSans', sans-serif; font-family: 'OpenSans', sans-serif;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
} }
.app-form-control>label>select { .app-form-control > label > select {
font-size: 16px; font-size: 16px;
font-family: 'OpenSans', sans-serif; font-family: 'OpenSans', sans-serif;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;

View File

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts"></script>
</script>
<template> <template>
<div class="app-form-group"> <div class="app-form-group">
<slot></slot> <slot></slot>

View File

@ -1,21 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { AccountApiClient, AccountHistoryItemType, type Account, type AccountHistoryItem, type AccountHistoryJournalEntryItem, type AccountHistoryValueRecordItem } from '@/api/account'; import {
import type { PageRequest } from '@/api/pagination'; AccountApiClient,
import { onMounted, ref, type Ref } from 'vue'; AccountHistoryItemType,
import ValueRecordHistoryItem from './ValueRecordHistoryItem.vue'; type Account,
import JournalEntryHistoryItem from './JournalEntryHistoryItem.vue'; type AccountHistoryItem,
import { useRoute, useRouter } from 'vue-router'; type AccountHistoryJournalEntryItem,
import AppButton from '../common/AppButton.vue'; type AccountHistoryValueRecordItem,
import AppBadge from '../common/AppBadge.vue'; } from '@/api/account'
import HistoryItemDivider from './HistoryItemDivider.vue'; import type { PageRequest } from '@/api/pagination'
import { getSelectedProfile } from '@/api/profile'; import { onMounted, ref, type Ref } from 'vue'
import ValueRecordHistoryItem from './ValueRecordHistoryItem.vue'
import JournalEntryHistoryItem from './JournalEntryHistoryItem.vue'
import { useRoute, useRouter } from 'vue-router'
import AppButton from '../common/AppButton.vue'
import AppBadge from '../common/AppBadge.vue'
import HistoryItemDivider from './HistoryItemDivider.vue'
import { getSelectedProfile } from '@/api/profile'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const props = defineProps<{ account: Account }>() const props = defineProps<{ account: Account }>()
const historyItems: Ref<AccountHistoryItem[]> = ref([]) const historyItems: Ref<AccountHistoryItem[]> = ref([])
const canLoadMore = ref(true) const canLoadMore = ref(true)
const nextPage: Ref<PageRequest> = ref({ page: 1, size: 10, sorts: [{ attribute: 'timestamp', dir: 'DESC' }] }) const nextPage: Ref<PageRequest> = ref({
page: 1,
size: 10,
sorts: [{ attribute: 'timestamp', dir: 'DESC' }],
})
onMounted(async () => { onMounted(async () => {
await loadNextPage() await loadNextPage()
@ -60,14 +71,18 @@ function asJE(i: AccountHistoryItem): AccountHistoryJournalEntryItem {
} }
function canView(item: AccountHistoryItem) { function canView(item: AccountHistoryItem) {
return item.type === AccountHistoryItemType.JOURNAL_ENTRY || return (
item.type === AccountHistoryItemType.JOURNAL_ENTRY ||
item.type === AccountHistoryItemType.VALUE_RECORD item.type === AccountHistoryItemType.VALUE_RECORD
)
} }
function viewItem(item: AccountHistoryItem) { function viewItem(item: AccountHistoryItem) {
const profile = getSelectedProfile(route) const profile = getSelectedProfile(route)
if (item.type === AccountHistoryItemType.VALUE_RECORD) { if (item.type === AccountHistoryItemType.VALUE_RECORD) {
router.push(`/profiles/${profile}/accounts/${props.account.id}/value-records/${asVR(item).valueRecordId}`) router.push(
`/profiles/${profile}/accounts/${props.account.id}/value-records/${asVR(item).valueRecordId}`,
)
} else if (item.type === AccountHistoryItemType.JOURNAL_ENTRY) { } else if (item.type === AccountHistoryItemType.JOURNAL_ENTRY) {
router.push(`/profiles/${getSelectedProfile(route)}/transactions/${asJE(item).transactionId}`) router.push(`/profiles/${getSelectedProfile(route)}/transactions/${asJE(item).transactionId}`)
} }
@ -77,30 +92,59 @@ defineExpose({ reload })
</script> </script>
<template> <template>
<div> <div>
<div v-for="item, idx in historyItems" :key="idx"> <div
v-for="(item, idx) in historyItems"
:key="idx"
>
<div class="history-item"> <div class="history-item">
<!-- The main body on the left. --> <!-- The main body on the left. -->
<div style="flex-grow: 1;"> <div style="flex-grow: 1">
<div class="font-mono font-size-xsmall text-muted" style="margin-bottom: 0.25rem;"> <div
class="font-mono font-size-xsmall text-muted"
style="margin-bottom: 0.25rem"
>
{{ new Date(item.timestamp).toLocaleString() }} {{ new Date(item.timestamp).toLocaleString() }}
</div> </div>
<ValueRecordHistoryItem v-if="item.type === AccountHistoryItemType.VALUE_RECORD" :item="asVR(item)" <ValueRecordHistoryItem
:account-id="account.id" /> v-if="item.type === AccountHistoryItemType.VALUE_RECORD"
:item="asVR(item)"
:account-id="account.id"
/>
<JournalEntryHistoryItem v-if="item.type === AccountHistoryItemType.JOURNAL_ENTRY" :item="asJE(item)" /> <JournalEntryHistoryItem
v-if="item.type === AccountHistoryItemType.JOURNAL_ENTRY"
:item="asJE(item)"
/>
</div> </div>
<!-- A "view item" button on the right. --> <!-- A "view item" button on the right. -->
<div> <div>
<AppButton icon="eye" size="sm" @click="viewItem(item)" v-if="canView(item)">View</AppButton> <AppButton
icon="eye"
size="sm"
@click="viewItem(item)"
v-if="canView(item)"
>View</AppButton
>
</div> </div>
</div> </div>
<HistoryItemDivider v-if="idx + 1 < historyItems.length" /> <HistoryItemDivider v-if="idx + 1 < historyItems.length" />
</div> </div>
<div class="align-center"> <div class="align-center">
<AppButton size="md" @click="loadNextPage()" v-if="canLoadMore">Load more history...</AppButton> <AppButton
<AppButton size="sm" @click="loadAll()" theme="secondary" v-if="canLoadMore">Load all</AppButton> size="md"
@click="loadNextPage()"
v-if="canLoadMore"
>Load more history...</AppButton
>
<AppButton
size="sm"
@click="loadAll()"
theme="secondary"
v-if="canLoadMore"
>Load all</AppButton
>
<AppBadge v-if="!canLoadMore">This is the start of this account's history.</AppBadge> <AppBadge v-if="!canLoadMore">This is the start of this account's history.</AppBadge>
</div> </div>
</div> </div>

View File

@ -1,11 +1,8 @@
<script setup lang="ts"> <script setup lang="ts"></script>
</script>
<template> <template>
<div class="history-item-divider"> <div class="history-item-divider">
<div class="history-item-divider-line"></div> <div class="history-item-divider-line"></div>
<div class="history-item-divider-label"> <div class="history-item-divider-label"></div>
</div>
<div class="history-item-divider-line"></div> <div class="history-item-divider-line"></div>
</div> </div>
</template> </template>

View File

@ -1,17 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { AccountJournalEntryType, type AccountHistoryJournalEntryItem } from '@/api/account' import { AccountJournalEntryType, type AccountHistoryJournalEntryItem } from '@/api/account'
import { formatMoney } from '@/api/data'; import { formatMoney } from '@/api/data'
import AppBadge from '../common/AppBadge.vue'; import AppBadge from '../common/AppBadge.vue'
defineProps<{ item: AccountHistoryJournalEntryItem }>() defineProps<{ item: AccountHistoryJournalEntryItem }>()
</script> </script>
<template> <template>
<div> <div>
<AppBadge class="font-mono">{{ formatMoney(item.amount, item.currency) }}</AppBadge> <AppBadge class="font-mono">{{ formatMoney(item.amount, item.currency) }}</AppBadge>
<span v-if="item.journalEntryType === AccountJournalEntryType.DEBIT" class="font-bold text-positive"> <span
v-if="item.journalEntryType === AccountJournalEntryType.DEBIT"
class="font-bold text-positive"
>
debited debited
</span> </span>
<span v-if="item.journalEntryType === AccountJournalEntryType.CREDIT" class="font-bold text-negative"> <span
v-if="item.journalEntryType === AccountJournalEntryType.CREDIT"
class="font-bold text-negative"
>
credited credited
</span> </span>
to this account via Transaction #{{ item.transactionId }}. to this account via Transaction #{{ item.transactionId }}.

View File

@ -1,16 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { type AccountHistoryValueRecordItem } from '@/api/account'; import { type AccountHistoryValueRecordItem } from '@/api/account'
import { formatMoney } from '@/api/data'; import { formatMoney } from '@/api/data'
import AppBadge from '../common/AppBadge.vue'; import AppBadge from '../common/AppBadge.vue'
defineProps<{ item: AccountHistoryValueRecordItem, accountId: number }>()
defineProps<{ item: AccountHistoryValueRecordItem; accountId: number }>()
</script> </script>
<template> <template>
<div> <div>
Value recorded as <AppBadge class="font-mono">{{ Value recorded as
formatMoney(item.value, <AppBadge class="font-mono">{{ formatMoney(item.value, item.currency) }} </AppBadge>
item.currency) }}
</AppBadge>
</div> </div>
</template> </template>

View File

@ -1,21 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { AccountApiClient, type Account } from '@/api/account'; import { AccountApiClient, type Account } from '@/api/account'
import { formatMoney } from '@/api/data'; import { formatMoney } from '@/api/data'
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
import AddValueRecordModal from '@/components/AddValueRecordModal.vue'; import AddValueRecordModal from '@/components/AddValueRecordModal.vue'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import AppPage from '@/components/common/AppPage.vue'; import AppPage from '@/components/common/AppPage.vue'
import ButtonBar from '@/components/common/ButtonBar.vue'; import ButtonBar from '@/components/common/ButtonBar.vue'
import AccountHistory from '@/components/history/AccountHistory.vue'; import AccountHistory from '@/components/history/AccountHistory.vue'
import PropertiesTable from '@/components/PropertiesTable.vue'; import PropertiesTable from '@/components/PropertiesTable.vue'
import { showConfirm } from '@/util/alert'; import { showConfirm } from '@/util/alert'
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'; import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const addValueRecordModal = useTemplateRef("addValueRecordModal") const addValueRecordModal = useTemplateRef('addValueRecordModal')
const history = useTemplateRef('history') const history = useTemplateRef('history')
const account: Ref<Account | null> = ref(null) const account: Ref<Account | null> = ref(null)
@ -32,7 +32,11 @@ onMounted(async () => {
async function deleteAccount() { async function deleteAccount() {
if (!account.value) return if (!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.')) { if (
await showConfirm(
'Are you sure you want to delete this account? This will permanently remove the account and all associated transactions.',
)
) {
try { try {
const api = new AccountApiClient(route) const api = new AccountApiClient(route)
await api.deleteAccount(account.value.id) await api.deleteAccount(account.value.id)
@ -92,14 +96,29 @@ async function addValueRecord() {
</PropertiesTable> </PropertiesTable>
<ButtonBar> <ButtonBar>
<AppButton @click="addValueRecord()">Record Value</AppButton> <AppButton @click="addValueRecord()">Record Value</AppButton>
<AppButton icon="wrench" <AppButton
@click="router.push(`/profiles/${getSelectedProfile(route)}/accounts/${account?.id}/edit`)"> icon="wrench"
Edit</AppButton> @click="router.push(`/profiles/${getSelectedProfile(route)}/accounts/${account?.id}/edit`)"
<AppButton icon="trash" @click="deleteAccount()">Delete</AppButton> >
Edit</AppButton
>
<AppButton
icon="trash"
@click="deleteAccount()"
>Delete</AppButton
>
</ButtonBar> </ButtonBar>
<AccountHistory :account="account" v-if="account" ref="history" /> <AccountHistory
:account="account"
v-if="account"
ref="history"
/>
<AddValueRecordModal v-if="account" :account="account" ref="addValueRecordModal" /> <AddValueRecordModal
v-if="account"
:account="account"
ref="addValueRecordModal"
/>
</AppPage> </AppPage>
</template> </template>

View File

@ -1,15 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
import { TransactionApiClient, type TransactionCategory, type TransactionCategoryTree } from '@/api/transaction'; import {
import AppButton from '@/components/common/AppButton.vue'; TransactionApiClient,
import AppPage from '@/components/common/AppPage.vue'; type TransactionCategory,
import ButtonBar from '@/components/common/ButtonBar.vue'; type TransactionCategoryTree,
import CategoryDisplayItem from '@/components/CategoryDisplayItem.vue'; } from '@/api/transaction'
import EditCategoryModal from '@/components/EditCategoryModal.vue'; import AppButton from '@/components/common/AppButton.vue'
import { showConfirm } from '@/util/alert'; import AppPage from '@/components/common/AppPage.vue'
import { hideLoader, showLoader } from '@/util/loader'; import ButtonBar from '@/components/common/ButtonBar.vue'
import { nextTick, onMounted, ref, useTemplateRef, type Ref } from 'vue'; import CategoryDisplayItem from '@/components/CategoryDisplayItem.vue'
import { useRoute } from 'vue-router'; import EditCategoryModal from '@/components/EditCategoryModal.vue'
import { showConfirm } from '@/util/alert'
import { hideLoader, showLoader } from '@/util/loader'
import { nextTick, onMounted, ref, useTemplateRef, type Ref } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
@ -42,7 +46,9 @@ async function editCategory(categoryId: number) {
} }
async function deleteCategory(categoryId: number) { async function deleteCategory(categoryId: number) {
const result = await showConfirm('Are you sure you want to delete this category? It will be removed from all transactions. All sub-categories will also be removed.') const result = await showConfirm(
'Are you sure you want to delete this category? It will be removed from all transactions. All sub-categories will also be removed.',
)
if (result) { if (result) {
try { try {
showLoader() showLoader()
@ -69,18 +75,31 @@ async function addCategory() {
<template> <template>
<AppPage title="Categories"> <AppPage title="Categories">
<p> <p>
Categories are used to group related transactions for your own organization, Categories are used to group related transactions for your own organization, as well as
as well as analytics. Categories are structured hierarchically, where each analytics. Categories are structured hierarchically, where each category could have zero or
category could have zero or more sub-categories. more sub-categories.
</p> </p>
<div> <div>
<CategoryDisplayItem v-for="category in categories" :key="category.id" :category="category" :editable="true" <CategoryDisplayItem
@edited="editCategory" @deleted="deleteCategory" /> v-for="category in categories"
:key="category.id"
:category="category"
:editable="true"
@edited="editCategory"
@deleted="deleteCategory"
/>
</div> </div>
<ButtonBar> <ButtonBar>
<AppButton icon="plus" @click="addCategory()">Add Category</AppButton> <AppButton
icon="plus"
@click="addCategory()"
>Add Category</AppButton
>
</ButtonBar> </ButtonBar>
<EditCategoryModal ref="editCategoryModal" :category="editedCategory" /> <EditCategoryModal
ref="editCategoryModal"
:category="editedCategory"
/>
</AppPage> </AppPage>
</template> </template>

View File

@ -1,15 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { AuthApiClient } from '@/api/auth'; import { AuthApiClient } from '@/api/auth'
import { ApiError } from '@/api/base'; import { ApiError } from '@/api/base'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import AppForm from '@/components/common/form/AppForm.vue'; import AppForm from '@/components/common/form/AppForm.vue'
import FormControl from '@/components/common/form/FormControl.vue'; import FormControl from '@/components/common/form/FormControl.vue'
import FormGroup from '@/components/common/form/FormGroup.vue'; import FormGroup from '@/components/common/form/FormGroup.vue'
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store'
import { showAlert } from '@/util/alert'; import { showAlert } from '@/util/alert'
import { hideLoader, showLoader } from '@/util/loader'; import { hideLoader, showLoader } from '@/util/loader'
import { ref } from 'vue'; import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -28,7 +28,7 @@ async function doLogin() {
const token = await apiClient.login(username.value, password.value) const token = await apiClient.login(username.value, password.value)
authStore.onUserLoggedIn(username.value, token) authStore.onUserLoggedIn(username.value, token)
hideLoader() hideLoader()
if ('next' in route.query && typeof (route.query.next) === 'string') { if ('next' in route.query && typeof route.query.next === 'string') {
await router.replace(route.query.next) await router.replace(route.query.next)
} else { } else {
await router.replace('/') await router.replace('/')
@ -69,47 +69,76 @@ function isDataValid() {
function generateSampleData() { function generateSampleData() {
fetch(import.meta.env.VITE_API_BASE_URL + '/sample-data', { fetch(import.meta.env.VITE_API_BASE_URL + '/sample-data', {
method: 'POST' method: 'POST',
}) })
} }
</script> </script>
<template> <template>
<div class="app-login-panel"> <div class="app-login-panel">
<h1 style="text-align: center; margin-bottom: 0.5rem; font-family: 'PlaywriteNL';">Finnow</h1> <h1 style="text-align: center; margin-bottom: 0.5rem; font-family: 'PlaywriteNL'">Finnow</h1>
<p style="text-align: center; font-weight: 600; margin-top: 0;"> <p style="text-align: center; font-weight: 600; margin-top: 0">
<em>Personal finance for the modern era.</em> <em>Personal finance for the modern era.</em>
</p> </p>
<AppForm @submit="doLogin()"> <AppForm @submit="doLogin()">
<FormGroup> <FormGroup>
<FormControl label="Username" class="login-control"> <FormControl
<input class="login-input" type="text" v-model="username" :disabled="disableForm" /> label="Username"
class="login-control"
>
<input
class="login-input"
type="text"
v-model="username"
:disabled="disableForm"
/>
</FormControl> </FormControl>
<FormControl label="Password" class="login-control"> <FormControl
<input class="login-input" id="password-input" type="password" v-model="password" :disabled="disableForm" /> label="Password"
class="login-control"
>
<input
class="login-input"
id="password-input"
type="password"
v-model="password"
:disabled="disableForm"
/>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<div style="display: flex; margin-left: 1rem; margin-right: 1rem;"> <div style="display: flex; margin-left: 1rem; margin-right: 1rem">
<AppButton type="submit" :disabled="disableForm || !isDataValid()" style="flex-grow: 1;">Login <AppButton
type="submit"
:disabled="disableForm || !isDataValid()"
style="flex-grow: 1"
>Login
</AppButton> </AppButton>
<AppButton type="button" theme="secondary" :disabled="disableForm || !isDataValid()" @click="doRegister()"> <AppButton
Register</AppButton> type="button"
theme="secondary"
:disabled="disableForm || !isDataValid()"
@click="doRegister()"
>
Register</AppButton
>
</div> </div>
<div v-if="isDev"> <div v-if="isDev">
<AppButton type="button" @click="generateSampleData()">Generate Sample Data</AppButton> <AppButton
type="button"
@click="generateSampleData()"
>Generate Sample Data</AppButton
>
</div> </div>
<!-- Disclaimer note that this project is still a work-in-progress! --> <!-- Disclaimer note that this project is still a work-in-progress! -->
<div class="font-size-small mx-1"> <div class="font-size-small mx-1">
<p> <p>
<strong>Note:</strong> Finnow is still under development, and may be <strong>Note:</strong> Finnow is still under development, and may be prone to bugs or
prone to bugs or unexpected data loss. Proceed to register and use unexpected data loss. Proceed to register and use this service at your own risk.
this service at your own risk.
</p> </p>
<p> <p>
Data is stored securely in protected cloud storage, but may be Data is stored securely in protected cloud storage, but may be accessed by a system
accessed by a system administrator in case of errors or debugging. administrator in case of errors or debugging. Please <strong>DO NOT</strong> store any
Please <strong>DO NOT</strong> store any sensitive financial sensitive financial credentials that you aren't okay with losing or potentially being
credentials that you aren't okay with losing or potentially being
leaked. Finnow is not responsible for any lost or compromised data. leaked. Finnow is not responsible for any lost or compromised data.
</p> </p>
</div> </div>

View File

@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { AuthApiClient } from '@/api/auth'; import { AuthApiClient } from '@/api/auth'
import { ApiError } from '@/api/base'; import { ApiError } from '@/api/base'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import AppPage from '@/components/common/AppPage.vue'; import AppPage from '@/components/common/AppPage.vue'
import AppForm from '@/components/common/form/AppForm.vue'; import AppForm from '@/components/common/form/AppForm.vue'
import FormControl from '@/components/common/form/FormControl.vue'; import FormControl from '@/components/common/form/FormControl.vue'
import FormGroup from '@/components/common/form/FormGroup.vue'; import FormGroup from '@/components/common/form/FormGroup.vue'
import ModalWrapper from '@/components/common/ModalWrapper.vue'; import ModalWrapper from '@/components/common/ModalWrapper.vue'
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store'
import { showAlert, showConfirm } from '@/util/alert'; import { showAlert, showConfirm } from '@/util/alert'
import { hideLoader, showLoader } from '@/util/loader'; import { hideLoader, showLoader } from '@/util/loader'
import { ref, useTemplateRef } from 'vue'; import { ref, useTemplateRef } from 'vue'
const authStore = useAuthStore() const authStore = useAuthStore()
@ -19,7 +19,11 @@ const currentPassword = ref('')
const newPassword = ref('') const newPassword = ref('')
async function doDeleteUser() { async function doDeleteUser() {
if (await showConfirm('Are you sure you want to delete your account? All data will be permanently deleted.')) { if (
await showConfirm(
'Are you sure you want to delete your account? All data will be permanently deleted.',
)
) {
const api = new AuthApiClient() const api = new AuthApiClient()
try { try {
await api.deleteMyUser() await api.deleteMyUser()
@ -58,15 +62,21 @@ async function doChangePassword() {
<template> <template>
<AppPage title="My User"> <AppPage title="My User">
<p> <p>
You are logged in as <code style="font-size: 14px;">{{ authStore.state?.username }}</code>. You are logged in as <code style="font-size: 14px">{{ authStore.state?.username }}</code
>.
</p> </p>
<p> <p>
There's not really that much to do with your user account specifically, There's not really that much to do with your user account specifically, all important settings
all important settings are profile-specific. are profile-specific.
</p> </p>
<div style="text-align: right;"> <div style="text-align: right">
<AppButton @click="showChangePasswordModal()">Change Password</AppButton> <AppButton @click="showChangePasswordModal()">Change Password</AppButton>
<AppButton icon="trash" theme="secondary" @click="doDeleteUser()">Delete My User</AppButton> <AppButton
icon="trash"
theme="secondary"
@click="doDeleteUser()"
>Delete My User</AppButton
>
</div> </div>
<!-- Modal for changing the user's password. --> <!-- Modal for changing the user's password. -->
@ -74,24 +84,35 @@ async function doChangePassword() {
<template v-slot:default> <template v-slot:default>
<AppForm> <AppForm>
<h2>Change Password</h2> <h2>Change Password</h2>
<p> <p>Change the password used to log into your user account.</p>
Change the password used to log into your user account.
</p>
<FormGroup> <FormGroup>
<FormControl label="Current Password"> <FormControl label="Current Password">
<input type="password" v-model="currentPassword" minlength="8" /> <input
type="password"
v-model="currentPassword"
minlength="8"
/>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<FormControl label="New Password"> <FormControl label="New Password">
<input type="password" v-model="newPassword" minlength="8" @keydown.enter.prevent="doChangePassword()" /> <input
type="password"
v-model="newPassword"
minlength="8"
@keydown.enter.prevent="doChangePassword()"
/>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
</AppForm> </AppForm>
</template> </template>
<template v-slot:buttons> <template v-slot:buttons>
<AppButton @click="doChangePassword()">Change</AppButton> <AppButton @click="doChangePassword()">Change</AppButton>
<AppButton theme="secondary" @click="changePasswordModal?.close()">Cancel</AppButton> <AppButton
theme="secondary"
@click="changePasswordModal?.close()"
>Cancel</AppButton
>
</template> </template>
</ModalWrapper> </ModalWrapper>
</AppPage> </AppPage>

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { AccountApiClient, type Account } from '@/api/account'; import { AccountApiClient, type Account } from '@/api/account'
import { ProfileApiClient, type Profile } from '@/api/profile'; import { ProfileApiClient, type Profile } from '@/api/profile'
import { onMounted, ref, type Ref } from 'vue'; import { onMounted, ref, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -36,14 +36,15 @@ onMounted(async () => {
<template> <template>
<div class="app-page-container"> <div class="app-page-container">
<h1 class="app-page-title">Profile {{ profile?.name }}</h1> <h1 class="app-page-title">Profile {{ profile?.name }}</h1>
<p> <p>This is the page for the profile!</p>
This is the page for the profile!
</p>
<ul> <ul>
<li v-for="account in accounts" :key="account.id"> <li
v-for="account in accounts"
:key="account.id"
>
<p> <p>
Account {{ account.id }} for currency {{ account.currency }} Account {{ account.id }} for currency {{ account.currency }} Number suffix:
Number suffix: {{ account.numberSuffix }} {{ account.numberSuffix }}
</p> </p>
</li> </li>
</ul> </ul>

View File

@ -1,10 +1,10 @@
<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/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import AppPage from '@/components/common/AppPage.vue'; import AppPage from '@/components/common/AppPage.vue'
import ModalWrapper from '@/components/common/ModalWrapper.vue'; import ModalWrapper from '@/components/common/ModalWrapper.vue'
import { onMounted, type Ref, ref, useTemplateRef } from 'vue'; import { onMounted, type Ref, ref, useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
const profiles: Ref<Profile[]> = ref([]) const profiles: Ref<Profile[]> = ref([])
@ -42,11 +42,20 @@ async function addProfile() {
</script> </script>
<template> <template>
<AppPage title="Select a Profile"> <AppPage title="Select a 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 style="text-align: right;"> <div style="text-align: right">
<AppButton icon="plus" @click="addProfileModal?.show()">Add a new profile</AppButton> <AppButton
icon="plus"
@click="addProfileModal?.show()"
>Add a new profile</AppButton
>
</div> </div>
<ModalWrapper ref="addProfileModal"> <ModalWrapper ref="addProfileModal">
@ -54,12 +63,21 @@ async function addProfile() {
<h3 class="app-modal-dialog-header">Add Profile</h3> <h3 class="app-modal-dialog-header">Add Profile</h3>
<div> <div>
<label for="new-profile-name-input">Enter the name of your new profile.</label> <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" /> <input
type="text"
minlength="3"
id="new-profile-name-input"
v-model="newProfileName"
/>
</div> </div>
</template> </template>
<template v-slot:buttons> <template v-slot:buttons>
<AppButton @click="addProfile()">Add</AppButton> <AppButton @click="addProfile()">Add</AppButton>
<AppButton button-style="secondary" @click="addProfileModal?.close()">Cancel</AppButton> <AppButton
button-style="secondary"
@click="addProfileModal?.close()"
>Cancel</AppButton
>
</template> </template>
</ModalWrapper> </ModalWrapper>
</AppPage> </AppPage>

View File

@ -1,18 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ApiError } from '@/api/base'; import { ApiError } from '@/api/base'
import { formatMoney } from '@/api/data'; import { formatMoney } from '@/api/data'
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
import { TransactionApiClient, type TransactionDetail } from '@/api/transaction'; import { TransactionApiClient, type TransactionDetail } from '@/api/transaction'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import AppPage from '@/components/common/AppPage.vue'; import AppPage from '@/components/common/AppPage.vue'
import CategoryLabel from '@/components/CategoryLabel.vue'; import CategoryLabel from '@/components/CategoryLabel.vue'
import PropertiesTable from '@/components/PropertiesTable.vue'; import PropertiesTable from '@/components/PropertiesTable.vue'
import TagLabel from '@/components/TagLabel.vue'; import TagLabel from '@/components/TagLabel.vue'
import { showAlert, showConfirm } from '@/util/alert'; import { showAlert, showConfirm } from '@/util/alert'
import { onMounted, ref, type Ref } from 'vue'; import { onMounted, ref, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router'
import AttachmentRow from '@/components/common/AttachmentRow.vue'; import AttachmentRow from '@/components/common/AttachmentRow.vue'
import LineItemCard from '@/components/LineItemCard.vue'; import LineItemCard from '@/components/LineItemCard.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -35,7 +35,9 @@ onMounted(async () => {
async function deleteTransaction() { async function deleteTransaction() {
if (!transaction.value) return if (!transaction.value) return
const conf = await showConfirm('Are you sure you want to delete this transaction? This will permanently delete all data pertaining to this transaction, and it cannot be recovered.') const conf = await showConfirm(
'Are you sure you want to delete this transaction? This will permanently delete all data pertaining to this transaction, and it cannot be recovered.',
)
if (!conf) return if (!conf) return
try { try {
await transactionApi.deleteTransaction(transaction.value.id) await transactionApi.deleteTransaction(transaction.value.id)
@ -46,7 +48,10 @@ async function deleteTransaction() {
} }
</script> </script>
<template> <template>
<AppPage :title="'Transaction ' + transaction.id" v-if="transaction"> <AppPage
:title="'Transaction ' + transaction.id"
v-if="transaction"
>
<PropertiesTable> <PropertiesTable>
<tr> <tr>
<th>Timestamp</th> <th>Timestamp</th>
@ -73,7 +78,10 @@ async function deleteTransaction() {
<tr v-if="transaction.category"> <tr v-if="transaction.category">
<th>Category</th> <th>Category</th>
<td> <td>
<CategoryLabel :category="transaction.category" :clickable="true" /> <CategoryLabel
:category="transaction.category"
:clickable="true"
/>
</td> </td>
</tr> </tr>
<tr v-if="transaction.creditedAccount"> <tr v-if="transaction.creditedAccount">
@ -91,27 +99,50 @@ async function deleteTransaction() {
<tr> <tr>
<th>Tags</th> <th>Tags</th>
<td> <td>
<TagLabel v-for="t in transaction.tags" :key="t" :tag="t" /> <TagLabel
v-for="t in transaction.tags"
:key="t"
:tag="t"
/>
</td> </td>
</tr> </tr>
</PropertiesTable> </PropertiesTable>
<div v-if="transaction.lineItems.length > 0"> <div v-if="transaction.lineItems.length > 0">
<h3>Line Items</h3> <h3>Line Items</h3>
<LineItemCard v-for="item of transaction.lineItems" :key="item.idx" :line-item="item" <LineItemCard
:currency="transaction.currency" :total-count="transaction.lineItems.length" :editable="false" /> v-for="item of transaction.lineItems"
:key="item.idx"
:line-item="item"
:currency="transaction.currency"
:total-count="transaction.lineItems.length"
:editable="false"
/>
</div> </div>
<div v-if="transaction.attachments.length > 0"> <div v-if="transaction.attachments.length > 0">
<h3>Attachments</h3> <h3>Attachments</h3>
<AttachmentRow v-for="a in transaction.attachments" :attachment="a" :key="a.id" disabled /> <AttachmentRow
v-for="a in transaction.attachments"
:attachment="a"
:key="a.id"
disabled
/>
</div> </div>
<div> <div>
<AppButton icon="wrench" <AppButton
@click="router.push(`/profiles/${getSelectedProfile(route)}/transactions/${transaction.id}/edit`)"> icon="wrench"
@click="
router.push(`/profiles/${getSelectedProfile(route)}/transactions/${transaction.id}/edit`)
"
>
Edit Edit
</AppButton> </AppButton>
<AppButton icon="trash" @click="deleteTransaction()">Delete</AppButton> <AppButton
icon="trash"
@click="deleteTransaction()"
>Delete</AppButton
>
</div> </div>
</AppPage> </AppPage>
</template> </template>

View File

@ -4,12 +4,12 @@ bar with some controls and user information, and a router view for all child
pages. pages.
--> -->
<script setup lang="ts"> <script setup lang="ts">
import { AuthApiClient } from '@/api/auth'; import { AuthApiClient } from '@/api/auth'
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
import { secondsUntilExpired } from '@/api/token-util'; import { secondsUntilExpired } from '@/api/token-util'
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store'
import { onMounted, ref, type Ref } from 'vue'; import { onMounted, ref, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -61,14 +61,25 @@ async function checkAuth() {
<div> <div>
<header class="app-header-bar"> <header class="app-header-bar">
<div> <div>
<h1 class="app-header-text" @click="onHeaderClicked()">Finnow</h1> <h1
class="app-header-text"
@click="onHeaderClicked()"
>
Finnow
</h1>
</div> </div>
<div> <div>
<span class="app-user-widget" @click="router.push('/me')"> <span
class="app-user-widget"
@click="router.push('/me')"
>
<font-awesome-icon icon="fa-user"></font-awesome-icon> <font-awesome-icon icon="fa-user"></font-awesome-icon>
</span> </span>
<span class="app-logout-button" @click="authStore.onUserLoggedOut()"> <span
class="app-logout-button"
@click="authStore.onUserLoggedOut()"
>
<font-awesome-icon icon="fa-solid fa-arrow-right-from-bracket"></font-awesome-icon> <font-awesome-icon icon="fa-solid fa-arrow-right-from-bracket"></font-awesome-icon>
</span> </span>
</div> </div>

View File

@ -1,8 +1,7 @@
<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'; import TransactionsModule from './home/TransactionsModule.vue'
</script> </script>
<template> <template>
<div class="app-module-container"> <div class="app-module-container">

View File

@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { AccountApiClient, type AccountValueRecord } from '@/api/account'; import { AccountApiClient, type AccountValueRecord } from '@/api/account'
import { formatMoney } from '@/api/data'; import { formatMoney } from '@/api/data'
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
import AppBadge from '@/components/common/AppBadge.vue'; import AppBadge from '@/components/common/AppBadge.vue'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import AppPage from '@/components/common/AppPage.vue'; import AppPage from '@/components/common/AppPage.vue'
import AttachmentRow from '@/components/common/AttachmentRow.vue'; import AttachmentRow from '@/components/common/AttachmentRow.vue'
import ButtonBar from '@/components/common/ButtonBar.vue'; import ButtonBar from '@/components/common/ButtonBar.vue'
import PropertiesTable from '@/components/PropertiesTable.vue'; import PropertiesTable from '@/components/PropertiesTable.vue'
import { showConfirm } from '@/util/alert'; import { showConfirm } from '@/util/alert'
import { onMounted, ref, type Ref } from 'vue'; import { onMounted, ref, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -30,19 +30,26 @@ onMounted(async () => {
async function deleteValueRecord() { async function deleteValueRecord() {
if (!valueRecord.value) return if (!valueRecord.value) return
const confirm = await showConfirm("Are you sure you want to delete this value record? This may affect how your account's balance is calculated.") const confirm = await showConfirm(
"Are you sure you want to delete this value record? This may affect how your account's balance is calculated.",
)
if (!confirm) return if (!confirm) return
const api = new AccountApiClient(route) const api = new AccountApiClient(route)
try { try {
await api.deleteValueRecord(valueRecord.value.accountId, valueRecord.value.id) await api.deleteValueRecord(valueRecord.value.accountId, valueRecord.value.id)
await router.replace(`/profiles/${getSelectedProfile(route)}/accounts/${valueRecord.value.accountId}`) await router.replace(
`/profiles/${getSelectedProfile(route)}/accounts/${valueRecord.value.accountId}`,
)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
} }
</script> </script>
<template> <template>
<AppPage title="Value Record" v-if="valueRecord"> <AppPage
title="Value Record"
v-if="valueRecord"
>
<PropertiesTable> <PropertiesTable>
<tr> <tr>
<th>ID</th> <th>ID</th>
@ -57,13 +64,22 @@ async function deleteValueRecord() {
</PropertiesTable> </PropertiesTable>
<div v-if="valueRecord.attachments.length > 0"> <div v-if="valueRecord.attachments.length > 0">
<h3>Attachments</h3> <h3>Attachments</h3>
<AttachmentRow v-for="a in valueRecord.attachments" :attachment="a" :key="a.id" disabled /> <AttachmentRow
v-for="a in valueRecord.attachments"
:attachment="a"
:key="a.id"
disabled
/>
</div> </div>
<ButtonBar> <ButtonBar>
<AppButton type="button" icon="trash" size="sm" @click="deleteValueRecord()"> <AppButton
type="button"
icon="trash"
size="sm"
@click="deleteValueRecord()"
>
Delete this record Delete this record
</AppButton> </AppButton>
</ButtonBar> </ButtonBar>
</AppPage> </AppPage>
</template> </template>

View File

@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
import { TransactionApiClient, type TransactionVendor } from '@/api/transaction'; import { TransactionApiClient, type TransactionVendor } from '@/api/transaction'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import AppPage from '@/components/common/AppPage.vue'; import AppPage from '@/components/common/AppPage.vue'
import ButtonBar from '@/components/common/ButtonBar.vue'; import ButtonBar from '@/components/common/ButtonBar.vue'
import EditVendorModal from '@/components/EditVendorModal.vue'; import EditVendorModal from '@/components/EditVendorModal.vue'
import { showConfirm } from '@/util/alert'; import { showConfirm } from '@/util/alert'
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'; import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
const transactionApi = new TransactionApiClient(getSelectedProfile(route)) const transactionApi = new TransactionApiClient(getSelectedProfile(route))
@ -41,7 +41,9 @@ async function editVendor(vendor: TransactionVendor) {
} }
async function deleteVendor(vendor: TransactionVendor) { async function deleteVendor(vendor: TransactionVendor) {
const confirmed = await showConfirm('Are you sure you want to delete this vendor? It will be permanently removed from all associated transactions.') const confirmed = await showConfirm(
'Are you sure you want to delete this vendor? It will be permanently removed from all associated transactions.',
)
if (!confirmed) return if (!confirmed) return
await transactionApi.deleteVendor(vendor.id) await transactionApi.deleteVendor(vendor.id)
await loadVendors() await loadVendors()
@ -50,26 +52,42 @@ async function deleteVendor(vendor: TransactionVendor) {
<template> <template>
<AppPage title="Vendors"> <AppPage title="Vendors">
<p> <p>
Vendors are businesses and other entities with which you exchange money. Vendors are businesses and other entities with which you exchange money. Adding a vendor to
Adding a vendor to Finnow allows you to track when you interact with that Finnow allows you to track when you interact with that vendor on a transaction.
vendor on a transaction.
</p> </p>
<table class="app-table"> <table class="app-table">
<tbody> <tbody>
<tr v-for="vendor in vendors" :key="vendor.id"> <tr
v-for="vendor in vendors"
:key="vendor.id"
>
<td>{{ vendor.name }}</td> <td>{{ vendor.name }}</td>
<td>{{ vendor.description }}</td> <td>{{ vendor.description }}</td>
<td style="min-width: 130px;"> <td style="min-width: 130px">
<AppButton icon="wrench" @click="editVendor(vendor)" /> <AppButton
<AppButton icon="trash" @click="deleteVendor(vendor)" /> icon="wrench"
@click="editVendor(vendor)"
/>
<AppButton
icon="trash"
@click="deleteVendor(vendor)"
/>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<ButtonBar> <ButtonBar>
<AppButton button-type="button" icon="plus" @click="addVendor()">Add Vendor</AppButton> <AppButton
button-type="button"
icon="plus"
@click="addVendor()"
>Add Vendor</AppButton
>
</ButtonBar> </ButtonBar>
<EditVendorModal ref="editVendorModal" :vendor="editedVendor" /> <EditVendorModal
ref="editVendorModal"
:vendor="editedVendor"
/>
</AppPage> </AppPage>
</template> </template>

View File

@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { AccountApiClient, AccountTypes, type Account, type AccountType } from '@/api/account'; import { AccountApiClient, AccountTypes, type Account, type AccountType } from '@/api/account'
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
import AppPage from '@/components/common/AppPage.vue'; import AppPage from '@/components/common/AppPage.vue'
import AppForm from '@/components/common/form/AppForm.vue'; import AppForm from '@/components/common/form/AppForm.vue'
import FormActions from '@/components/common/form/FormActions.vue'; import FormActions from '@/components/common/form/FormActions.vue'
import FormControl from '@/components/common/form/FormControl.vue'; import FormControl from '@/components/common/form/FormControl.vue'
import FormGroup from '@/components/common/form/FormGroup.vue'; import FormGroup from '@/components/common/form/FormGroup.vue'
import { computed, onMounted, ref, type Ref } from 'vue'; import { computed, onMounted, ref, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -26,7 +26,7 @@ const description = ref('')
onMounted(async () => { onMounted(async () => {
const accountIdStr = route.params.id const accountIdStr = route.params.id
if (accountIdStr && typeof (accountIdStr) === 'string') { if (accountIdStr && typeof accountIdStr === 'string') {
const accountId = parseInt(accountIdStr) const accountId = parseInt(accountIdStr)
try { try {
loading.value = true loading.value = true
@ -51,7 +51,7 @@ async function doSubmit() {
description: description.value, description: description.value,
type: accountType.value.id, type: accountType.value.id,
currency: currency.value, currency: currency.value,
numberSuffix: accountNumberSuffix.value numberSuffix: accountNumberSuffix.value,
} }
try { try {
@ -72,11 +72,22 @@ async function doSubmit() {
<AppPage :title="editing ? 'Edit Account' : 'Add Account'"> <AppPage :title="editing ? 'Edit Account' : 'Add Account'">
<AppForm @submit="doSubmit()"> <AppForm @submit="doSubmit()">
<FormGroup> <FormGroup>
<FormControl label="Account Name" style="max-width: 200px;"> <FormControl
<input v-model="accountName" :disabled="loading" /> label="Account Name"
style="max-width: 200px"
>
<input
v-model="accountName"
:disabled="loading"
/>
</FormControl> </FormControl>
<FormControl label="Account Type"> <FormControl label="Account Type">
<select id="account-type-select" v-model="accountType" :disabled="loading" required> <select
id="account-type-select"
v-model="accountType"
:disabled="loading"
required
>
<option :value="AccountTypes.CHECKING">{{ AccountTypes.CHECKING.name }}</option> <option :value="AccountTypes.CHECKING">{{ AccountTypes.CHECKING.name }}</option>
<option :value="AccountTypes.SAVINGS">{{ AccountTypes.SAVINGS.name }}</option> <option :value="AccountTypes.SAVINGS">{{ AccountTypes.SAVINGS.name }}</option>
<option :value="AccountTypes.CREDIT_CARD">{{ AccountTypes.CREDIT_CARD.name }}</option> <option :value="AccountTypes.CREDIT_CARD">{{ AccountTypes.CREDIT_CARD.name }}</option>
@ -84,25 +95,46 @@ async function doSubmit() {
</select> </select>
</FormControl> </FormControl>
<FormControl label="Currency"> <FormControl label="Currency">
<select id="currency-select" v-model="currency" :disabled="loading" required> <select
id="currency-select"
v-model="currency"
:disabled="loading"
required
>
<option value="USD">USD</option> <option value="USD">USD</option>
<option value="EUR">EUR</option> <option value="EUR">EUR</option>
<option value="GBP">GBP</option> <option value="GBP">GBP</option>
</select> </select>
</FormControl> </FormControl>
<FormControl label="Account Number Suffix" style="max-width: 200px;"> <FormControl
<input id="account-number-suffix-input" v-model="accountNumberSuffix" minlength="4" maxlength="4" label="Account Number Suffix"
:disabled="loading" required /> style="max-width: 200px"
>
<input
id="account-number-suffix-input"
v-model="accountNumberSuffix"
minlength="4"
maxlength="4"
:disabled="loading"
required
/>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<FormControl label="Description"> <FormControl label="Description">
<textarea id="description-textarea" v-model="description" :disabled="loading"></textarea> <textarea
id="description-textarea"
v-model="description"
:disabled="loading"
></textarea>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<FormActions @cancel="router.replace(`/profiles/${getSelectedProfile(route)}`)" :disabled="loading" <FormActions
:submit-text="editing ? 'Save' : 'Add'" /> @cancel="router.replace(`/profiles/${getSelectedProfile(route)}`)"
:disabled="loading"
:submit-text="editing ? 'Save' : 'Add'"
/>
</AppForm> </AppForm>
</AppPage> </AppPage>
</template> </template>

View File

@ -10,22 +10,29 @@ The form consists of a few main sections:
- Tags editor for editing the set of tags. - Tags editor for editing the set of tags.
--> -->
<script setup lang="ts"> <script setup lang="ts">
import { AccountApiClient, type Account } from '@/api/account'; import { AccountApiClient, type Account } from '@/api/account'
import { DataApiClient, floatMoneyToInteger, type Currency } from '@/api/data'; import { DataApiClient, floatMoneyToInteger, type Currency } from '@/api/data'
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
import { TransactionApiClient, type AddTransactionPayload, type TransactionDetail, type TransactionDetailLineItem, type TransactionVendor } from '@/api/transaction'; import {
import AppPage from '@/components/common/AppPage.vue'; TransactionApiClient,
import CategorySelect from '@/components/CategorySelect.vue'; type AddTransactionPayload,
import FileSelector from '@/components/common/FileSelector.vue'; type TransactionDetail,
import AppForm from '@/components/common/form/AppForm.vue'; type TransactionDetailLineItem,
import FormActions from '@/components/common/form/FormActions.vue'; type TransactionVendor,
import FormControl from '@/components/common/form/FormControl.vue'; } from '@/api/transaction'
import FormGroup from '@/components/common/form/FormGroup.vue'; import AppPage from '@/components/common/AppPage.vue'
import LineItemsEditor from '@/components/LineItemsEditor.vue'; import CategorySelect from '@/components/CategorySelect.vue'
import TagLabel from '@/components/TagLabel.vue'; import FileSelector from '@/components/common/FileSelector.vue'
import { getDatetimeLocalValueForNow } from '@/util/time'; import AppForm from '@/components/common/form/AppForm.vue'
import { computed, onMounted, ref, watch, type Ref } from 'vue'; import FormActions from '@/components/common/form/FormActions.vue'
import { useRoute, useRouter, } from 'vue-router'; import FormControl from '@/components/common/form/FormControl.vue'
import FormGroup from '@/components/common/form/FormGroup.vue'
import LineItemsEditor from '@/components/LineItemsEditor.vue'
import TagLabel from '@/components/TagLabel.vue'
import { getDatetimeLocalValueForNow } from '@/util/time'
import { computed, onMounted, ref, watch, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import VendorSelect from '@/components/VendorSelect.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -37,20 +44,74 @@ const existingTransaction: Ref<TransactionDetail | null> = ref(null)
const editing = computed(() => { const editing = computed(() => {
return existingTransaction.value !== null || route.meta.title === 'Edit Transaction' return existingTransaction.value !== null || route.meta.title === 'Edit Transaction'
}) })
const formValid = computed(() => {
console.log('Computing if for is valid...')
return (
timestamp.value.length > 0 &&
amount.value > 0 &&
currency.value !== null &&
description.value.length > 0 &&
(creditedAccountId.value !== null || debitedAccountId.value !== null) &&
creditedAccountId.value !== debitedAccountId.value
)
})
const unsavedEdits = computed(() => {
console.log('Computing if there are unsaved edits...')
if (!existingTransaction.value) return true
const tagsEqual =
tags.value.every((t) => existingTransaction.value?.tags.includes(t)) &&
existingTransaction.value.tags.every((t) => tags.value.includes(t))
let lineItemsEqual = false
if (lineItems.value.length === existingTransaction.value.lineItems.length) {
lineItemsEqual = true
for (let i = 0; i < lineItems.value.length; i++) {
const i1 = lineItems.value[i]
const i2 = existingTransaction.value.lineItems[i]
if (
i1.idx !== i2.idx ||
i1.quantity !== i2.quantity ||
i1.valuePerItem !== i2.valuePerItem ||
i1.description !== i2.description ||
(i1.category?.id ?? null) !== (i2.category?.id ?? null)
) {
lineItemsEqual = false
break
}
}
}
const attachmentsChanged =
attachmentsToUpload.value.length > 0 || removedAttachmentIds.value.length > 0
return (
new Date(timestamp.value).toISOString() !== existingTransaction.value.timestamp ||
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !==
existingTransaction.value.amount ||
currency.value !== existingTransaction.value.currency ||
description.value !== existingTransaction.value.description ||
(vendor.value?.id ?? null) !== (existingTransaction.value.vendor?.id ?? null) ||
categoryId.value !== (existingTransaction.value.category?.id ?? null) ||
creditedAccountId.value !== (existingTransaction.value.creditedAccount?.id ?? null) ||
debitedAccountId.value !== (existingTransaction.value.debitedAccount?.id ?? null) ||
!tagsEqual ||
!lineItemsEqual ||
attachmentsChanged
)
})
// General data used to populate form controls. // General data used to populate form controls.
const allCurrencies: Ref<Currency[]> = ref([]) const allCurrencies: Ref<Currency[]> = ref([])
const availableCurrencies = computed(() => { const availableCurrencies = computed(() => {
return allCurrencies.value.filter(c => allAccounts.value.some(a => a.currency.code === c.code)) return allCurrencies.value.filter((c) =>
allAccounts.value.some((a) => a.currency.code === c.code),
)
}) })
const availableVendors: Ref<TransactionVendor[]> = ref([])
const allAccounts: Ref<Account[]> = ref([]) const allAccounts: Ref<Account[]> = ref([])
const availableAccounts = computed(() => { const availableAccounts = computed(() => {
return allAccounts.value.filter(a => a.currency.code === currency.value?.code) return allAccounts.value.filter((a) => a.currency.code === currency.value?.code)
}) })
const allTags: Ref<string[]> = ref([]) const allTags: Ref<string[]> = ref([])
const availableTags = computed(() => { const availableTags = computed(() => {
return allTags.value.filter(t => !tags.value.includes(t)) return allTags.value.filter((t) => !tags.value.includes(t))
}) })
const loading = ref(false) const loading = ref(false)
@ -59,7 +120,7 @@ const timestamp = ref('')
const amount = ref(0) const amount = ref(0)
const currency: Ref<Currency | null> = ref(null) const currency: Ref<Currency | null> = ref(null)
const description = ref('') const description = ref('')
const vendorId: Ref<number | null> = ref(null) const vendor: Ref<TransactionVendor | null> = ref(null)
const categoryId: Ref<number | null> = ref(null) const categoryId: Ref<number | null> = ref(null)
const creditedAccountId: Ref<number | null> = ref(null) const creditedAccountId: Ref<number | null> = ref(null)
const debitedAccountId: Ref<number | null> = ref(null) const debitedAccountId: Ref<number | null> = ref(null)
@ -72,7 +133,7 @@ const attachmentsToUpload: Ref<File[]> = ref([])
const removedAttachmentIds: Ref<number[]> = ref([]) const removedAttachmentIds: Ref<number[]> = ref([])
watch(customTagInput, (newValue: string) => { watch(customTagInput, (newValue: string) => {
const result = newValue.match("^[a-z0-9-_]{3,32}$") const result = newValue.match('^[a-z0-9-_]{3,32}$')
customTagInputValid.value = result !== null && result.length > 0 customTagInputValid.value = result !== null && result.length > 0
}) })
watch(availableCurrencies, (newValue: Currency[]) => { watch(availableCurrencies, (newValue: Currency[]) => {
@ -85,14 +146,12 @@ onMounted(async () => {
const dataClient = new DataApiClient() const dataClient = new DataApiClient()
// Fetch various collections of data needed for different user choices. // Fetch various collections of data needed for different user choices.
dataClient.getCurrencies().then(currencies => allCurrencies.value = currencies) dataClient.getCurrencies().then((currencies) => (allCurrencies.value = currencies))
transactionApi.getVendors().then(vendors => availableVendors.value = vendors) transactionApi.getAllTags().then((t) => (allTags.value = t))
transactionApi.getAllTags().then(t => allTags.value = t) accountApi.getAccounts().then((accounts) => (allAccounts.value = accounts))
accountApi.getAccounts().then(accounts => allAccounts.value = accounts)
const transactionIdStr = route.params.id const transactionIdStr = route.params.id
if (transactionIdStr && typeof (transactionIdStr) === 'string') { if (transactionIdStr && typeof transactionIdStr === 'string') {
const transactionId = parseInt(transactionIdStr) const transactionId = parseInt(transactionIdStr)
try { try {
loading.value = true loading.value = true
@ -120,32 +179,47 @@ async function doSubmit() {
return return
} }
let vendorId: number | null = vendor.value?.id ?? null
if (vendor.value !== null && vendorId === -1) {
const newVendor = await transactionApi.createVendor({
name: vendor.value?.name,
description: null,
})
vendorId = newVendor.id
}
const localDate = new Date(timestamp.value) const localDate = new Date(timestamp.value)
const payload: AddTransactionPayload = { const payload: AddTransactionPayload = {
timestamp: localDate.toISOString(), timestamp: localDate.toISOString(),
amount: floatMoneyToInteger(amount.value, currency.value), amount: floatMoneyToInteger(amount.value, currency.value),
currencyCode: currency.value?.code ?? '', currencyCode: currency.value?.code ?? '',
description: description.value, description: description.value,
vendorId: vendorId.value, vendorId: vendorId,
categoryId: categoryId.value, categoryId: categoryId.value,
creditedAccountId: creditedAccountId.value, creditedAccountId: creditedAccountId.value,
debitedAccountId: debitedAccountId.value, debitedAccountId: debitedAccountId.value,
tags: tags.value, tags: tags.value,
lineItems: lineItems.value.map(i => { lineItems: lineItems.value.map((i) => {
return { ...i, categoryId: i.category?.id ?? null } return { ...i, categoryId: i.category?.id ?? null }
}), }),
attachmentIdsToRemove: removedAttachmentIds.value attachmentIdsToRemove: removedAttachmentIds.value,
} }
let savedTransaction = null let savedTransaction = null
try { try {
loading.value = true loading.value = true
if (existingTransaction.value) { if (existingTransaction.value) {
savedTransaction = await transactionApi.updateTransaction(existingTransaction.value?.id, payload, attachmentsToUpload.value) savedTransaction = await transactionApi.updateTransaction(
existingTransaction.value?.id,
payload,
attachmentsToUpload.value,
)
} else { } else {
savedTransaction = await transactionApi.addTransaction(payload, attachmentsToUpload.value) savedTransaction = await transactionApi.addTransaction(payload, attachmentsToUpload.value)
} }
await router.replace(`/profiles/${getSelectedProfile(route)}/transactions/${savedTransaction.id}`) await router.replace(
`/profiles/${getSelectedProfile(route)}/transactions/${savedTransaction.id}`,
)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} finally { } finally {
@ -159,7 +233,9 @@ async function doSubmit() {
*/ */
function doCancel() { function doCancel() {
if (editing.value) { if (editing.value) {
router.replace(`/profiles/${getSelectedProfile(route)}/transactions/${existingTransaction.value?.id}`) router.replace(
`/profiles/${getSelectedProfile(route)}/transactions/${existingTransaction.value?.id}`,
)
} else { } else {
router.replace(`/profiles/${getSelectedProfile(route)}`) router.replace(`/profiles/${getSelectedProfile(route)}`)
} }
@ -182,7 +258,7 @@ function loadValuesFromExistingTransaction(t: TransactionDetail) {
amount.value = t.amount / Math.pow(10, t.currency.fractionalDigits) amount.value = t.amount / Math.pow(10, t.currency.fractionalDigits)
currency.value = t.currency currency.value = t.currency
description.value = t.description description.value = t.description
vendorId.value = t.vendor?.id ?? null vendor.value = t.vendor ?? null
categoryId.value = t.category?.id ?? null categoryId.value = t.category?.id ?? null
creditedAccountId.value = t.creditedAccount?.id ?? null creditedAccountId.value = t.creditedAccount?.id ?? null
debitedAccountId.value = t.debitedAccount?.id ?? null debitedAccountId.value = t.debitedAccount?.id ?? null
@ -194,64 +270,7 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
const date = new Date(timestamp) const date = new Date(timestamp)
date.setMilliseconds(0) date.setMilliseconds(0)
const timezoneOffset = new Date().getTimezoneOffset() * 60_000 const timezoneOffset = new Date().getTimezoneOffset() * 60_000
return (new Date(date.getTime() - timezoneOffset)).toISOString().slice(0, -1) return new Date(date.getTime() - timezoneOffset).toISOString().slice(0, -1)
}
/**
* Determines if the form is valid, which if true, means the user is allowed
* to save the form.
*/
function isFormValid() {
return timestamp.value.length > 0 &&
amount.value > 0 &&
currency.value !== null &&
description.value.length > 0 &&
(
creditedAccountId.value !== null ||
debitedAccountId.value !== null
) &&
creditedAccountId.value !== debitedAccountId.value
}
/**
* Determines if the user's editing an existing transaction, and there is at
* least one edit to it. Otherwise, there's no point in saving.
*/
function isEdited() {
if (!existingTransaction.value) return true
const tagsEqual = tags.value.every(t => existingTransaction.value?.tags.includes(t)) &&
existingTransaction.value.tags.every(t => tags.value.includes(t))
let lineItemsEqual = false
if (lineItems.value.length === existingTransaction.value.lineItems.length) {
lineItemsEqual = true
for (let i = 0; i < lineItems.value.length; i++) {
const i1 = lineItems.value[i]
const i2 = existingTransaction.value.lineItems[i]
if (
i1.idx !== i2.idx ||
i1.quantity !== i2.quantity ||
i1.valuePerItem !== i2.valuePerItem ||
i1.description !== i2.description ||
(i1.category?.id ?? null) !== (i2.category?.id ?? null)
) {
lineItemsEqual = false
break
}
}
}
const attachmentsChanged = attachmentsToUpload.value.length > 0 || removedAttachmentIds.value.length > 0
return new Date(timestamp.value).toISOString() !== existingTransaction.value.timestamp ||
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== existingTransaction.value.amount ||
currency.value !== existingTransaction.value.currency ||
description.value !== existingTransaction.value.description ||
vendorId.value !== (existingTransaction.value.vendor?.id ?? null) ||
categoryId.value !== (existingTransaction.value.category?.id ?? null) ||
creditedAccountId.value !== (existingTransaction.value.creditedAccount?.id ?? null) ||
debitedAccountId.value !== (existingTransaction.value.debitedAccount?.id ?? null) ||
!tagsEqual ||
!lineItemsEqual ||
attachmentsChanged
} }
</script> </script>
<template> <template>
@ -260,32 +279,53 @@ function isEdited() {
<FormGroup> <FormGroup>
<!-- Basic properties --> <!-- Basic properties -->
<FormControl label="Timestamp"> <FormControl label="Timestamp">
<input type="datetime-local" v-model="timestamp" step="1" :disabled="loading" style="min-width: 250px;" /> <input
type="datetime-local"
v-model="timestamp"
step="1"
:disabled="loading"
style="min-width: 250px"
/>
</FormControl> </FormControl>
<FormControl label="Amount"> <FormControl label="Amount">
<input type="number" v-model="amount" step="0.01" min="0.01" :disabled="loading" style="max-width: 100px;" /> <input
type="number"
v-model="amount"
step="0.01"
min="0.01"
:disabled="loading"
style="max-width: 100px"
/>
</FormControl> </FormControl>
<FormControl label="Currency"> <FormControl label="Currency">
<select v-model="currency" :disabled="loading || availableCurrencies.length === 1"> <select
<option v-for="currency in availableCurrencies" :key="currency.code" :value="currency"> v-model="currency"
:disabled="loading || availableCurrencies.length === 1"
>
<option
v-for="currency in availableCurrencies"
:key="currency.code"
:value="currency"
>
{{ currency.code }} {{ currency.code }}
</option> </option>
</select> </select>
</FormControl> </FormControl>
<FormControl label="Description" style="min-width: 200px;"> <FormControl
<textarea v-model="description" :disabled="loading"></textarea> label="Description"
style="min-width: 200px"
>
<textarea
v-model="description"
:disabled="loading"
></textarea>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<!-- Vendor & Category --> <!-- Vendor & Category -->
<FormControl label="Vendor"> <FormControl label="Vendor">
<select v-model="vendorId" :disabled="loading"> <VendorSelect v-model="vendor" />
<option v-for="vendor in availableVendors" :key="vendor.id" :value="vendor.id">
{{ vendor.name }}
</option>
<option :value="null" :selected="vendorId === null">None</option>
</select>
</FormControl> </FormControl>
<FormControl label="Category"> <FormControl label="Category">
<CategorySelect v-model="categoryId" /> <CategorySelect v-model="categoryId" />
@ -295,16 +335,30 @@ function isEdited() {
<FormGroup> <FormGroup>
<!-- Accounts --> <!-- Accounts -->
<FormControl label="Credited Account"> <FormControl label="Credited Account">
<select v-model="creditedAccountId" :disabled="loading"> <select
<option v-for="account in availableAccounts" :key="account.id" :value="account.id"> v-model="creditedAccountId"
:disabled="loading"
>
<option
v-for="account in availableAccounts"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.numberSuffix }}) {{ account.name }} ({{ account.numberSuffix }})
</option> </option>
<option :value="null">None</option> <option :value="null">None</option>
</select> </select>
</FormControl> </FormControl>
<FormControl label="Debited Account"> <FormControl label="Debited Account">
<select v-model="debitedAccountId" :disabled="loading"> <select
<option v-for="account in availableAccounts" :key="account.id" :value="account.id"> v-model="debitedAccountId"
:disabled="loading"
>
<option
v-for="account in availableAccounts"
:key="account.id"
:value="account.id"
>
{{ account.name }} ({{ account.numberSuffix }}) {{ account.name }} ({{ account.numberSuffix }})
</option> </option>
<option :value="null">None</option> <option :value="null">None</option>
@ -312,34 +366,64 @@ function isEdited() {
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<LineItemsEditor v-if="currency" v-model="lineItems" :currency="currency" <LineItemsEditor
:transaction-amount="floatMoneyToInteger(amount, currency)" /> v-if="currency"
v-model="lineItems"
:currency="currency"
:transaction-amount="floatMoneyToInteger(amount, currency)"
/>
<FormGroup> <FormGroup>
<!-- Tags --> <!-- Tags -->
<FormControl label="Tags"> <FormControl label="Tags">
<div style="margin-top: 0.5rem; margin-bottom: 0.5rem;"> <div style="margin-top: 0.5rem; margin-bottom: 0.5rem">
<TagLabel v-for="t in tags" :key="t" :tag="t" deletable @deleted="tags = tags.filter(tg => tg !== t)" /> <TagLabel
v-for="t in tags"
:key="t"
:tag="t"
deletable
@deleted="tags = tags.filter((tg) => tg !== t)"
/>
</div> </div>
<div> <div>
<select v-model="selectedTagToAdd"> <select v-model="selectedTagToAdd">
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option> <option
v-for="tag in availableTags"
:key="tag"
:value="tag"
>
{{ tag }}
</option>
</select> </select>
<input v-model="customTagInput" placeholder="Custom tag..." /> <input
<button type="button" @click="addTag()" :disabled="selectedTagToAdd === null && !customTagInputValid">Add v-model="customTagInput"
Tag</button> placeholder="Custom tag..."
/>
<button
type="button"
@click="addTag()"
:disabled="selectedTagToAdd === null && !customTagInputValid"
>
Add Tag
</button>
</div> </div>
</FormControl> </FormControl>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<h5>Attachments</h5> <h5>Attachments</h5>
<FileSelector :initial-files="existingTransaction?.attachments ?? []" <FileSelector
v-model:uploaded-files="attachmentsToUpload" v-model:removed-files="removedAttachmentIds" /> :initial-files="existingTransaction?.attachments ?? []"
v-model:uploaded-files="attachmentsToUpload"
v-model:removed-files="removedAttachmentIds"
/>
</FormGroup> </FormGroup>
<FormActions @cancel="doCancel()" :disabled="loading || !isFormValid() || !isEdited()" <FormActions
:submit-text="editing ? 'Save' : 'Add'" /> @cancel="doCancel()"
:disabled="loading || !formValid || !unsavedEdits"
:submit-text="editing ? 'Save' : 'Add'"
/>
</AppForm> </AppForm>
</AppPage> </AppPage>
</template> </template>

View File

@ -19,12 +19,15 @@ const totalOwed: Ref<CurrencyBalance[]> = computed(() => {
const totals: CurrencyBalance[] = [] const totals: CurrencyBalance[] = []
for (const acc of accounts.value) { for (const acc of accounts.value) {
if (acc.currentBalance === null) continue if (acc.currentBalance === null) continue
if (totals.filter(t => t.currency.code === acc.currency.code).length === 0) { if (totals.filter((t) => t.currency.code === acc.currency.code).length === 0) {
totals.push({ balance: 0, currency: acc.currency }) totals.push({ balance: 0, currency: acc.currency })
} }
const currencyTotal = totals.filter(t => t.currency.code === acc.currency.code)[0] const currencyTotal = totals.filter((t) => t.currency.code === acc.currency.code)[0]
const accountType = AccountTypes.of(acc.type) const accountType = AccountTypes.of(acc.type)
if ((accountType.debitsPositive && acc.currentBalance < 0) || (!accountType.debitsPositive && acc.currentBalance > 0)) { if (
(accountType.debitsPositive && acc.currentBalance < 0) ||
(!accountType.debitsPositive && acc.currentBalance > 0)
) {
currencyTotal.balance += acc.currentBalance currencyTotal.balance += acc.currentBalance
} }
} }
@ -33,35 +36,56 @@ const totalOwed: Ref<CurrencyBalance[]> = computed(() => {
onMounted(async () => { onMounted(async () => {
const accountApi = new AccountApiClient(route) const accountApi = new AccountApiClient(route)
accountApi.getAccounts().then(result => accounts.value = result) accountApi
.catch(err => console.error(err)) .getAccounts()
accountApi.getTotalBalances().then(result => { .then((result) => (accounts.value = result))
totalBalances.value = result .catch((err) => console.error(err))
}) accountApi
.catch(err => console.error(err)) .getTotalBalances()
.then((result) => {
totalBalances.value = result
})
.catch((err) => console.error(err))
}) })
</script> </script>
<template> <template>
<HomeModule title="Accounts"> <HomeModule title="Accounts">
<template v-slot:default> <template v-slot:default>
<AccountCard v-for="a in accounts" :account="a" :key="a.id" /> <AccountCard
v-for="a in accounts"
:account="a"
:key="a.id"
/>
<p v-if="accounts.length === 0"> <p v-if="accounts.length === 0">
You haven't added any accounts. Add one to start tracking your finances. You haven't added any accounts. Add one to start tracking your finances.
</p> </p>
<div> <div>
<AppBadge v-for="bal in totalBalances" :key="bal.currency.code"> <AppBadge
{{ bal.currency.code }} Total: <span class="font-mono">{{ formatMoney(bal.balance, bal.currency) }}</span> v-for="bal in totalBalances"
:key="bal.currency.code"
>
{{ bal.currency.code }} Total:
<span class="font-mono">{{ formatMoney(bal.balance, bal.currency) }}</span>
</AppBadge> </AppBadge>
<AppBadge v-for="debt in totalOwed" :key="debt.currency.code"> <AppBadge
{{ debt.currency.code }} Debt: <span class="font-mono" :class="{ 'text-negative': debt.balance > 0 }">{{ v-for="debt in totalOwed"
formatMoney(debt.balance, :key="debt.currency.code"
debt.currency) }}</span> >
{{ debt.currency.code }} Debt:
<span
class="font-mono"
:class="{ 'text-negative': debt.balance > 0 }"
>{{ formatMoney(debt.balance, debt.currency) }}</span
>
</AppBadge> </AppBadge>
</div> </div>
</template> </template>
<template v-slot:actions> <template v-slot:actions>
<AppButton icon="plus" @click="router.push(`/profiles/${getSelectedProfile(route)}/add-account`)">Add Account <AppButton
icon="plus"
@click="router.push(`/profiles/${getSelectedProfile(route)}/add-account`)"
>Add Account
</AppButton> </AppButton>
</template> </template>
</HomeModule> </HomeModule>

View File

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { getSelectedProfile, ProfileApiClient, type Profile } from '@/api/profile'; import { getSelectedProfile, ProfileApiClient, type Profile } from '@/api/profile'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'; import ConfirmModal from '@/components/common/ConfirmModal.vue'
import HomeModule from '@/components/HomeModule.vue'; import HomeModule from '@/components/HomeModule.vue'
import { showAlert } from '@/util/alert'; import { showAlert } from '@/util/alert'
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'; import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -18,8 +18,8 @@ onMounted(async () => {
profile.value = await new ProfileApiClient().getProfile(getSelectedProfile(route)) profile.value = await new ProfileApiClient().getProfile(getSelectedProfile(route))
} catch (err) { } catch (err) {
console.error(err) console.error(err)
await showAlert("Failed to get profile.") await showAlert('Failed to get profile.')
await router.replace("/profiles") await router.replace('/profiles')
} }
}) })
@ -37,24 +37,41 @@ async function deleteProfile() {
} }
</script> </script>
<template> <template>
<HomeModule title="Profile" v-if="profile"> <HomeModule
title="Profile"
v-if="profile"
>
<template v-slot:default> <template v-slot:default>
<p>Your currently selected profile is: {{ profile.name }}</p> <p>Your currently selected profile is: {{ profile.name }}</p>
<p> <p>
<RouterLink :to="`/profiles/${profile.name}/vendors`">View all vendors here.</RouterLink> <RouterLink :to="`/profiles/${profile.name}/vendors`">View all vendors here.</RouterLink>
</p> </p>
<p> <p>
<RouterLink :to="`/profiles/${profile.name}/categories`">View all categories here.</RouterLink> <RouterLink :to="`/profiles/${profile.name}/categories`"
>View all categories here.</RouterLink
>
</p> </p>
<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>
</template> </template>
<template v-slot:actions> <template v-slot:actions>
<AppButton icon="folder-open" @click="router.push('/profiles')">Choose another profile</AppButton> <AppButton
<AppButton button-style="secondary" icon="trash" @click="deleteProfile()">Delete</AppButton> icon="folder-open"
@click="router.push('/profiles')"
>Choose another profile</AppButton
>
<AppButton
button-style="secondary"
icon="trash"
@click="deleteProfile()"
>Delete</AppButton
>
</template> </template>
</HomeModule> </HomeModule>
</template> </template>

View File

@ -1,17 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Page, PageRequest } from '@/api/pagination'; import type { Page, PageRequest } from '@/api/pagination'
import { getSelectedProfile } from '@/api/profile'; import { getSelectedProfile } from '@/api/profile'
import { TransactionApiClient, type TransactionsListItem } from '@/api/transaction'; import { TransactionApiClient, type TransactionsListItem } from '@/api/transaction'
import AppButton from '@/components/common/AppButton.vue'; import AppButton from '@/components/common/AppButton.vue'
import HomeModule from '@/components/HomeModule.vue'; import HomeModule from '@/components/HomeModule.vue'
import PaginationControls from '@/components/common/PaginationControls.vue'; import PaginationControls from '@/components/common/PaginationControls.vue'
import { onMounted, ref, type Ref } from 'vue'; import { onMounted, ref, type Ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router'
import TransactionCard from '@/components/TransactionCard.vue'; import TransactionCard from '@/components/TransactionCard.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const transactions: Ref<Page<TransactionsListItem>> = ref({ items: [], pageRequest: { page: 1, size: 5, sorts: [] }, totalElements: 0, totalPages: 0, isFirst: true, isLast: true }) const transactions: Ref<Page<TransactionsListItem>> = ref({
items: [],
pageRequest: { page: 1, size: 5, sorts: [] },
totalElements: 0,
totalPages: 0,
isFirst: true,
isLast: true,
})
onMounted(async () => { onMounted(async () => {
await fetchPage(transactions.value.pageRequest) await fetchPage(transactions.value.pageRequest)
@ -29,17 +36,25 @@ async function fetchPage(pageRequest: PageRequest) {
<template> <template>
<HomeModule title="Transactions"> <HomeModule title="Transactions">
<template v-slot:default> <template v-slot:default>
<TransactionCard v-for="tx in transactions.items" :key="tx.id" :tx="tx" /> <TransactionCard
v-for="tx in transactions.items"
:key="tx.id"
:tx="tx"
/>
<PaginationControls :page="transactions" @update="pr => fetchPage(pr)"></PaginationControls> <PaginationControls
<p v-if="transactions.totalElements === 0"> :page="transactions"
You haven't added any transactions. @update="(pr) => fetchPage(pr)"
</p> ></PaginationControls>
<p v-if="transactions.totalElements === 0">You haven't added any transactions.</p>
</template> </template>
<template v-slot:actions> <template v-slot:actions>
<AppButton icon="plus" @click="router.push(`/profiles/${getSelectedProfile(route)}/add-transaction`)"> <AppButton
Add icon="plus"
Transaction</AppButton> @click="router.push(`/profiles/${getSelectedProfile(route)}/add-transaction`)"
>
Add Transaction</AppButton
>
</template> </template>
</HomeModule> </HomeModule>
</template> </template>