Reformatted all components, added VendorSelect.
Build and Deploy Web App / build-and-deploy (push) Failing after 11s
Details
Build and Deploy Web App / build-and-deploy (push) Failing after 11s
Details
This commit is contained in:
parent
943ce13be2
commit
100652d03a
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
{{ " ".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"
|
||||||
|
>
|
||||||
|
{{ ' '.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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }}.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue