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",
|
||||
"semi": false,
|
||||
"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/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.0.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.0.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
||||
"@fortawesome/vue-fontawesome": "^3.1.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.0.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.0.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.0.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.1.2",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"vue3-select-component": "^0.12.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/node": "^22.18.6",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^9.31.0",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-vue": "~10.3.0",
|
||||
"jiti": "^2.4.2",
|
||||
"jiti": "^2.5.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^7.0.6",
|
||||
"vite-plugin-vue-devtools": "^8.0.0",
|
||||
"vue-tsc": "^3.0.4"
|
||||
"vite": "^7.1.6",
|
||||
"vite-plugin-vue-devtools": "^8.0.2",
|
||||
"vue-tsc": "^3.0.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import GlobalAlertModal from './components/GlobalAlertModal.vue';
|
||||
import GlobalLoadingOverlay from './components/GlobalLoadingOverlay.vue';
|
||||
import GlobalAlertModal from './components/GlobalAlertModal.vue'
|
||||
import GlobalLoadingOverlay from './components/GlobalLoadingOverlay.vue'
|
||||
</script>
|
||||
<template>
|
||||
<RouterView></RouterView>
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ import { type Page, type PageRequest } from './pagination'
|
|||
export interface TransactionVendor {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
description: string | null
|
||||
}
|
||||
|
||||
export interface TransactionVendorPayload {
|
||||
name: string
|
||||
description: string
|
||||
description: string | null
|
||||
}
|
||||
|
||||
export interface TransactionCategory {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { AccountTypes, type Account } from '@/api/account';
|
||||
import { formatMoney } from '@/api/data';
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import AppBadge from './common/AppBadge.vue';
|
||||
import { AccountTypes, type Account } from '@/api/account'
|
||||
import { formatMoney } from '@/api/data'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppBadge from './common/AppBadge.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
|
@ -12,17 +12,19 @@ const route = useRoute()
|
|||
const props = defineProps<{ account: Account }>()
|
||||
const accountType = computed(() => AccountTypes.of(props.account.type))
|
||||
const isBalancePositive = computed(() => {
|
||||
return props.account.currentBalance !== null && (
|
||||
accountType.value.debitsPositive
|
||||
return (
|
||||
props.account.currentBalance !== null &&
|
||||
(accountType.value.debitsPositive
|
||||
? props.account.currentBalance > 0
|
||||
: props.account.currentBalance < 0
|
||||
: props.account.currentBalance < 0)
|
||||
)
|
||||
})
|
||||
const isBalanceNegative = computed(() => {
|
||||
return props.account.currentBalance !== null && (
|
||||
accountType.value.debitsPositive
|
||||
return (
|
||||
props.account.currentBalance !== null &&
|
||||
(accountType.value.debitsPositive
|
||||
? props.account.currentBalance < 0
|
||||
: props.account.currentBalance > 0
|
||||
: props.account.currentBalance > 0)
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -32,18 +34,28 @@ function goToAccount() {
|
|||
}
|
||||
</script>
|
||||
<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. -->
|
||||
<div class="account-card-top-row">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="font-mono font-size-small" :class="{
|
||||
'text-positive': isBalancePositive,
|
||||
'text-negative': isBalanceNegative
|
||||
}">
|
||||
<div
|
||||
class="font-mono font-size-small"
|
||||
:class="{
|
||||
'text-positive': isBalancePositive,
|
||||
'text-negative': isBalanceNegative,
|
||||
}"
|
||||
>
|
||||
<span v-if="account.currentBalance !== null">
|
||||
{{ formatMoney(account.currentBalance, account.currency) }}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef, type Ref } from 'vue';
|
||||
import AppForm from '@/components/common/form/AppForm.vue';
|
||||
import FormControl from '@/components/common/form/FormControl.vue';
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue';
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import { AccountApiClient, AccountValueRecordType, type Account, 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';
|
||||
import { ref, useTemplateRef, type Ref } from 'vue'
|
||||
import AppForm from '@/components/common/form/AppForm.vue'
|
||||
import FormControl from '@/components/common/form/FormControl.vue'
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue'
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import {
|
||||
AccountApiClient,
|
||||
AccountValueRecordType,
|
||||
type Account,
|
||||
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 props = defineProps<{ account: Account }>()
|
||||
|
|
@ -25,7 +31,8 @@ async function show(): Promise<AccountValueRecord | undefined> {
|
|||
if (!modal.value) return Promise.resolve(undefined)
|
||||
timestamp.value = getDatetimeLocalValueForNow()
|
||||
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 {
|
||||
amount.value = 0
|
||||
}
|
||||
|
|
@ -41,16 +48,22 @@ async function addValueRecord() {
|
|||
const payload: AccountValueRecordCreationPayload = {
|
||||
timestamp: datetimeLocalToISO(timestamp.value),
|
||||
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.
|
||||
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
|
||||
}
|
||||
const api = new AccountApiClient(route)
|
||||
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')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
|
@ -65,16 +78,25 @@ defineExpose({ show })
|
|||
<template v-slot:default>
|
||||
<h2>Add Value Record</h2>
|
||||
<p>
|
||||
Record the current value of this account, to act as a keyframe from
|
||||
which the account's balance can be derived.
|
||||
Record the current value of this account, to act as a keyframe from which the account's
|
||||
balance can be derived.
|
||||
</p>
|
||||
<AppForm>
|
||||
<FormGroup>
|
||||
<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 label="Value">
|
||||
<input type="number" v-model="amount" step="0.01" />
|
||||
<input
|
||||
type="number"
|
||||
v-model="amount"
|
||||
step="0.01"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
|
|
@ -85,7 +107,11 @@ defineExpose({ show })
|
|||
</template>
|
||||
<template v-slot:buttons>
|
||||
<AppButton @click="addValueRecord()">Add</AppButton>
|
||||
<AppButton button-style="secondary" @click="modal?.close()">Cancel</AppButton>
|
||||
<AppButton
|
||||
button-style="secondary"
|
||||
@click="modal?.close()"
|
||||
>Cancel</AppButton
|
||||
>
|
||||
</template>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,73 @@
|
|||
<script setup lang="ts">
|
||||
import type { TransactionCategoryTree } from '@/api/transaction';
|
||||
import AppButton from './common/AppButton.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { TransactionCategoryTree } from '@/api/transaction'
|
||||
import AppButton from './common/AppButton.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
category: TransactionCategoryTree
|
||||
editable: boolean
|
||||
}>()
|
||||
defineEmits<{
|
||||
'edited': [number]
|
||||
'deleted': [number]
|
||||
edited: [number]
|
||||
deleted: [number]
|
||||
}>()
|
||||
const expanded = ref(false)
|
||||
const canExpand = computed(() => props.category.children.length > 0)
|
||||
</script>
|
||||
<template>
|
||||
<div class="category-display-item" :class="{
|
||||
'category-display-item-bg-1': category.depth % 2 === 0,
|
||||
'category-display-item-bg-2': category.depth % 2 === 1
|
||||
}">
|
||||
<div
|
||||
class="category-display-item"
|
||||
: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>
|
||||
<h4 class="category-display-item-title">{{ category.name }}</h4>
|
||||
<p class="category-display-item-description">{{ category.description }}</p>
|
||||
</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 v-if="editable" style="text-align: right;">
|
||||
<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
|
||||
v-if="editable"
|
||||
style="text-align: right"
|
||||
>
|
||||
<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>
|
||||
<!-- Nested display item for each child: -->
|
||||
<div style="margin-left: 1rem;" 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
|
||||
style="margin-left: 1rem"
|
||||
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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import AppBadge from './common/AppBadge.vue';
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppBadge from './common/AppBadge.vue'
|
||||
|
||||
interface CategoryInfo {
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const props = defineProps<{ category: CategoryInfo, clickable?: boolean }>()
|
||||
const props = defineProps<{ category: CategoryInfo; clickable?: boolean }>()
|
||||
|
||||
function onClicked() {
|
||||
if (props.clickable) {
|
||||
|
|
@ -20,8 +19,14 @@ function onClicked() {
|
|||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppBadge @click="onClicked()" :style="{ 'cursor': clickable ? 'pointer' : 'inherit' }">
|
||||
<div class="category-label-color" :style="{ 'background-color': '#' + category.color }"></div>
|
||||
<AppBadge
|
||||
@click="onClicked()"
|
||||
:style="{ cursor: clickable ? 'pointer' : 'inherit' }"
|
||||
>
|
||||
<div
|
||||
class="category-label-color"
|
||||
:style="{ 'background-color': '#' + category.color }"
|
||||
></div>
|
||||
{{ category.name }}
|
||||
</AppBadge>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import { TransactionApiClient, type TransactionCategoryTree } from '@/api/transaction';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import { TransactionApiClient, type TransactionCategoryTree } from '@/api/transaction'
|
||||
import { onMounted, ref, type Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const model = defineModel<number | null>({ required: true })
|
||||
|
|
@ -12,8 +12,7 @@ const categories: Ref<TransactionCategoryTree[]> = ref([])
|
|||
|
||||
onMounted(() => {
|
||||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
api.getCategoriesFlattened()
|
||||
.then(c => categories.value = c)
|
||||
api.getCategoriesFlattened().then((c) => (categories.value = c))
|
||||
})
|
||||
|
||||
function getCategoryById(id: number): TransactionCategoryTree | null {
|
||||
|
|
@ -24,10 +23,23 @@ function getCategoryById(id: number): TransactionCategoryTree | null {
|
|||
}
|
||||
</script>
|
||||
<template>
|
||||
<select v-model="model" @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 }}
|
||||
<select
|
||||
v-model="model"
|
||||
@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 v-if="required !== true" :value="null" :selected="model === null">None</option>
|
||||
</select>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { TransactionApiClient, type TransactionCategory } from '@/api/transaction';
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue';
|
||||
import { ref, useTemplateRef, type Ref } from 'vue';
|
||||
import AppForm from '@/components/common/form/AppForm.vue';
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue';
|
||||
import FormControl from '@/components/common/form/FormControl.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import CategorySelect from './CategorySelect.vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import { TransactionApiClient, type TransactionCategory } from '@/api/transaction'
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue'
|
||||
import { ref, useTemplateRef, type Ref } from 'vue'
|
||||
import AppForm from '@/components/common/form/AppForm.vue'
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue'
|
||||
import FormControl from '@/components/common/form/FormControl.vue'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import CategorySelect from './CategorySelect.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
|
||||
const route = useRoute()
|
||||
const props = defineProps<{
|
||||
|
|
@ -29,7 +29,7 @@ function show(): Promise<string | undefined> {
|
|||
description.value = props.category?.description ?? ''
|
||||
if (props.category) {
|
||||
name.value = props.category.name
|
||||
description.value = props.category.description ?? ""
|
||||
description.value = props.category.description ?? ''
|
||||
color.value = '#' + props.category.color
|
||||
parentId.value = props.category.parentId
|
||||
} else {
|
||||
|
|
@ -42,17 +42,19 @@ function show(): Promise<string | undefined> {
|
|||
}
|
||||
|
||||
function canSave() {
|
||||
const inputValid = name.value.trim().length > 0 &&
|
||||
color.value.match('^#(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})$')
|
||||
const inputValid =
|
||||
name.value.trim().length > 0 && color.value.match('^#(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})$')
|
||||
if (!inputValid) return false
|
||||
if (props.category) {
|
||||
const prevDescription = props.category.description?.trim() ?? ""
|
||||
const prevDescription = props.category.description?.trim() ?? ''
|
||||
const currentDescription = description.value.trim()
|
||||
const descriptionChanged = prevDescription !== currentDescription
|
||||
return props.category.name.trim() !== name.value.trim() ||
|
||||
return (
|
||||
props.category.name.trim() !== name.value.trim() ||
|
||||
descriptionChanged ||
|
||||
props.category.color.trim().toLowerCase() !== color.value.trim().toLowerCase() ||
|
||||
props.category.parentId !== parentId.value
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -67,7 +69,7 @@ async function doSave() {
|
|||
name: name.value.trim(),
|
||||
description: desc,
|
||||
color: color.value.trim().substring(1),
|
||||
parentId: parentId.value
|
||||
parentId: parentId.value,
|
||||
}
|
||||
try {
|
||||
let savedCategory = null
|
||||
|
|
@ -93,12 +95,21 @@ defineExpose({ show })
|
|||
<AppForm>
|
||||
<FormGroup>
|
||||
<FormControl label="Name">
|
||||
<input type="text" v-model="name" />
|
||||
<input
|
||||
type="text"
|
||||
v-model="name"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl label="Color">
|
||||
<input type="color" v-model="color" />
|
||||
<input
|
||||
type="color"
|
||||
v-model="color"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl label="Description" style="min-width: 300px;">
|
||||
<FormControl
|
||||
label="Description"
|
||||
style="min-width: 300px"
|
||||
>
|
||||
<textarea v-model="description"></textarea>
|
||||
</FormControl>
|
||||
<FormControl label="Parent Category">
|
||||
|
|
@ -108,8 +119,16 @@ defineExpose({ show })
|
|||
</AppForm>
|
||||
</template>
|
||||
<template v-slot:buttons>
|
||||
<AppButton :disabled="!canSave()" @click="doSave()">Save</AppButton>
|
||||
<AppButton button-style="secondary" @click="modal?.close()">Cancel</AppButton>
|
||||
<AppButton
|
||||
:disabled="!canSave()"
|
||||
@click="doSave()"
|
||||
>Save</AppButton
|
||||
>
|
||||
<AppButton
|
||||
button-style="secondary"
|
||||
@click="modal?.close()"
|
||||
>Cancel</AppButton
|
||||
>
|
||||
</template>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import AppForm from '@/components/common/form/AppForm.vue';
|
||||
import FormControl from '@/components/common/form/FormControl.vue';
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue';
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue';
|
||||
import { TransactionApiClient, type TransactionVendor } from '@/api/transaction';
|
||||
import AppButton from './common/AppButton.vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
import AppForm from '@/components/common/form/AppForm.vue'
|
||||
import FormControl from '@/components/common/form/FormControl.vue'
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue'
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue'
|
||||
import { TransactionApiClient, type TransactionVendor } from '@/api/transaction'
|
||||
import AppButton from './common/AppButton.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
|
||||
const route = useRoute()
|
||||
const props = defineProps<{
|
||||
|
|
@ -31,8 +31,10 @@ function canSave() {
|
|||
const inputValid = name.value.trim().length > 0
|
||||
if (!inputValid) return false
|
||||
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()
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -41,7 +43,7 @@ async function doSave() {
|
|||
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||
const payload = {
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim()
|
||||
description: description.value.trim(),
|
||||
}
|
||||
try {
|
||||
let savedVendor = null
|
||||
|
|
@ -67,17 +69,31 @@ defineExpose({ show })
|
|||
<AppForm>
|
||||
<FormGroup>
|
||||
<FormControl label="Name">
|
||||
<input type="text" v-model="name" />
|
||||
<input
|
||||
type="text"
|
||||
v-model="name"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl label="Description" style="min-width: 300px;">
|
||||
<FormControl
|
||||
label="Description"
|
||||
style="min-width: 300px"
|
||||
>
|
||||
<textarea v-model="description"></textarea>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
</AppForm>
|
||||
</template>
|
||||
<template v-slot:buttons>
|
||||
<AppButton :disabled="!canSave()" @click="doSave()">Save</AppButton>
|
||||
<AppButton button-style="secondary" @click="modal?.close()">Cancel</AppButton>
|
||||
<AppButton
|
||||
:disabled="!canSave()"
|
||||
@click="doSave()"
|
||||
>Save</AppButton
|
||||
>
|
||||
<AppButton
|
||||
button-style="secondary"
|
||||
@click="modal?.close()"
|
||||
>Cancel</AppButton
|
||||
>
|
||||
</template>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,36 @@
|
|||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue';
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import { useTemplateRef } from 'vue'
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
|
||||
const globalAlertModal = useTemplateRef('global-alert-modal')
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper id="global-alert-modal" ref="global-alert-modal">
|
||||
<ModalWrapper
|
||||
id="global-alert-modal"
|
||||
ref="global-alert-modal"
|
||||
>
|
||||
<template v-slot:default>
|
||||
<p id="global-alert-modal-text">This is an alert!</p>
|
||||
</template>
|
||||
<template v-slot:buttons>
|
||||
<AppButton id="global-alert-modal-ok-button" @click="globalAlertModal?.close('ok')">Ok</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>
|
||||
<AppButton
|
||||
id="global-alert-modal-ok-button"
|
||||
@click="globalAlertModal?.close('ok')"
|
||||
>Ok</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>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<!-- A globally-mounted loading overlay controlled by util/loader.ts. -->
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<div class="loading-overlay">
|
||||
<div class="loader-indicator"></div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue';
|
||||
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue'
|
||||
|
||||
defineProps<{ title: string }>()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import type { TransactionDetailLineItem } from '@/api/transaction';
|
||||
import AppButton from './common/AppButton.vue';
|
||||
import { formatMoney, type Currency } from '@/api/data';
|
||||
import type { TransactionDetailLineItem } from '@/api/transaction'
|
||||
import AppButton from './common/AppButton.vue'
|
||||
import { formatMoney, type Currency } from '@/api/data'
|
||||
|
||||
defineProps<{
|
||||
lineItem: TransactionDetailLineItem
|
||||
|
|
@ -11,11 +11,10 @@ defineProps<{
|
|||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'deleted': void,
|
||||
'movedUp': void,
|
||||
'movedDown': void
|
||||
deleted: void
|
||||
movedUp: void
|
||||
movedDown: void
|
||||
}>()
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<div class="line-item-card">
|
||||
|
|
@ -29,10 +28,24 @@ defineEmits<{
|
|||
<span class="font-mono font-size-small">
|
||||
{{ formatMoney(lineItem.valuePerItem, currency) }}
|
||||
</span>
|
||||
<AppButton icon="arrow-up" v-if="editable && lineItem.idx > 0" 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')" />
|
||||
<AppButton
|
||||
icon="arrow-up"
|
||||
v-if="editable && lineItem.idx > 0"
|
||||
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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ transaction. This editor shows a table of current line items, and includes a
|
|||
modal for adding a new one.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { type TransactionCategoryTree, type TransactionDetailLineItem } from '@/api/transaction';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue';
|
||||
import { floatMoneyToInteger, formatMoney, type Currency } from '@/api/data';
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue';
|
||||
import FormControl from '@/components/common/form/FormControl.vue';
|
||||
import { computed, ref, type Ref, useTemplateRef } from 'vue';
|
||||
import CategorySelect from './CategorySelect.vue';
|
||||
import LineItemCard from './LineItemCard.vue';
|
||||
import AppBadge from './common/AppBadge.vue';
|
||||
import { type TransactionCategoryTree, type TransactionDetailLineItem } from '@/api/transaction'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue'
|
||||
import { floatMoneyToInteger, formatMoney, type Currency } from '@/api/data'
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue'
|
||||
import FormControl from '@/components/common/form/FormControl.vue'
|
||||
import { computed, ref, type Ref, useTemplateRef } from 'vue'
|
||||
import CategorySelect from './CategorySelect.vue'
|
||||
import LineItemCard from './LineItemCard.vue'
|
||||
import AppBadge from './common/AppBadge.vue'
|
||||
|
||||
const model = defineModel<TransactionDetailLineItem[]>({ required: true })
|
||||
const props = defineProps<{
|
||||
|
|
@ -37,34 +37,31 @@ const selectedCategory: Ref<TransactionCategoryTree | null> = ref(null)
|
|||
const addLineItemModal = useTemplateRef('addLineItemModal')
|
||||
|
||||
function canAddLineItem() {
|
||||
return addLineItemDescription.value.length > 0 &&
|
||||
addLineItemQuantity.value > 0
|
||||
return addLineItemDescription.value.length > 0 && addLineItemQuantity.value > 0
|
||||
}
|
||||
|
||||
function showAddLineItemModal() {
|
||||
addLineItemDescription.value = ''
|
||||
addLineItemValuePerItem.value = 1.00
|
||||
addLineItemValuePerItem.value = 1.0
|
||||
addLineItemQuantity.value = 1
|
||||
addLineItemCategoryId.value = null
|
||||
addLineItemModal.value?.show()
|
||||
}
|
||||
|
||||
async function addLineItem() {
|
||||
const newIdx = model.value.length === 0
|
||||
? 0
|
||||
: Math.max(...model.value.map(i => i.idx)) + 1
|
||||
const newIdx = model.value.length === 0 ? 0 : Math.max(...model.value.map((i) => i.idx)) + 1
|
||||
model.value.push({
|
||||
idx: newIdx,
|
||||
description: addLineItemDescription.value,
|
||||
quantity: addLineItemQuantity.value,
|
||||
valuePerItem: floatMoneyToInteger(addLineItemValuePerItem.value, props.currency),
|
||||
category: selectedCategory.value
|
||||
category: selectedCategory.value,
|
||||
})
|
||||
addLineItemModal.value?.close()
|
||||
}
|
||||
|
||||
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++) {
|
||||
model.value[i].idx = i
|
||||
}
|
||||
|
|
@ -92,15 +89,30 @@ function moveItemDown(idx: number) {
|
|||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<LineItemCard v-for="item in model" :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)" />
|
||||
<LineItemCard
|
||||
v-for="item in model"
|
||||
: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>
|
||||
<AppButton icon="plus" @click="showAddLineItemModal()">Add Line Item</AppButton>
|
||||
<AppBadge v-if="model.length > 0" :class="{
|
||||
'text-positive': computedTotal === transactionAmount,
|
||||
'text-negative': computedTotal !== transactionAmount
|
||||
}">
|
||||
<AppButton
|
||||
icon="plus"
|
||||
@click="showAddLineItemModal()"
|
||||
>Add Line Item</AppButton
|
||||
>
|
||||
<AppBadge
|
||||
v-if="model.length > 0"
|
||||
:class="{
|
||||
'text-positive': computedTotal === transactionAmount,
|
||||
'text-negative': computedTotal !== transactionAmount,
|
||||
}"
|
||||
>
|
||||
Items Total: {{ formatMoney(computedTotal, currency) }}
|
||||
</AppBadge>
|
||||
</div>
|
||||
|
|
@ -114,19 +126,39 @@ function moveItemDown(idx: number) {
|
|||
<textarea v-model="addLineItemDescription"></textarea>
|
||||
</FormControl>
|
||||
<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 label="Quantity">
|
||||
<input type="number" step="1" min="1" v-model="addLineItemQuantity" />
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
v-model="addLineItemQuantity"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl label="Category">
|
||||
<CategorySelect v-model="addLineItemCategoryId" @category-selected="c => selectedCategory = c" />
|
||||
<CategorySelect
|
||||
v-model="addLineItemCategoryId"
|
||||
@category-selected="(c) => (selectedCategory = c)"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
</template>
|
||||
<template v-slot:buttons>
|
||||
<AppButton @click="addLineItem()" :disabled="!canAddLineItem()">Add</AppButton>
|
||||
<AppButton button-style="secondary" @click="addLineItemModal?.close()">Cancel</AppButton>
|
||||
<AppButton
|
||||
@click="addLineItem()"
|
||||
:disabled="!canAddLineItem()"
|
||||
>Add</AppButton
|
||||
>
|
||||
<AppButton
|
||||
button-style="secondary"
|
||||
@click="addLineItemModal?.close()"
|
||||
>Cancel</AppButton
|
||||
>
|
||||
</template>
|
||||
</ModalWrapper>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<table class="app-properties-table">
|
||||
<tbody>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
<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 }>()
|
||||
</script>
|
||||
<template>
|
||||
<AppBadge>
|
||||
<span class="tag-label-hashtag">#</span>
|
||||
{{ tag }}
|
||||
<font-awesome-icon v-if="deletable" icon="fa-x" class="tag-label-delete"
|
||||
@click="$emit('deleted')"></font-awesome-icon>
|
||||
<font-awesome-icon
|
||||
v-if="deletable"
|
||||
icon="fa-x"
|
||||
class="tag-label-delete"
|
||||
@click="$emit('deleted')"
|
||||
></font-awesome-icon>
|
||||
</AppBadge>
|
||||
</template>
|
||||
<style lang="css">
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import { formatMoney } from '@/api/data';
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import type { TransactionsListItem } from '@/api/transaction';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import CategoryLabel from './CategoryLabel.vue';
|
||||
import { computed, type Ref } from 'vue';
|
||||
import AppBadge from './common/AppBadge.vue';
|
||||
import { formatMoney } from '@/api/data'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import type { TransactionsListItem } from '@/api/transaction'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import CategoryLabel from './CategoryLabel.vue'
|
||||
import { computed, type Ref } from 'vue'
|
||||
import AppBadge from './common/AppBadge.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
type MoneyStyle = "positive" | "negative" | "neutral"
|
||||
type MoneyStyle = 'positive' | 'negative' | 'neutral'
|
||||
|
||||
const props = defineProps<{ tx: TransactionsListItem }>()
|
||||
// Defines the style to use for money based on which accounts are involved.
|
||||
const moneyStyle: Ref<MoneyStyle> = computed(() => {
|
||||
if (props.tx.debitedAccount !== null && props.tx.creditedAccount === null) {
|
||||
return "positive"
|
||||
return 'positive'
|
||||
} else if (props.tx.creditedAccount !== null && props.tx.debitedAccount === null) {
|
||||
return "negative"
|
||||
return 'negative'
|
||||
}
|
||||
return "neutral"
|
||||
return 'neutral'
|
||||
})
|
||||
|
||||
function goToTransaction() {
|
||||
|
|
@ -29,7 +29,10 @@ function goToTransaction() {
|
|||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="transaction-card" @click="goToTransaction()">
|
||||
<div
|
||||
class="transaction-card"
|
||||
@click="goToTransaction()"
|
||||
>
|
||||
<!-- Top row contains timestamp and amount. -->
|
||||
<div class="transaction-card-top-row">
|
||||
<div>
|
||||
|
|
@ -39,16 +42,25 @@ function goToTransaction() {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-mono align-right font-size-small" :class="{
|
||||
'text-positive': moneyStyle === 'positive',
|
||||
'text-negative': moneyStyle === 'negative'
|
||||
}">
|
||||
<div
|
||||
class="font-mono align-right font-size-small"
|
||||
:class="{
|
||||
'text-positive': moneyStyle === 'positive',
|
||||
'text-negative': moneyStyle === 'negative',
|
||||
}"
|
||||
>
|
||||
{{ formatMoney(tx.amount, tx.currency) }}
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -61,7 +73,11 @@ function goToTransaction() {
|
|||
|
||||
<!-- Bottom row contains other links. -->
|
||||
<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>
|
||||
</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">
|
||||
type BadgeSize = "sm" | "md" | "lg";
|
||||
type BadgeStyle = "normal" | "positive" | "warning" | "negative"
|
||||
type BadgeSize = 'sm' | 'md' | 'lg'
|
||||
type BadgeStyle = 'normal' | 'positive' | 'warning' | 'negative'
|
||||
|
||||
defineProps<{ size?: BadgeSize, color?: BadgeStyle }>()
|
||||
defineProps<{ size?: BadgeSize; color?: BadgeStyle }>()
|
||||
</script>
|
||||
<template>
|
||||
<span class="app-badge" :class="{
|
||||
'app-badge-sm': size === 'sm',
|
||||
'app-badge-lg': size === 'lg'
|
||||
}">
|
||||
<span
|
||||
class="app-badge"
|
||||
:class="{
|
||||
'app-badge-sm': size === 'sm',
|
||||
'app-badge-lg': size === 'lg',
|
||||
}"
|
||||
>
|
||||
<slot></slot>
|
||||
</span>
|
||||
</template>
|
||||
|
|
@ -22,7 +25,7 @@ defineProps<{ size?: BadgeSize, color?: BadgeStyle }>()
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
.app-badge+.app-badge {
|
||||
.app-badge + .app-badge {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +1,48 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed } from 'vue'
|
||||
|
||||
|
||||
export type ButtonTheme = "primary" | "secondary"
|
||||
export type ButtonType = "button" | "submit" | "reset"
|
||||
export type ButtonSize = "sm" | "md" | "lg"
|
||||
export type ButtonTheme = 'primary' | 'secondary'
|
||||
export type ButtonType = 'button' | 'submit' | 'reset'
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
interface Props {
|
||||
theme?: ButtonTheme,
|
||||
size?: ButtonSize,
|
||||
icon?: string,
|
||||
type?: ButtonType,
|
||||
theme?: ButtonTheme
|
||||
size?: ButtonSize
|
||||
icon?: string
|
||||
type?: ButtonType
|
||||
disabled?: boolean
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
buttonStyle: "primary",
|
||||
buttonType: "button",
|
||||
size: "md",
|
||||
disabled: false
|
||||
buttonStyle: 'primary',
|
||||
buttonType: 'button',
|
||||
size: 'md',
|
||||
disabled: false,
|
||||
})
|
||||
defineEmits(['click'])
|
||||
|
||||
const buttonStyle = computed(() => ({
|
||||
'app-button-theme-secondary': props.theme === "secondary",
|
||||
'app-button-theme-secondary': props.theme === 'secondary',
|
||||
'app-button-disabled': props.disabled,
|
||||
'app-button-size-sm': props.size === "sm",
|
||||
'app-button-size-lg': props.size === "lg"
|
||||
'app-button-size-sm': props.size === 'sm',
|
||||
'app-button-size-lg': props.size === 'lg',
|
||||
}))
|
||||
</script>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<slot></slot>
|
||||
</button>
|
||||
|
|
@ -41,22 +51,22 @@ const buttonStyle = computed(() => ({
|
|||
.app-button {
|
||||
background-color: #111827;
|
||||
border: 1px solid transparent;
|
||||
border-radius: .75rem;
|
||||
border-radius: 0.75rem;
|
||||
box-sizing: border-box;
|
||||
color: #FFFFFF;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
font-family: "OpenSans", sans-serif;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1rem;
|
||||
padding: .75rem 1.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-align: center;
|
||||
text-decoration: none #6B7280 solid;
|
||||
text-decoration: none #6b7280 solid;
|
||||
text-decoration-thickness: auto;
|
||||
transition-duration: .2s;
|
||||
transition-duration: 0.2s;
|
||||
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;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { AttachmentApiClient } from '@/api/attachment';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import AppButton from './AppButton.vue';
|
||||
|
||||
import { AttachmentApiClient } from '@/api/attachment'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppButton from './AppButton.vue'
|
||||
|
||||
interface AttachmentInfo {
|
||||
filename: string
|
||||
|
|
@ -14,8 +13,8 @@ interface AttachmentInfo {
|
|||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const props = defineProps<{ attachment: AttachmentInfo, disabled?: boolean }>()
|
||||
defineEmits<{ "deleted": void }>()
|
||||
const props = defineProps<{ attachment: AttachmentInfo; disabled?: boolean }>()
|
||||
defineEmits<{ deleted: void }>()
|
||||
|
||||
function downloadFile() {
|
||||
const api = new AttachmentApiClient(route)
|
||||
|
|
@ -25,15 +24,25 @@ function downloadFile() {
|
|||
</script>
|
||||
<template>
|
||||
<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>{{ attachment.filename }}</div>
|
||||
<div style="font-size: 0.75rem;">{{ attachment.contentType }}</div>
|
||||
<div style="font-size: 0.75rem">{{ attachment.contentType }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<AppButton icon="download" type="button" @click="downloadFile()" v-if="attachment.id !== undefined" />
|
||||
<AppButton v-if="!disabled" type="button" icon="trash" @click="$emit('deleted')" />
|
||||
<AppButton
|
||||
icon="download"
|
||||
type="button"
|
||||
@click="downloadFile()"
|
||||
v-if="attachment.id !== undefined"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="!disabled"
|
||||
type="button"
|
||||
icon="trash"
|
||||
@click="$emit('deleted')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<div class="button-bar">
|
||||
<slot></slot>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue';
|
||||
import { useTemplateRef } from 'vue'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue'
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
|
|
@ -22,7 +22,11 @@ defineExpose({ confirm })
|
|||
</template>
|
||||
<template v-slot:buttons>
|
||||
<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>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AttachmentRow from './AttachmentRow.vue';
|
||||
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AttachmentRow from './AttachmentRow.vue'
|
||||
|
||||
interface ExistingFile {
|
||||
id: number
|
||||
|
|
@ -14,8 +14,8 @@ abstract class FileListItem {
|
|||
constructor(
|
||||
public readonly filename: string,
|
||||
public readonly contentType: string,
|
||||
public readonly size: number
|
||||
) { }
|
||||
public readonly size: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
class ExistingFileListItem extends FileListItem {
|
||||
|
|
@ -35,13 +35,13 @@ class NewFileListItem extends FileListItem {
|
|||
}
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean,
|
||||
disabled?: boolean
|
||||
initialFiles?: ExistingFile[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
initialFiles: () => []
|
||||
initialFiles: () => [],
|
||||
})
|
||||
const previousInitialFiles: Ref<ExistingFile[]> = ref([])
|
||||
const fileInput = useTemplateRef('fileInput')
|
||||
|
|
@ -53,27 +53,36 @@ const removedFiles = defineModel<number[]>('removed-files', { default: [] })
|
|||
const files: Ref<FileListItem[]> = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
|
||||
files.value = props.initialFiles.map((f) => new ExistingFileListItem(f))
|
||||
previousInitialFiles.value = [...props.initialFiles]
|
||||
// If input initial files change, reset the file selector to just those.
|
||||
watch(() => props.initialFiles, () => {
|
||||
if (previousInitialFiles.value !== props.initialFiles) {
|
||||
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
|
||||
previousInitialFiles.value = [...props.initialFiles]
|
||||
}
|
||||
})
|
||||
watch(
|
||||
() => 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.
|
||||
watch(() => files, () => {
|
||||
// Compute the set of uploaded files as just any newly uploaded file list item.
|
||||
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.
|
||||
const retainedExistingFileIds = files.value
|
||||
.filter(f => f instanceof ExistingFileListItem)
|
||||
.map(f => f.id)
|
||||
removedFiles.value = props.initialFiles
|
||||
.filter(f => !retainedExistingFileIds.includes(f.id))
|
||||
.map(f => f.id)
|
||||
}, { deep: true })
|
||||
watch(
|
||||
() => files,
|
||||
() => {
|
||||
// Compute the set of uploaded files as just any newly uploaded file list item.
|
||||
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.
|
||||
const retainedExistingFileIds = files.value
|
||||
.filter((f) => f instanceof ExistingFileListItem)
|
||||
.map((f) => f.id)
|
||||
removedFiles.value = props.initialFiles
|
||||
.filter((f) => !retainedExistingFileIds.includes(f.id))
|
||||
.map((f) => f.id)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
})
|
||||
|
||||
function onFileInputChanged(e: Event) {
|
||||
|
|
@ -105,14 +114,32 @@ function onFileDeleteClicked(idx: number) {
|
|||
<template>
|
||||
<div class="file-selector">
|
||||
<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>
|
||||
<input id="fileInput" type="file" capture="environment" multiple @change="onFileInputChanged"
|
||||
style="display: none;" ref="fileInput" :disabled="disabled" />
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
capture="environment"
|
||||
multiple
|
||||
@change="onFileInputChanged"
|
||||
style="display: none"
|
||||
ref="fileInput"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, type Ref } from 'vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import { ref, type Ref } from 'vue'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
|
||||
defineProps<{ id?: string }>()
|
||||
|
||||
const dialog: Ref<HTMLDialogElement | null> = ref(null)
|
||||
|
||||
function show(): Promise<string | undefined> {
|
||||
return new Promise(resolve => {
|
||||
return new Promise((resolve) => {
|
||||
dialog.value?.showModal()
|
||||
dialog.value?.addEventListener('close', () => {
|
||||
resolve(dialog.value?.returnValue)
|
||||
}, { once: true })
|
||||
dialog.value?.addEventListener(
|
||||
'close',
|
||||
() => {
|
||||
resolve(dialog.value?.returnValue)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -23,13 +27,20 @@ defineExpose({ show, close })
|
|||
</script>
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<dialog ref="dialog" class="app-modal-dialog" :id="id">
|
||||
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="app-modal-dialog"
|
||||
:id="id"
|
||||
>
|
||||
<slot></slot>
|
||||
|
||||
<div class="app-modal-dialog-actions">
|
||||
<slot name="buttons">
|
||||
<AppButton button-style="secondary" @click="close()">Close</AppButton>
|
||||
<AppButton
|
||||
button-style="secondary"
|
||||
@click="close()"
|
||||
>Close</AppButton
|
||||
>
|
||||
</slot>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { Page, PageRequest } from '@/api/pagination';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
|
||||
import type { Page, PageRequest } from '@/api/pagination'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
|
||||
const props = defineProps<{ page?: Page<unknown> }>()
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -13,7 +12,7 @@ function updatePage(newPage: number) {
|
|||
emit('update', {
|
||||
page: newPage,
|
||||
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', {
|
||||
page: props.page.pageRequest.page + step,
|
||||
size: props.page.pageRequest.size,
|
||||
sorts: props.page.pageRequest.sorts
|
||||
sorts: props.page.pageRequest.sorts,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<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
|
||||
</AppButton>
|
||||
|
||||
<AppButton size="sm" :disabled="!page || page.isFirst" @click="incrementPage(-1)">
|
||||
<AppButton
|
||||
size="sm"
|
||||
:disabled="!page || page.isFirst"
|
||||
@click="incrementPage(-1)"
|
||||
>
|
||||
Previous Page
|
||||
</AppButton>
|
||||
|
||||
<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
|
||||
</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
|
||||
</AppButton>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
defineEmits<{ 'submit': void }>()
|
||||
defineEmits<{ submit: void }>()
|
||||
</script>
|
||||
<template>
|
||||
<form @submit.prevent="e => $emit('submit')">
|
||||
<form @submit.prevent="(e) => $emit('submit')">
|
||||
<slot></slot>
|
||||
</form>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import AppButton from '../AppButton.vue';
|
||||
import AppButton from '../AppButton.vue'
|
||||
|
||||
defineEmits<{ 'cancel': void }>()
|
||||
defineProps<{ submitText?: string, cancelText?: string, disabled?: boolean }>()
|
||||
defineEmits<{ cancel: void }>()
|
||||
defineProps<{ submitText?: string; cancelText?: string; disabled?: boolean }>()
|
||||
</script>
|
||||
<template>
|
||||
<div class="app-form-actions">
|
||||
<AppButton type="submit" :disabled="disabled ?? false">{{ submitText ?? 'Submit' }}</AppButton>
|
||||
<AppButton theme="secondary" @click="$emit('cancel')">{{ cancelText ?? 'Cancel'
|
||||
}}</AppButton>
|
||||
<AppButton
|
||||
type="submit"
|
||||
:disabled="disabled ?? false"
|
||||
>{{ submitText ?? 'Submit' }}</AppButton
|
||||
>
|
||||
<AppButton
|
||||
theme="secondary"
|
||||
@click="$emit('cancel')"
|
||||
>{{ cancelText ?? 'Cancel' }}</AppButton
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="css">
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ defineProps<{
|
|||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.app-form-control>label {
|
||||
.app-form-control > label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.9rem;
|
||||
|
|
@ -26,19 +26,19 @@ defineProps<{
|
|||
|
||||
/* Styles for different form controls under here: */
|
||||
|
||||
.app-form-control>label>input {
|
||||
.app-form-control > label > input {
|
||||
font-size: 16px;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.app-form-control>label>textarea {
|
||||
.app-form-control > label > textarea {
|
||||
font-size: 16px;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.app-form-control>label>select {
|
||||
.app-form-control > label > select {
|
||||
font-size: 16px;
|
||||
font-family: 'OpenSans', sans-serif;
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<div class="app-form-group">
|
||||
<slot></slot>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import { AccountApiClient, AccountHistoryItemType, type Account, type AccountHistoryItem, type AccountHistoryJournalEntryItem, type AccountHistoryValueRecordItem } from '@/api/account';
|
||||
import type { PageRequest } from '@/api/pagination';
|
||||
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';
|
||||
import {
|
||||
AccountApiClient,
|
||||
AccountHistoryItemType,
|
||||
type Account,
|
||||
type AccountHistoryItem,
|
||||
type AccountHistoryJournalEntryItem,
|
||||
type AccountHistoryValueRecordItem,
|
||||
} from '@/api/account'
|
||||
import type { PageRequest } from '@/api/pagination'
|
||||
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 router = useRouter()
|
||||
const props = defineProps<{ account: Account }>()
|
||||
const historyItems: Ref<AccountHistoryItem[]> = ref([])
|
||||
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 () => {
|
||||
await loadNextPage()
|
||||
|
|
@ -60,14 +71,18 @@ function asJE(i: AccountHistoryItem): AccountHistoryJournalEntryItem {
|
|||
}
|
||||
|
||||
function canView(item: AccountHistoryItem) {
|
||||
return item.type === AccountHistoryItemType.JOURNAL_ENTRY ||
|
||||
return (
|
||||
item.type === AccountHistoryItemType.JOURNAL_ENTRY ||
|
||||
item.type === AccountHistoryItemType.VALUE_RECORD
|
||||
)
|
||||
}
|
||||
|
||||
function viewItem(item: AccountHistoryItem) {
|
||||
const profile = getSelectedProfile(route)
|
||||
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) {
|
||||
router.push(`/profiles/${getSelectedProfile(route)}/transactions/${asJE(item).transactionId}`)
|
||||
}
|
||||
|
|
@ -77,30 +92,59 @@ defineExpose({ reload })
|
|||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="item, idx in historyItems" :key="idx">
|
||||
<div
|
||||
v-for="(item, idx) in historyItems"
|
||||
:key="idx"
|
||||
>
|
||||
<div class="history-item">
|
||||
<!-- The main body on the left. -->
|
||||
<div style="flex-grow: 1;">
|
||||
<div class="font-mono font-size-xsmall text-muted" style="margin-bottom: 0.25rem;">
|
||||
<div style="flex-grow: 1">
|
||||
<div
|
||||
class="font-mono font-size-xsmall text-muted"
|
||||
style="margin-bottom: 0.25rem"
|
||||
>
|
||||
{{ new Date(item.timestamp).toLocaleString() }}
|
||||
</div>
|
||||
|
||||
<ValueRecordHistoryItem v-if="item.type === AccountHistoryItemType.VALUE_RECORD" :item="asVR(item)"
|
||||
:account-id="account.id" />
|
||||
<ValueRecordHistoryItem
|
||||
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>
|
||||
<!-- A "view item" button on the right. -->
|
||||
<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>
|
||||
|
||||
<HistoryItemDivider v-if="idx + 1 < historyItems.length" />
|
||||
</div>
|
||||
<div class="align-center">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<div class="history-item-divider">
|
||||
<div class="history-item-divider-line"></div>
|
||||
<div class="history-item-divider-label">
|
||||
|
||||
</div>
|
||||
<div class="history-item-divider-label"></div>
|
||||
<div class="history-item-divider-line"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { AccountJournalEntryType, type AccountHistoryJournalEntryItem } from '@/api/account'
|
||||
import { formatMoney } from '@/api/data';
|
||||
import AppBadge from '../common/AppBadge.vue';
|
||||
import { formatMoney } from '@/api/data'
|
||||
import AppBadge from '../common/AppBadge.vue'
|
||||
|
||||
defineProps<{ item: AccountHistoryJournalEntryItem }>()
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<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
|
||||
</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
|
||||
</span>
|
||||
to this account via Transaction #{{ item.transactionId }}.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { type AccountHistoryValueRecordItem } from '@/api/account';
|
||||
import { formatMoney } from '@/api/data';
|
||||
import AppBadge from '../common/AppBadge.vue';
|
||||
|
||||
defineProps<{ item: AccountHistoryValueRecordItem, accountId: number }>()
|
||||
import { type AccountHistoryValueRecordItem } from '@/api/account'
|
||||
import { formatMoney } from '@/api/data'
|
||||
import AppBadge from '../common/AppBadge.vue'
|
||||
|
||||
defineProps<{ item: AccountHistoryValueRecordItem; accountId: number }>()
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
Value recorded as <AppBadge class="font-mono">{{
|
||||
formatMoney(item.value,
|
||||
item.currency) }}
|
||||
</AppBadge>
|
||||
Value recorded as
|
||||
<AppBadge class="font-mono">{{ formatMoney(item.value, item.currency) }} </AppBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import { AccountApiClient, type Account } from '@/api/account';
|
||||
import { formatMoney } from '@/api/data';
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import AddValueRecordModal from '@/components/AddValueRecordModal.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppPage from '@/components/common/AppPage.vue';
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue';
|
||||
import AccountHistory from '@/components/history/AccountHistory.vue';
|
||||
import PropertiesTable from '@/components/PropertiesTable.vue';
|
||||
import { showConfirm } from '@/util/alert';
|
||||
import { onMounted, ref, useTemplateRef, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { AccountApiClient, type Account } from '@/api/account'
|
||||
import { formatMoney } from '@/api/data'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import AddValueRecordModal from '@/components/AddValueRecordModal.vue'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AppPage from '@/components/common/AppPage.vue'
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue'
|
||||
import AccountHistory from '@/components/history/AccountHistory.vue'
|
||||
import PropertiesTable from '@/components/PropertiesTable.vue'
|
||||
import { showConfirm } from '@/util/alert'
|
||||
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const addValueRecordModal = useTemplateRef("addValueRecordModal")
|
||||
const addValueRecordModal = useTemplateRef('addValueRecordModal')
|
||||
const history = useTemplateRef('history')
|
||||
const account: Ref<Account | null> = ref(null)
|
||||
|
||||
|
|
@ -32,7 +32,11 @@ onMounted(async () => {
|
|||
|
||||
async function deleteAccount() {
|
||||
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 {
|
||||
const api = new AccountApiClient(route)
|
||||
await api.deleteAccount(account.value.id)
|
||||
|
|
@ -92,14 +96,29 @@ async function addValueRecord() {
|
|||
</PropertiesTable>
|
||||
<ButtonBar>
|
||||
<AppButton @click="addValueRecord()">Record Value</AppButton>
|
||||
<AppButton icon="wrench"
|
||||
@click="router.push(`/profiles/${getSelectedProfile(route)}/accounts/${account?.id}/edit`)">
|
||||
Edit</AppButton>
|
||||
<AppButton icon="trash" @click="deleteAccount()">Delete</AppButton>
|
||||
<AppButton
|
||||
icon="wrench"
|
||||
@click="router.push(`/profiles/${getSelectedProfile(route)}/accounts/${account?.id}/edit`)"
|
||||
>
|
||||
Edit</AppButton
|
||||
>
|
||||
<AppButton
|
||||
icon="trash"
|
||||
@click="deleteAccount()"
|
||||
>Delete</AppButton
|
||||
>
|
||||
</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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import { TransactionApiClient, type TransactionCategory, type TransactionCategoryTree } from '@/api/transaction';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppPage from '@/components/common/AppPage.vue';
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue';
|
||||
import CategoryDisplayItem from '@/components/CategoryDisplayItem.vue';
|
||||
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';
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import {
|
||||
TransactionApiClient,
|
||||
type TransactionCategory,
|
||||
type TransactionCategoryTree,
|
||||
} from '@/api/transaction'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AppPage from '@/components/common/AppPage.vue'
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue'
|
||||
import CategoryDisplayItem from '@/components/CategoryDisplayItem.vue'
|
||||
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()
|
||||
|
||||
|
|
@ -42,7 +46,9 @@ async function editCategory(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) {
|
||||
try {
|
||||
showLoader()
|
||||
|
|
@ -69,18 +75,31 @@ async function addCategory() {
|
|||
<template>
|
||||
<AppPage title="Categories">
|
||||
<p>
|
||||
Categories are used to group related transactions for your own organization,
|
||||
as well as analytics. Categories are structured hierarchically, where each
|
||||
category could have zero or more sub-categories.
|
||||
Categories are used to group related transactions for your own organization, as well as
|
||||
analytics. Categories are structured hierarchically, where each category could have zero or
|
||||
more sub-categories.
|
||||
</p>
|
||||
<div>
|
||||
<CategoryDisplayItem v-for="category in categories" :key="category.id" :category="category" :editable="true"
|
||||
@edited="editCategory" @deleted="deleteCategory" />
|
||||
<CategoryDisplayItem
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:category="category"
|
||||
:editable="true"
|
||||
@edited="editCategory"
|
||||
@deleted="deleteCategory"
|
||||
/>
|
||||
</div>
|
||||
<ButtonBar>
|
||||
<AppButton icon="plus" @click="addCategory()">Add Category</AppButton>
|
||||
<AppButton
|
||||
icon="plus"
|
||||
@click="addCategory()"
|
||||
>Add Category</AppButton
|
||||
>
|
||||
</ButtonBar>
|
||||
|
||||
<EditCategoryModal ref="editCategoryModal" :category="editedCategory" />
|
||||
<EditCategoryModal
|
||||
ref="editCategoryModal"
|
||||
:category="editedCategory"
|
||||
/>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { AuthApiClient } from '@/api/auth';
|
||||
import { ApiError } from '@/api/base';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppForm from '@/components/common/form/AppForm.vue';
|
||||
import FormControl from '@/components/common/form/FormControl.vue';
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { showAlert } from '@/util/alert';
|
||||
import { hideLoader, showLoader } from '@/util/loader';
|
||||
import { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { AuthApiClient } from '@/api/auth'
|
||||
import { ApiError } from '@/api/base'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AppForm from '@/components/common/form/AppForm.vue'
|
||||
import FormControl from '@/components/common/form/FormControl.vue'
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { showAlert } from '@/util/alert'
|
||||
import { hideLoader, showLoader } from '@/util/loader'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
|
@ -28,7 +28,7 @@ async function doLogin() {
|
|||
const token = await apiClient.login(username.value, password.value)
|
||||
authStore.onUserLoggedIn(username.value, token)
|
||||
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)
|
||||
} else {
|
||||
await router.replace('/')
|
||||
|
|
@ -69,47 +69,76 @@ function isDataValid() {
|
|||
|
||||
function generateSampleData() {
|
||||
fetch(import.meta.env.VITE_API_BASE_URL + '/sample-data', {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="app-login-panel">
|
||||
<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;">
|
||||
<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">
|
||||
<em>Personal finance for the modern era.</em>
|
||||
</p>
|
||||
<AppForm @submit="doLogin()">
|
||||
<FormGroup>
|
||||
<FormControl label="Username" class="login-control">
|
||||
<input class="login-input" type="text" v-model="username" :disabled="disableForm" />
|
||||
<FormControl
|
||||
label="Username"
|
||||
class="login-control"
|
||||
>
|
||||
<input
|
||||
class="login-input"
|
||||
type="text"
|
||||
v-model="username"
|
||||
:disabled="disableForm"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl label="Password" class="login-control">
|
||||
<input class="login-input" id="password-input" type="password" v-model="password" :disabled="disableForm" />
|
||||
<FormControl
|
||||
label="Password"
|
||||
class="login-control"
|
||||
>
|
||||
<input
|
||||
class="login-input"
|
||||
id="password-input"
|
||||
type="password"
|
||||
v-model="password"
|
||||
:disabled="disableForm"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
<div style="display: flex; margin-left: 1rem; margin-right: 1rem;">
|
||||
<AppButton type="submit" :disabled="disableForm || !isDataValid()" style="flex-grow: 1;">Login
|
||||
<div style="display: flex; margin-left: 1rem; margin-right: 1rem">
|
||||
<AppButton
|
||||
type="submit"
|
||||
:disabled="disableForm || !isDataValid()"
|
||||
style="flex-grow: 1"
|
||||
>Login
|
||||
</AppButton>
|
||||
<AppButton type="button" theme="secondary" :disabled="disableForm || !isDataValid()" @click="doRegister()">
|
||||
Register</AppButton>
|
||||
<AppButton
|
||||
type="button"
|
||||
theme="secondary"
|
||||
:disabled="disableForm || !isDataValid()"
|
||||
@click="doRegister()"
|
||||
>
|
||||
Register</AppButton
|
||||
>
|
||||
</div>
|
||||
<div v-if="isDev">
|
||||
<AppButton type="button" @click="generateSampleData()">Generate Sample Data</AppButton>
|
||||
<AppButton
|
||||
type="button"
|
||||
@click="generateSampleData()"
|
||||
>Generate Sample Data</AppButton
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Disclaimer note that this project is still a work-in-progress! -->
|
||||
<div class="font-size-small mx-1">
|
||||
<p>
|
||||
<strong>Note:</strong> Finnow is still under development, and may be
|
||||
prone to bugs or unexpected data loss. Proceed to register and use
|
||||
this service at your own risk.
|
||||
<strong>Note:</strong> Finnow is still under development, and may be prone to bugs or
|
||||
unexpected data loss. Proceed to register and use this service at your own risk.
|
||||
</p>
|
||||
<p>
|
||||
Data is stored securely in protected cloud storage, but may be
|
||||
accessed by a system administrator in case of errors or debugging.
|
||||
Please <strong>DO NOT</strong> store any sensitive financial
|
||||
credentials that you aren't okay with losing or potentially being
|
||||
Data is stored securely in protected cloud storage, but may be accessed by a system
|
||||
administrator in case of errors or debugging. Please <strong>DO NOT</strong> store any
|
||||
sensitive financial credentials that you aren't okay with losing or potentially being
|
||||
leaked. Finnow is not responsible for any lost or compromised data.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { AuthApiClient } from '@/api/auth';
|
||||
import { ApiError } from '@/api/base';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppPage from '@/components/common/AppPage.vue';
|
||||
import AppForm from '@/components/common/form/AppForm.vue';
|
||||
import FormControl from '@/components/common/form/FormControl.vue';
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue';
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { showAlert, showConfirm } from '@/util/alert';
|
||||
import { hideLoader, showLoader } from '@/util/loader';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { AuthApiClient } from '@/api/auth'
|
||||
import { ApiError } from '@/api/base'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AppPage from '@/components/common/AppPage.vue'
|
||||
import AppForm from '@/components/common/form/AppForm.vue'
|
||||
import FormControl from '@/components/common/form/FormControl.vue'
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue'
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { showAlert, showConfirm } from '@/util/alert'
|
||||
import { hideLoader, showLoader } from '@/util/loader'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
|
|
@ -19,7 +19,11 @@ const currentPassword = ref('')
|
|||
const newPassword = ref('')
|
||||
|
||||
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()
|
||||
try {
|
||||
await api.deleteMyUser()
|
||||
|
|
@ -58,15 +62,21 @@ async function doChangePassword() {
|
|||
<template>
|
||||
<AppPage title="My User">
|
||||
<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>
|
||||
There's not really that much to do with your user account specifically,
|
||||
all important settings are profile-specific.
|
||||
There's not really that much to do with your user account specifically, all important settings
|
||||
are profile-specific.
|
||||
</p>
|
||||
<div style="text-align: right;">
|
||||
<div style="text-align: right">
|
||||
<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>
|
||||
|
||||
<!-- Modal for changing the user's password. -->
|
||||
|
|
@ -74,24 +84,35 @@ async function doChangePassword() {
|
|||
<template v-slot:default>
|
||||
<AppForm>
|
||||
<h2>Change Password</h2>
|
||||
<p>
|
||||
Change the password used to log into your user account.
|
||||
</p>
|
||||
<p>Change the password used to log into your user account.</p>
|
||||
<FormGroup>
|
||||
<FormControl label="Current Password">
|
||||
<input type="password" v-model="currentPassword" minlength="8" />
|
||||
<input
|
||||
type="password"
|
||||
v-model="currentPassword"
|
||||
minlength="8"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<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>
|
||||
</FormGroup>
|
||||
</AppForm>
|
||||
</template>
|
||||
<template v-slot:buttons>
|
||||
<AppButton @click="doChangePassword()">Change</AppButton>
|
||||
<AppButton theme="secondary" @click="changePasswordModal?.close()">Cancel</AppButton>
|
||||
<AppButton
|
||||
theme="secondary"
|
||||
@click="changePasswordModal?.close()"
|
||||
>Cancel</AppButton
|
||||
>
|
||||
</template>
|
||||
</ModalWrapper>
|
||||
</AppPage>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { AccountApiClient, type Account } from '@/api/account';
|
||||
import { ProfileApiClient, type Profile } from '@/api/profile';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { AccountApiClient, type Account } from '@/api/account'
|
||||
import { ProfileApiClient, type Profile } from '@/api/profile'
|
||||
import { onMounted, ref, type Ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
|
@ -36,14 +36,15 @@ onMounted(async () => {
|
|||
<template>
|
||||
<div class="app-page-container">
|
||||
<h1 class="app-page-title">Profile {{ profile?.name }}</h1>
|
||||
<p>
|
||||
This is the page for the profile!
|
||||
</p>
|
||||
<p>This is the page for the profile!</p>
|
||||
<ul>
|
||||
<li v-for="account in accounts" :key="account.id">
|
||||
<li
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
>
|
||||
<p>
|
||||
Account {{ account.id }} for currency {{ account.currency }}
|
||||
Number suffix: {{ account.numberSuffix }}
|
||||
Account {{ account.id }} for currency {{ account.currency }} Number suffix:
|
||||
{{ account.numberSuffix }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { ProfileApiClient, type Profile } from '@/api/profile';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppPage from '@/components/common/AppPage.vue';
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue';
|
||||
import { onMounted, type Ref, ref, useTemplateRef } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ProfileApiClient, type Profile } from '@/api/profile'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AppPage from '@/components/common/AppPage.vue'
|
||||
import ModalWrapper from '@/components/common/ModalWrapper.vue'
|
||||
import { onMounted, type Ref, ref, useTemplateRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
|
||||
const profiles: Ref<Profile[]> = ref([])
|
||||
|
|
@ -42,11 +42,20 @@ async function addProfile() {
|
|||
</script>
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<AppButton icon="plus" @click="addProfileModal?.show()">Add a new profile</AppButton>
|
||||
<div style="text-align: right">
|
||||
<AppButton
|
||||
icon="plus"
|
||||
@click="addProfileModal?.show()"
|
||||
>Add a new profile</AppButton
|
||||
>
|
||||
</div>
|
||||
|
||||
<ModalWrapper ref="addProfileModal">
|
||||
|
|
@ -54,12 +63,21 @@ async function addProfile() {
|
|||
<h3 class="app-modal-dialog-header">Add Profile</h3>
|
||||
<div>
|
||||
<label for="new-profile-name-input">Enter the name of your new profile.</label>
|
||||
<input type="text" minlength="3" id="new-profile-name-input" v-model="newProfileName" />
|
||||
<input
|
||||
type="text"
|
||||
minlength="3"
|
||||
id="new-profile-name-input"
|
||||
v-model="newProfileName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:buttons>
|
||||
<AppButton @click="addProfile()">Add</AppButton>
|
||||
<AppButton button-style="secondary" @click="addProfileModal?.close()">Cancel</AppButton>
|
||||
<AppButton
|
||||
button-style="secondary"
|
||||
@click="addProfileModal?.close()"
|
||||
>Cancel</AppButton
|
||||
>
|
||||
</template>
|
||||
</ModalWrapper>
|
||||
</AppPage>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import { ApiError } from '@/api/base';
|
||||
import { formatMoney } from '@/api/data';
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import { TransactionApiClient, type TransactionDetail } from '@/api/transaction';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppPage from '@/components/common/AppPage.vue';
|
||||
import CategoryLabel from '@/components/CategoryLabel.vue';
|
||||
import PropertiesTable from '@/components/PropertiesTable.vue';
|
||||
import TagLabel from '@/components/TagLabel.vue';
|
||||
import { showAlert, showConfirm } from '@/util/alert';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import AttachmentRow from '@/components/common/AttachmentRow.vue';
|
||||
import LineItemCard from '@/components/LineItemCard.vue';
|
||||
import { ApiError } from '@/api/base'
|
||||
import { formatMoney } from '@/api/data'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import { TransactionApiClient, type TransactionDetail } from '@/api/transaction'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AppPage from '@/components/common/AppPage.vue'
|
||||
import CategoryLabel from '@/components/CategoryLabel.vue'
|
||||
import PropertiesTable from '@/components/PropertiesTable.vue'
|
||||
import TagLabel from '@/components/TagLabel.vue'
|
||||
import { showAlert, showConfirm } from '@/util/alert'
|
||||
import { onMounted, ref, type Ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AttachmentRow from '@/components/common/AttachmentRow.vue'
|
||||
import LineItemCard from '@/components/LineItemCard.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
|
@ -35,7 +35,9 @@ onMounted(async () => {
|
|||
|
||||
async function deleteTransaction() {
|
||||
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
|
||||
try {
|
||||
await transactionApi.deleteTransaction(transaction.value.id)
|
||||
|
|
@ -46,7 +48,10 @@ async function deleteTransaction() {
|
|||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppPage :title="'Transaction ' + transaction.id" v-if="transaction">
|
||||
<AppPage
|
||||
:title="'Transaction ' + transaction.id"
|
||||
v-if="transaction"
|
||||
>
|
||||
<PropertiesTable>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
|
|
@ -73,7 +78,10 @@ async function deleteTransaction() {
|
|||
<tr v-if="transaction.category">
|
||||
<th>Category</th>
|
||||
<td>
|
||||
<CategoryLabel :category="transaction.category" :clickable="true" />
|
||||
<CategoryLabel
|
||||
:category="transaction.category"
|
||||
:clickable="true"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="transaction.creditedAccount">
|
||||
|
|
@ -91,27 +99,50 @@ async function deleteTransaction() {
|
|||
<tr>
|
||||
<th>Tags</th>
|
||||
<td>
|
||||
<TagLabel v-for="t in transaction.tags" :key="t" :tag="t" />
|
||||
<TagLabel
|
||||
v-for="t in transaction.tags"
|
||||
:key="t"
|
||||
:tag="t"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</PropertiesTable>
|
||||
|
||||
<div v-if="transaction.lineItems.length > 0">
|
||||
<h3>Line Items</h3>
|
||||
<LineItemCard v-for="item of transaction.lineItems" :key="item.idx" :line-item="item"
|
||||
:currency="transaction.currency" :total-count="transaction.lineItems.length" :editable="false" />
|
||||
<LineItemCard
|
||||
v-for="item of transaction.lineItems"
|
||||
:key="item.idx"
|
||||
:line-item="item"
|
||||
:currency="transaction.currency"
|
||||
:total-count="transaction.lineItems.length"
|
||||
:editable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="transaction.attachments.length > 0">
|
||||
<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>
|
||||
<AppButton icon="wrench"
|
||||
@click="router.push(`/profiles/${getSelectedProfile(route)}/transactions/${transaction.id}/edit`)">
|
||||
<AppButton
|
||||
icon="wrench"
|
||||
@click="
|
||||
router.push(`/profiles/${getSelectedProfile(route)}/transactions/${transaction.id}/edit`)
|
||||
"
|
||||
>
|
||||
Edit
|
||||
</AppButton>
|
||||
<AppButton icon="trash" @click="deleteTransaction()">Delete</AppButton>
|
||||
<AppButton
|
||||
icon="trash"
|
||||
@click="deleteTransaction()"
|
||||
>Delete</AppButton
|
||||
>
|
||||
</div>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ bar with some controls and user information, and a router view for all child
|
|||
pages.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { AuthApiClient } from '@/api/auth';
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import { secondsUntilExpired } from '@/api/token-util';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { AuthApiClient } from '@/api/auth'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import { secondsUntilExpired } from '@/api/token-util'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { onMounted, ref, type Ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
|
@ -61,14 +61,25 @@ async function checkAuth() {
|
|||
<div>
|
||||
<header class="app-header-bar">
|
||||
<div>
|
||||
<h1 class="app-header-text" @click="onHeaderClicked()">Finnow</h1>
|
||||
<h1
|
||||
class="app-header-text"
|
||||
@click="onHeaderClicked()"
|
||||
>
|
||||
Finnow
|
||||
</h1>
|
||||
</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>
|
||||
</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>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import ProfileModule from './home/ProfileModule.vue';
|
||||
import AccountsModule from './home/AccountsModule.vue';
|
||||
import TransactionsModule from './home/TransactionsModule.vue';
|
||||
|
||||
import ProfileModule from './home/ProfileModule.vue'
|
||||
import AccountsModule from './home/AccountsModule.vue'
|
||||
import TransactionsModule from './home/TransactionsModule.vue'
|
||||
</script>
|
||||
<template>
|
||||
<div class="app-module-container">
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { AccountApiClient, type AccountValueRecord } from '@/api/account';
|
||||
import { formatMoney } from '@/api/data';
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import AppBadge from '@/components/common/AppBadge.vue';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppPage from '@/components/common/AppPage.vue';
|
||||
import AttachmentRow from '@/components/common/AttachmentRow.vue';
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue';
|
||||
import PropertiesTable from '@/components/PropertiesTable.vue';
|
||||
import { showConfirm } from '@/util/alert';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { AccountApiClient, type AccountValueRecord } from '@/api/account'
|
||||
import { formatMoney } from '@/api/data'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import AppBadge from '@/components/common/AppBadge.vue'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AppPage from '@/components/common/AppPage.vue'
|
||||
import AttachmentRow from '@/components/common/AttachmentRow.vue'
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue'
|
||||
import PropertiesTable from '@/components/PropertiesTable.vue'
|
||||
import { showConfirm } from '@/util/alert'
|
||||
import { onMounted, ref, type Ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
|
@ -30,19 +30,26 @@ onMounted(async () => {
|
|||
|
||||
async function deleteValueRecord() {
|
||||
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
|
||||
const api = new AccountApiClient(route)
|
||||
try {
|
||||
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) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppPage title="Value Record" v-if="valueRecord">
|
||||
<AppPage
|
||||
title="Value Record"
|
||||
v-if="valueRecord"
|
||||
>
|
||||
<PropertiesTable>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
|
|
@ -57,13 +64,22 @@ async function deleteValueRecord() {
|
|||
</PropertiesTable>
|
||||
<div v-if="valueRecord.attachments.length > 0">
|
||||
<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>
|
||||
<ButtonBar>
|
||||
<AppButton type="button" icon="trash" size="sm" @click="deleteValueRecord()">
|
||||
<AppButton
|
||||
type="button"
|
||||
icon="trash"
|
||||
size="sm"
|
||||
@click="deleteValueRecord()"
|
||||
>
|
||||
Delete this record
|
||||
</AppButton>
|
||||
</ButtonBar>
|
||||
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import { TransactionApiClient, type TransactionVendor } from '@/api/transaction';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import AppPage from '@/components/common/AppPage.vue';
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue';
|
||||
import EditVendorModal from '@/components/EditVendorModal.vue';
|
||||
import { showConfirm } from '@/util/alert';
|
||||
import { onMounted, ref, useTemplateRef, type Ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import { TransactionApiClient, type TransactionVendor } from '@/api/transaction'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AppPage from '@/components/common/AppPage.vue'
|
||||
import ButtonBar from '@/components/common/ButtonBar.vue'
|
||||
import EditVendorModal from '@/components/EditVendorModal.vue'
|
||||
import { showConfirm } from '@/util/alert'
|
||||
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const transactionApi = new TransactionApiClient(getSelectedProfile(route))
|
||||
|
|
@ -41,7 +41,9 @@ async function editVendor(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
|
||||
await transactionApi.deleteVendor(vendor.id)
|
||||
await loadVendors()
|
||||
|
|
@ -50,26 +52,42 @@ async function deleteVendor(vendor: TransactionVendor) {
|
|||
<template>
|
||||
<AppPage title="Vendors">
|
||||
<p>
|
||||
Vendors are businesses and other entities with which you exchange money.
|
||||
Adding a vendor to Finnow allows you to track when you interact with that
|
||||
vendor on a transaction.
|
||||
Vendors are businesses and other entities with which you exchange money. Adding a vendor to
|
||||
Finnow allows you to track when you interact with that vendor on a transaction.
|
||||
</p>
|
||||
<table class="app-table">
|
||||
<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.description }}</td>
|
||||
<td style="min-width: 130px;">
|
||||
<AppButton icon="wrench" @click="editVendor(vendor)" />
|
||||
<AppButton icon="trash" @click="deleteVendor(vendor)" />
|
||||
<td style="min-width: 130px">
|
||||
<AppButton
|
||||
icon="wrench"
|
||||
@click="editVendor(vendor)"
|
||||
/>
|
||||
<AppButton
|
||||
icon="trash"
|
||||
@click="deleteVendor(vendor)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ButtonBar>
|
||||
<AppButton button-type="button" icon="plus" @click="addVendor()">Add Vendor</AppButton>
|
||||
<AppButton
|
||||
button-type="button"
|
||||
icon="plus"
|
||||
@click="addVendor()"
|
||||
>Add Vendor</AppButton
|
||||
>
|
||||
</ButtonBar>
|
||||
|
||||
<EditVendorModal ref="editVendorModal" :vendor="editedVendor" />
|
||||
<EditVendorModal
|
||||
ref="editVendorModal"
|
||||
:vendor="editedVendor"
|
||||
/>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { AccountApiClient, AccountTypes, type Account, type AccountType } from '@/api/account';
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import AppPage from '@/components/common/AppPage.vue';
|
||||
import AppForm from '@/components/common/form/AppForm.vue';
|
||||
import FormActions from '@/components/common/form/FormActions.vue';
|
||||
import FormControl from '@/components/common/form/FormControl.vue';
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue';
|
||||
import { computed, onMounted, ref, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { AccountApiClient, AccountTypes, type Account, type AccountType } from '@/api/account'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import AppPage from '@/components/common/AppPage.vue'
|
||||
import AppForm from '@/components/common/form/AppForm.vue'
|
||||
import FormActions from '@/components/common/form/FormActions.vue'
|
||||
import FormControl from '@/components/common/form/FormControl.vue'
|
||||
import FormGroup from '@/components/common/form/FormGroup.vue'
|
||||
import { computed, onMounted, ref, type Ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
|
@ -26,7 +26,7 @@ const description = ref('')
|
|||
|
||||
onMounted(async () => {
|
||||
const accountIdStr = route.params.id
|
||||
if (accountIdStr && typeof (accountIdStr) === 'string') {
|
||||
if (accountIdStr && typeof accountIdStr === 'string') {
|
||||
const accountId = parseInt(accountIdStr)
|
||||
try {
|
||||
loading.value = true
|
||||
|
|
@ -51,7 +51,7 @@ async function doSubmit() {
|
|||
description: description.value,
|
||||
type: accountType.value.id,
|
||||
currency: currency.value,
|
||||
numberSuffix: accountNumberSuffix.value
|
||||
numberSuffix: accountNumberSuffix.value,
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -72,11 +72,22 @@ async function doSubmit() {
|
|||
<AppPage :title="editing ? 'Edit Account' : 'Add Account'">
|
||||
<AppForm @submit="doSubmit()">
|
||||
<FormGroup>
|
||||
<FormControl label="Account Name" style="max-width: 200px;">
|
||||
<input v-model="accountName" :disabled="loading" />
|
||||
<FormControl
|
||||
label="Account Name"
|
||||
style="max-width: 200px"
|
||||
>
|
||||
<input
|
||||
v-model="accountName"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</FormControl>
|
||||
<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.SAVINGS">{{ AccountTypes.SAVINGS.name }}</option>
|
||||
<option :value="AccountTypes.CREDIT_CARD">{{ AccountTypes.CREDIT_CARD.name }}</option>
|
||||
|
|
@ -84,25 +95,46 @@ async function doSubmit() {
|
|||
</select>
|
||||
</FormControl>
|
||||
<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="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormControl label="Account Number Suffix" style="max-width: 200px;">
|
||||
<input id="account-number-suffix-input" v-model="accountNumberSuffix" minlength="4" maxlength="4"
|
||||
:disabled="loading" required />
|
||||
<FormControl
|
||||
label="Account Number Suffix"
|
||||
style="max-width: 200px"
|
||||
>
|
||||
<input
|
||||
id="account-number-suffix-input"
|
||||
v-model="accountNumberSuffix"
|
||||
minlength="4"
|
||||
maxlength="4"
|
||||
:disabled="loading"
|
||||
required
|
||||
/>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
<FormControl label="Description">
|
||||
<textarea id="description-textarea" v-model="description" :disabled="loading"></textarea>
|
||||
<textarea
|
||||
id="description-textarea"
|
||||
v-model="description"
|
||||
:disabled="loading"
|
||||
></textarea>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions @cancel="router.replace(`/profiles/${getSelectedProfile(route)}`)" :disabled="loading"
|
||||
:submit-text="editing ? 'Save' : 'Add'" />
|
||||
<FormActions
|
||||
@cancel="router.replace(`/profiles/${getSelectedProfile(route)}`)"
|
||||
:disabled="loading"
|
||||
:submit-text="editing ? 'Save' : 'Add'"
|
||||
/>
|
||||
</AppForm>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -10,22 +10,29 @@ The form consists of a few main sections:
|
|||
- Tags editor for editing the set of tags.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { AccountApiClient, type Account } from '@/api/account';
|
||||
import { DataApiClient, floatMoneyToInteger, type Currency } from '@/api/data';
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import { TransactionApiClient, type AddTransactionPayload, type TransactionDetail, type TransactionDetailLineItem, type TransactionVendor } from '@/api/transaction';
|
||||
import AppPage from '@/components/common/AppPage.vue';
|
||||
import CategorySelect from '@/components/CategorySelect.vue';
|
||||
import FileSelector from '@/components/common/FileSelector.vue';
|
||||
import AppForm from '@/components/common/form/AppForm.vue';
|
||||
import FormActions from '@/components/common/form/FormActions.vue';
|
||||
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 { AccountApiClient, type Account } from '@/api/account'
|
||||
import { DataApiClient, floatMoneyToInteger, type Currency } from '@/api/data'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import {
|
||||
TransactionApiClient,
|
||||
type AddTransactionPayload,
|
||||
type TransactionDetail,
|
||||
type TransactionDetailLineItem,
|
||||
type TransactionVendor,
|
||||
} from '@/api/transaction'
|
||||
import AppPage from '@/components/common/AppPage.vue'
|
||||
import CategorySelect from '@/components/CategorySelect.vue'
|
||||
import FileSelector from '@/components/common/FileSelector.vue'
|
||||
import AppForm from '@/components/common/form/AppForm.vue'
|
||||
import FormActions from '@/components/common/form/FormActions.vue'
|
||||
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 router = useRouter()
|
||||
|
|
@ -37,20 +44,74 @@ const existingTransaction: Ref<TransactionDetail | null> = ref(null)
|
|||
const editing = computed(() => {
|
||||
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.
|
||||
const allCurrencies: Ref<Currency[]> = ref([])
|
||||
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 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 availableTags = computed(() => {
|
||||
return allTags.value.filter(t => !tags.value.includes(t))
|
||||
return allTags.value.filter((t) => !tags.value.includes(t))
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
|
|
@ -59,7 +120,7 @@ const timestamp = ref('')
|
|||
const amount = ref(0)
|
||||
const currency: Ref<Currency | null> = ref(null)
|
||||
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 creditedAccountId: 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([])
|
||||
|
||||
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
|
||||
})
|
||||
watch(availableCurrencies, (newValue: Currency[]) => {
|
||||
|
|
@ -85,14 +146,12 @@ onMounted(async () => {
|
|||
const dataClient = new DataApiClient()
|
||||
|
||||
// Fetch various collections of data needed for different user choices.
|
||||
dataClient.getCurrencies().then(currencies => allCurrencies.value = currencies)
|
||||
transactionApi.getVendors().then(vendors => availableVendors.value = vendors)
|
||||
transactionApi.getAllTags().then(t => allTags.value = t)
|
||||
accountApi.getAccounts().then(accounts => allAccounts.value = accounts)
|
||||
|
||||
dataClient.getCurrencies().then((currencies) => (allCurrencies.value = currencies))
|
||||
transactionApi.getAllTags().then((t) => (allTags.value = t))
|
||||
accountApi.getAccounts().then((accounts) => (allAccounts.value = accounts))
|
||||
|
||||
const transactionIdStr = route.params.id
|
||||
if (transactionIdStr && typeof (transactionIdStr) === 'string') {
|
||||
if (transactionIdStr && typeof transactionIdStr === 'string') {
|
||||
const transactionId = parseInt(transactionIdStr)
|
||||
try {
|
||||
loading.value = true
|
||||
|
|
@ -120,32 +179,47 @@ async function doSubmit() {
|
|||
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 payload: AddTransactionPayload = {
|
||||
timestamp: localDate.toISOString(),
|
||||
amount: floatMoneyToInteger(amount.value, currency.value),
|
||||
currencyCode: currency.value?.code ?? '',
|
||||
description: description.value,
|
||||
vendorId: vendorId.value,
|
||||
vendorId: vendorId,
|
||||
categoryId: categoryId.value,
|
||||
creditedAccountId: creditedAccountId.value,
|
||||
debitedAccountId: debitedAccountId.value,
|
||||
tags: tags.value,
|
||||
lineItems: lineItems.value.map(i => {
|
||||
lineItems: lineItems.value.map((i) => {
|
||||
return { ...i, categoryId: i.category?.id ?? null }
|
||||
}),
|
||||
attachmentIdsToRemove: removedAttachmentIds.value
|
||||
attachmentIdsToRemove: removedAttachmentIds.value,
|
||||
}
|
||||
|
||||
let savedTransaction = null
|
||||
try {
|
||||
loading.value = true
|
||||
if (existingTransaction.value) {
|
||||
savedTransaction = await transactionApi.updateTransaction(existingTransaction.value?.id, payload, attachmentsToUpload.value)
|
||||
savedTransaction = await transactionApi.updateTransaction(
|
||||
existingTransaction.value?.id,
|
||||
payload,
|
||||
attachmentsToUpload.value,
|
||||
)
|
||||
} else {
|
||||
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) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
|
|
@ -159,7 +233,9 @@ async function doSubmit() {
|
|||
*/
|
||||
function doCancel() {
|
||||
if (editing.value) {
|
||||
router.replace(`/profiles/${getSelectedProfile(route)}/transactions/${existingTransaction.value?.id}`)
|
||||
router.replace(
|
||||
`/profiles/${getSelectedProfile(route)}/transactions/${existingTransaction.value?.id}`,
|
||||
)
|
||||
} else {
|
||||
router.replace(`/profiles/${getSelectedProfile(route)}`)
|
||||
}
|
||||
|
|
@ -182,7 +258,7 @@ function loadValuesFromExistingTransaction(t: TransactionDetail) {
|
|||
amount.value = t.amount / Math.pow(10, t.currency.fractionalDigits)
|
||||
currency.value = t.currency
|
||||
description.value = t.description
|
||||
vendorId.value = t.vendor?.id ?? null
|
||||
vendor.value = t.vendor ?? null
|
||||
categoryId.value = t.category?.id ?? null
|
||||
creditedAccountId.value = t.creditedAccount?.id ?? null
|
||||
debitedAccountId.value = t.debitedAccount?.id ?? null
|
||||
|
|
@ -194,64 +270,7 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
|
|||
const date = new Date(timestamp)
|
||||
date.setMilliseconds(0)
|
||||
const timezoneOffset = new Date().getTimezoneOffset() * 60_000
|
||||
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
|
||||
return new Date(date.getTime() - timezoneOffset).toISOString().slice(0, -1)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
|
|
@ -260,32 +279,53 @@ function isEdited() {
|
|||
<FormGroup>
|
||||
<!-- Basic properties -->
|
||||
<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 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 label="Currency">
|
||||
<select v-model="currency" :disabled="loading || availableCurrencies.length === 1">
|
||||
<option v-for="currency in availableCurrencies" :key="currency.code" :value="currency">
|
||||
<select
|
||||
v-model="currency"
|
||||
:disabled="loading || availableCurrencies.length === 1"
|
||||
>
|
||||
<option
|
||||
v-for="currency in availableCurrencies"
|
||||
:key="currency.code"
|
||||
:value="currency"
|
||||
>
|
||||
{{ currency.code }}
|
||||
</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormControl label="Description" style="min-width: 200px;">
|
||||
<textarea v-model="description" :disabled="loading"></textarea>
|
||||
<FormControl
|
||||
label="Description"
|
||||
style="min-width: 200px"
|
||||
>
|
||||
<textarea
|
||||
v-model="description"
|
||||
:disabled="loading"
|
||||
></textarea>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<!-- Vendor & Category -->
|
||||
<FormControl label="Vendor">
|
||||
<select v-model="vendorId" :disabled="loading">
|
||||
<option v-for="vendor in availableVendors" :key="vendor.id" :value="vendor.id">
|
||||
{{ vendor.name }}
|
||||
</option>
|
||||
<option :value="null" :selected="vendorId === null">None</option>
|
||||
</select>
|
||||
<VendorSelect v-model="vendor" />
|
||||
</FormControl>
|
||||
<FormControl label="Category">
|
||||
<CategorySelect v-model="categoryId" />
|
||||
|
|
@ -295,16 +335,30 @@ function isEdited() {
|
|||
<FormGroup>
|
||||
<!-- Accounts -->
|
||||
<FormControl label="Credited Account">
|
||||
<select v-model="creditedAccountId" :disabled="loading">
|
||||
<option v-for="account in availableAccounts" :key="account.id" :value="account.id">
|
||||
<select
|
||||
v-model="creditedAccountId"
|
||||
:disabled="loading"
|
||||
>
|
||||
<option
|
||||
v-for="account in availableAccounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.numberSuffix }})
|
||||
</option>
|
||||
<option :value="null">None</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormControl label="Debited Account">
|
||||
<select v-model="debitedAccountId" :disabled="loading">
|
||||
<option v-for="account in availableAccounts" :key="account.id" :value="account.id">
|
||||
<select
|
||||
v-model="debitedAccountId"
|
||||
:disabled="loading"
|
||||
>
|
||||
<option
|
||||
v-for="account in availableAccounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }} ({{ account.numberSuffix }})
|
||||
</option>
|
||||
<option :value="null">None</option>
|
||||
|
|
@ -312,34 +366,64 @@ function isEdited() {
|
|||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<LineItemsEditor v-if="currency" v-model="lineItems" :currency="currency"
|
||||
:transaction-amount="floatMoneyToInteger(amount, currency)" />
|
||||
<LineItemsEditor
|
||||
v-if="currency"
|
||||
v-model="lineItems"
|
||||
:currency="currency"
|
||||
:transaction-amount="floatMoneyToInteger(amount, currency)"
|
||||
/>
|
||||
|
||||
<FormGroup>
|
||||
<!-- Tags -->
|
||||
<FormControl label="Tags">
|
||||
<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)" />
|
||||
<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)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<input v-model="customTagInput" placeholder="Custom tag..." />
|
||||
<button type="button" @click="addTag()" :disabled="selectedTagToAdd === null && !customTagInputValid">Add
|
||||
Tag</button>
|
||||
<input
|
||||
v-model="customTagInput"
|
||||
placeholder="Custom tag..."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="addTag()"
|
||||
:disabled="selectedTagToAdd === null && !customTagInputValid"
|
||||
>
|
||||
Add Tag
|
||||
</button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<h5>Attachments</h5>
|
||||
<FileSelector :initial-files="existingTransaction?.attachments ?? []"
|
||||
v-model:uploaded-files="attachmentsToUpload" v-model:removed-files="removedAttachmentIds" />
|
||||
<FileSelector
|
||||
:initial-files="existingTransaction?.attachments ?? []"
|
||||
v-model:uploaded-files="attachmentsToUpload"
|
||||
v-model:removed-files="removedAttachmentIds"
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormActions @cancel="doCancel()" :disabled="loading || !isFormValid() || !isEdited()"
|
||||
:submit-text="editing ? 'Save' : 'Add'" />
|
||||
<FormActions
|
||||
@cancel="doCancel()"
|
||||
:disabled="loading || !formValid || !unsavedEdits"
|
||||
:submit-text="editing ? 'Save' : 'Add'"
|
||||
/>
|
||||
</AppForm>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -19,12 +19,15 @@ const totalOwed: Ref<CurrencyBalance[]> = computed(() => {
|
|||
const totals: CurrencyBalance[] = []
|
||||
for (const acc of accounts.value) {
|
||||
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 })
|
||||
}
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -33,35 +36,56 @@ const totalOwed: Ref<CurrencyBalance[]> = computed(() => {
|
|||
|
||||
onMounted(async () => {
|
||||
const accountApi = new AccountApiClient(route)
|
||||
accountApi.getAccounts().then(result => accounts.value = result)
|
||||
.catch(err => console.error(err))
|
||||
accountApi.getTotalBalances().then(result => {
|
||||
totalBalances.value = result
|
||||
})
|
||||
.catch(err => console.error(err))
|
||||
accountApi
|
||||
.getAccounts()
|
||||
.then((result) => (accounts.value = result))
|
||||
.catch((err) => console.error(err))
|
||||
accountApi
|
||||
.getTotalBalances()
|
||||
.then((result) => {
|
||||
totalBalances.value = result
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<HomeModule title="Accounts">
|
||||
<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">
|
||||
You haven't added any accounts. Add one to start tracking your finances.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<AppBadge v-for="bal in totalBalances" :key="bal.currency.code">
|
||||
{{ bal.currency.code }} Total: <span class="font-mono">{{ formatMoney(bal.balance, bal.currency) }}</span>
|
||||
<AppBadge
|
||||
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 v-for="debt in totalOwed" :key="debt.currency.code">
|
||||
{{ debt.currency.code }} Debt: <span class="font-mono" :class="{ 'text-negative': debt.balance > 0 }">{{
|
||||
formatMoney(debt.balance,
|
||||
debt.currency) }}</span>
|
||||
<AppBadge
|
||||
v-for="debt in totalOwed"
|
||||
:key="debt.currency.code"
|
||||
>
|
||||
{{ debt.currency.code }} Debt:
|
||||
<span
|
||||
class="font-mono"
|
||||
:class="{ 'text-negative': debt.balance > 0 }"
|
||||
>{{ formatMoney(debt.balance, debt.currency) }}</span
|
||||
>
|
||||
</AppBadge>
|
||||
</div>
|
||||
</template>
|
||||
<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>
|
||||
</template>
|
||||
</HomeModule>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { getSelectedProfile, ProfileApiClient, type Profile } from '@/api/profile';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue';
|
||||
import HomeModule from '@/components/HomeModule.vue';
|
||||
import { showAlert } from '@/util/alert';
|
||||
import { onMounted, ref, useTemplateRef, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getSelectedProfile, ProfileApiClient, type Profile } from '@/api/profile'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import HomeModule from '@/components/HomeModule.vue'
|
||||
import { showAlert } from '@/util/alert'
|
||||
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
|
@ -18,8 +18,8 @@ onMounted(async () => {
|
|||
profile.value = await new ProfileApiClient().getProfile(getSelectedProfile(route))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
await showAlert("Failed to get profile.")
|
||||
await router.replace("/profiles")
|
||||
await showAlert('Failed to get profile.')
|
||||
await router.replace('/profiles')
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -37,24 +37,41 @@ async function deleteProfile() {
|
|||
}
|
||||
</script>
|
||||
<template>
|
||||
<HomeModule title="Profile" v-if="profile">
|
||||
<HomeModule
|
||||
title="Profile"
|
||||
v-if="profile"
|
||||
>
|
||||
<template v-slot:default>
|
||||
<p>Your currently selected profile is: {{ profile.name }}</p>
|
||||
<p>
|
||||
<RouterLink :to="`/profiles/${profile.name}/vendors`">View all vendors here.</RouterLink>
|
||||
</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>
|
||||
|
||||
<ConfirmModal ref="confirmDeleteModal">
|
||||
<p>Are you sure you want to delete this profile?</p>
|
||||
<p>This will permanently remove all data associated with this profile, and this cannot be undone!</p>
|
||||
<p>
|
||||
This will permanently remove all data associated with this profile, and this cannot be
|
||||
undone!
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
</template>
|
||||
<template v-slot:actions>
|
||||
<AppButton icon="folder-open" @click="router.push('/profiles')">Choose another profile</AppButton>
|
||||
<AppButton button-style="secondary" icon="trash" @click="deleteProfile()">Delete</AppButton>
|
||||
<AppButton
|
||||
icon="folder-open"
|
||||
@click="router.push('/profiles')"
|
||||
>Choose another profile</AppButton
|
||||
>
|
||||
<AppButton
|
||||
button-style="secondary"
|
||||
icon="trash"
|
||||
@click="deleteProfile()"
|
||||
>Delete</AppButton
|
||||
>
|
||||
</template>
|
||||
</HomeModule>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import type { Page, PageRequest } from '@/api/pagination';
|
||||
import { getSelectedProfile } from '@/api/profile';
|
||||
import { TransactionApiClient, type TransactionsListItem } from '@/api/transaction';
|
||||
import AppButton from '@/components/common/AppButton.vue';
|
||||
import HomeModule from '@/components/HomeModule.vue';
|
||||
import PaginationControls from '@/components/common/PaginationControls.vue';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import TransactionCard from '@/components/TransactionCard.vue';
|
||||
import type { Page, PageRequest } from '@/api/pagination'
|
||||
import { getSelectedProfile } from '@/api/profile'
|
||||
import { TransactionApiClient, type TransactionsListItem } from '@/api/transaction'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import HomeModule from '@/components/HomeModule.vue'
|
||||
import PaginationControls from '@/components/common/PaginationControls.vue'
|
||||
import { onMounted, ref, type Ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import TransactionCard from '@/components/TransactionCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
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 () => {
|
||||
await fetchPage(transactions.value.pageRequest)
|
||||
|
|
@ -29,17 +36,25 @@ async function fetchPage(pageRequest: PageRequest) {
|
|||
<template>
|
||||
<HomeModule title="Transactions">
|
||||
<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>
|
||||
<p v-if="transactions.totalElements === 0">
|
||||
You haven't added any transactions.
|
||||
</p>
|
||||
<PaginationControls
|
||||
:page="transactions"
|
||||
@update="(pr) => fetchPage(pr)"
|
||||
></PaginationControls>
|
||||
<p v-if="transactions.totalElements === 0">You haven't added any transactions.</p>
|
||||
</template>
|
||||
<template v-slot:actions>
|
||||
<AppButton icon="plus" @click="router.push(`/profiles/${getSelectedProfile(route)}/add-transaction`)">
|
||||
Add
|
||||
Transaction</AppButton>
|
||||
<AppButton
|
||||
icon="plus"
|
||||
@click="router.push(`/profiles/${getSelectedProfile(route)}/add-transaction`)"
|
||||
>
|
||||
Add Transaction</AppButton
|
||||
>
|
||||
</template>
|
||||
</HomeModule>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Reference in New Issue