Value record page, improved history, link styling, etc.
Build and Deploy Web App / build-and-deploy (push) Successful in 19s Details

This commit is contained in:
andrewlalis 2025-09-11 12:48:59 -04:00
parent 41acb0dd51
commit 30764ba624
17 changed files with 249 additions and 91 deletions

View File

@ -3,6 +3,7 @@ import { ApiClient } from './base'
import type { Currency } from './data'
import type { Page, PageRequest } from './pagination'
import { getSelectedProfile } from './profile'
import type { Attachment } from './attachment'
export interface AccountType {
id: string
@ -87,6 +88,7 @@ export interface AccountValueRecord {
type: AccountValueRecordType
value: number
currency: Currency
attachments: Attachment[]
}
export interface AccountValueRecordCreationPayload {
@ -103,6 +105,14 @@ export enum AccountHistoryItemType {
JOURNAL_ENTRY = 'JOURNAL_ENTRY',
}
export function accountHistoryItemTypeToDisplayName(type: AccountHistoryItemType) {
if (type === AccountHistoryItemType.TEXT) return 'Text'
if (type === AccountHistoryItemType.PROPERTY_CHANGE) return 'Property Change'
if (type === AccountHistoryItemType.VALUE_RECORD) return 'Value Record'
if (type === AccountHistoryItemType.JOURNAL_ENTRY) return 'Journal Entry'
return 'Unknown'
}
export interface AccountHistoryItem {
timestamp: string
type: AccountHistoryItemType

View File

@ -2,6 +2,14 @@ import type { RouteLocation } from 'vue-router'
import { ApiClient } from './base'
import { getSelectedProfile } from './profile'
export interface Attachment {
id: number
uploadedAt: string
filename: string
contentType: string
size: number
}
export class AttachmentApiClient extends ApiClient {
readonly profileName: string

View File

@ -1,3 +1,4 @@
import type { Attachment } from './attachment'
import { ApiClient } from './base'
import type { Currency } from './data'
import { type Page, type PageRequest } from './pagination'
@ -86,7 +87,7 @@ export interface TransactionDetail {
debitedAccount: TransactionDetailAccount | null
tags: string[]
lineItems: TransactionDetailLineItem[]
attachments: TransactionDetailAttachment[]
attachments: Attachment[]
}
export interface TransactionDetailAccount {
@ -104,14 +105,6 @@ export interface TransactionDetailLineItem {
category: TransactionCategory | null
}
export interface TransactionDetailAttachment {
id: number
uploadedAt: string
filename: string
contentType: string
size: number
}
export interface AddTransactionPayload {
timestamp: string
amount: number

View File

@ -1,5 +1,6 @@
@import url('@/assets/styles/fonts.css');
@import url('@/assets/styles/text.css');
@import url('@/assets/styles/spacing.css');
:root {
--theme-primary: #113188;
@ -12,6 +13,7 @@
--text: rgb(247, 247, 247);
--text-muted: gray;
--text-link: #6c8eff;
--positive: rgb(59, 219, 44);
--negative: rgb(253, 52, 52);
--warning: rgb(255, 187, 0);
@ -26,11 +28,11 @@ body {
}
a {
color: var(--theme-primary);
color: var(--text-link);
text-decoration: none;
}
a:hover {
color: var(--theme-tertiary);
color: var(--text-tertiary);
text-decoration: underline;
}

View File

@ -0,0 +1,11 @@
.p0 {
padding: 0;
}
.m0 {
margin: 0;
}
.mt-1 {
margin-top: 0.5rem;
}

View File

@ -41,3 +41,7 @@
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}

View File

@ -9,6 +9,7 @@ import { AccountApiClient, AccountValueRecordType, type Account, type AccountVal
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 }>()
@ -23,7 +24,11 @@ const attachments: Ref<File[]> = ref([])
async function show(): Promise<AccountValueRecord | undefined> {
if (!modal.value) return Promise.resolve(undefined)
timestamp.value = getDatetimeLocalValueForNow()
amount.value = props.account.currentBalance ?? 0
if (props.account.currentBalance !== null) {
amount.value = props.account.currentBalance / Math.pow(10, props.account.currency.fractionalDigits)
} else {
amount.value = 0
}
savedValueRecord.value = undefined
const result = await modal.value.show()
if (result === 'saved') {
@ -38,6 +43,11 @@ async function addValueRecord() {
type: AccountValueRecordType.BALANCE,
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?")
if (!result) return
}
const api = new AccountApiClient(route)
try {
savedValueRecord.value = await api.createValueRecord(props.account.id, payload, attachments.value)

View File

@ -0,0 +1,49 @@
<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';
interface AttachmentInfo {
filename: string
contentType: string
size: number
id?: number
}
const route = useRoute()
const authStore = useAuthStore()
const props = defineProps<{ attachment: AttachmentInfo, disabled?: boolean }>()
defineEmits<{ "deleted": void }>()
function downloadFile() {
const api = new AttachmentApiClient(route)
if (!authStore.state) return
api.downloadAttachment(props.attachment?.id ?? 0, authStore.state.token)
}
</script>
<template>
<div class="attachment-row">
<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>
</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')" />
</div>
</div>
</template>
<style lang="css">
.attachment-row {
background-color: var(--bg);
display: flex;
justify-content: space-between;
padding: 0.25rem;
border-radius: 0.5rem;
margin: 0.5rem 0;
}
</style>

View File

@ -1,9 +1,7 @@
<script setup lang="ts">
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue';
import AppButton from '@/components/common/AppButton.vue';
import { AttachmentApiClient } from '@/api/attachment';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth-store';
import AttachmentRow from './AttachmentRow.vue';
interface ExistingFile {
id: number
@ -41,8 +39,6 @@ interface Props {
initialFiles?: ExistingFile[]
}
const route = useRoute()
const authStore = useAuthStore()
const props = withDefaults(defineProps<Props>(), {
disabled: false,
initialFiles: () => []
@ -105,28 +101,11 @@ function onFileDeleteClicked(idx: number) {
}
files.value.splice(idx, 1)
}
function downloadFile(attachmentId: number) {
const api = new AttachmentApiClient(route)
if (!authStore.state) return
api.downloadAttachment(attachmentId, authStore.state.token)
}
</script>
<template>
<div class="file-selector">
<div @click.prevent="">
<div v-for="file, idx in files" :key="idx" class="file-selector-item">
<div style="display: flex; align-items: center; margin-left: 1rem;">
<div>
<div>{{ file.filename }}</div>
<div style="font-size: 0.75rem;">{{ file.contentType }}</div>
</div>
</div>
<div>
<AppButton icon="download" @click="downloadFile(file.id)" v-if="(file instanceof ExistingFileListItem)" />
<AppButton v-if="!disabled" icon="trash" @click="onFileDeleteClicked(idx)" />
</div>
</div>
<AttachmentRow v-for="file, idx in files" :key="idx" :attachment="file" @deleted="onFileDeleteClicked(idx)" />
</div>
<div>
@ -143,13 +122,4 @@ function downloadFile(attachmentId: number) {
.file-selector {
width: 100%;
}
.file-selector-item {
background-color: var(--bg-secondary);
display: flex;
justify-content: space-between;
padding: 0.25rem;
border-radius: 0.5rem;
margin: 0.5rem 0;
}
</style>

View File

@ -5,27 +5,48 @@ import { onMounted, ref, type Ref } from 'vue';
import ValueRecordHistoryItem from './ValueRecordHistoryItem.vue';
import JournalEntryHistoryItem from './JournalEntryHistoryItem.vue';
import { useRoute } from 'vue-router';
import AppButton from '../common/AppButton.vue';
import AppBadge from '../common/AppBadge.vue';
const route = useRoute()
const props = defineProps<{ accountId: number }>()
const historyItems: Ref<AccountHistoryItem[]> = ref([])
const canLoadMore = ref(true)
const nextPage: Ref<PageRequest> = ref({ page: 1, size: 10, sorts: [{ attribute: 'timestamp', dir: 'DESC' }] })
onMounted(async () => {
const pageRequest: PageRequest = { page: 1, size: 10, sorts: [{ attribute: 'timestamp', dir: 'DESC' }] }
await loadNextPage()
})
async function loadNextPage() {
const api = new AccountApiClient(route)
while (true) {
try {
const page = await api.getHistory(props.accountId, pageRequest)
const page = await api.getHistory(props.accountId, nextPage.value)
historyItems.value.push(...page.items)
if (page.isLast) return
pageRequest.page++
canLoadMore.value = !page.isLast
if (canLoadMore.value) {
nextPage.value.page++
}
} catch (err) {
console.error(err)
historyItems.value = []
return
canLoadMore.value = false
nextPage.value.page = 1
}
}
async function loadAll() {
while (canLoadMore.value) {
await loadNextPage()
}
})
}
function reload() {
nextPage.value.page = 1
canLoadMore.value = true
historyItems.value = []
loadNextPage()
}
function asVR(i: AccountHistoryItem): AccountHistoryValueRecordItem {
return i as AccountHistoryValueRecordItem
@ -34,19 +55,27 @@ function asVR(i: AccountHistoryItem): AccountHistoryValueRecordItem {
function asJE(i: AccountHistoryItem): AccountHistoryJournalEntryItem {
return i as AccountHistoryJournalEntryItem
}
defineExpose({ reload })
</script>
<template>
<div>
<div v-for="item in historyItems" :key="item.timestamp" class="history-item">
<div class="history-item-header">
<div class="font-mono font-size-xsmall">{{ new Date(item.timestamp).toLocaleString() }}</div>
<div>{{ item.type }}</div>
<div class="font-mono font-size-xsmall text-muted">{{ new Date(item.timestamp).toLocaleString() }}</div>
</div>
<ValueRecordHistoryItem v-if="item.type === AccountHistoryItemType.VALUE_RECORD" :item="asVR(item)"
:account-id="accountId" />
<JournalEntryHistoryItem v-if="item.type === AccountHistoryItemType.JOURNAL_ENTRY" :item="asJE(item)" />
<hr class="m0" />
</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>
<AppBadge v-if="!canLoadMore">This is the start of this account's history.</AppBadge>
</div>
</div>
</template>

View File

@ -3,6 +3,7 @@ import type { AccountHistoryJournalEntryItem } from '@/api/account'
import { formatMoney } from '@/api/data';
import { getSelectedProfile } from '@/api/profile';
import { useRoute } from 'vue-router';
import AppBadge from '../common/AppBadge.vue';
const route = useRoute()
@ -17,9 +18,11 @@ defineProps<{ item: AccountHistoryJournalEntryItem }>()
entered as a
<strong>{{ item.journalEntryType.toLowerCase() }}</strong>
for this account with a value of
{{ formatMoney(item.amount, item.currency) }}.
<AppBadge class="font-mono">{{ formatMoney(item.amount, item.currency) }}</AppBadge>
<br />
<p class="font-size-small m0 mt-1">
{{ item.transactionDescription }}
</p>
</div>
</div>
</template>

View File

@ -1,35 +1,26 @@
<script setup lang="ts">
import { AccountApiClient, type AccountHistoryValueRecordItem } from '@/api/account';
import { type AccountHistoryValueRecordItem } from '@/api/account';
import { formatMoney } from '@/api/data';
import AppButton from '../common/AppButton.vue';
import { showConfirm } from '@/util/alert';
import { useRoute } from 'vue-router';
import ButtonBar from '../common/ButtonBar.vue';
import AppBadge from '../common/AppBadge.vue';
import { computed } from 'vue';
import { getSelectedProfile } from '@/api/profile';
const route = useRoute()
const props = defineProps<{ item: AccountHistoryValueRecordItem, accountId: number }>()
async function deleteValueRecord(id: number) {
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(props.accountId, id)
} catch (err) {
console.error(err)
}
}
const valueRecordRoute = computed(() => `/profiles/${getSelectedProfile(route)}/accounts/${props.accountId}/value-records/${props.item.valueRecordId}`)
</script>
<template>
<div class="history-item-content">
<div>
Value recorded for this account at {{ formatMoney(item.value, item.currency) }}
<RouterLink :to="valueRecordRoute">Value recorded</RouterLink> as <AppBadge class="font-mono">{{
formatMoney(item.value,
item.currency) }}
</AppBadge>
</div>
<ButtonBar>
<AppButton type="button" icon="trash" size="sm" @click="deleteValueRecord(item.valueRecordId)">
Delete this record
</AppButton>
</ButtonBar>
</div>
</template>

View File

@ -16,6 +16,7 @@ const route = useRoute()
const router = useRouter()
const addValueRecordModal = useTemplateRef("addValueRecordModal")
const history = useTemplateRef('history')
const account: Ref<Account | null> = ref(null)
onMounted(async () => {
@ -45,7 +46,7 @@ async function deleteAccount() {
async function addValueRecord() {
const result = await addValueRecordModal.value?.show()
if (result) {
console.info('Value record added', result)
history.value?.reload()
}
}
</script>
@ -97,7 +98,7 @@ async function addValueRecord() {
<AppButton icon="trash" @click="deleteAccount()">Delete</AppButton>
</ButtonBar>
<AccountHistory :account-id="account.id" v-if="account" />
<AccountHistory :account-id="account.id" v-if="account" ref="history" />
<AddValueRecordModal v-if="account" :account="account" ref="addValueRecordModal" />
</AppPage>

View File

@ -11,6 +11,7 @@ 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';
const route = useRoute()
const router = useRouter()
@ -117,6 +118,11 @@ async function deleteTransaction() {
</tbody>
</table>
</div>
<div v-if="transaction.attachments.length > 0">
<h3>Attachments</h3>
<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`)">

View File

@ -0,0 +1,69 @@
<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';
const route = useRoute()
const router = useRouter()
const valueRecord: Ref<AccountValueRecord | null> = ref(null)
onMounted(async () => {
const accountId = parseInt(route.params.accountId as string)
const id = parseInt(route.params.valueRecordId as string)
try {
const api = new AccountApiClient(route)
valueRecord.value = await api.getValueRecord(accountId, id)
} catch (err) {
console.error(err)
await router.replace('/')
}
})
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.")
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}`)
} catch (err) {
console.error(err)
}
}
</script>
<template>
<AppPage title="Value Record" v-if="valueRecord">
<PropertiesTable>
<tr>
<th>ID</th>
<td>{{ valueRecord.id }}</td>
</tr>
<tr>
<th>Value</th>
<td>
<AppBadge>{{ formatMoney(valueRecord.value, valueRecord.currency) }}</AppBadge>
</td>
</tr>
</PropertiesTable>
<div v-if="valueRecord.attachments.length > 0">
<h3>Attachments</h3>
<AttachmentRow v-for="a in valueRecord.attachments" :attachment="a" :key="a.id" disabled />
</div>
<ButtonBar>
<AppButton type="button" icon="trash" size="sm" @click="deleteValueRecord()">
Delete this record
</AppButton>
</ButtonBar>
</AppPage>
</template>

View File

@ -3,6 +3,7 @@ import { AccountApiClient, type Account, type CurrencyBalance } from '@/api/acco
import { formatMoney } from '@/api/data'
import { getSelectedProfile } from '@/api/profile'
import AccountCard from '@/components/AccountCard.vue'
import AppBadge from '@/components/common/AppBadge.vue'
import AppButton from '@/components/common/AppButton.vue'
import HomeModule from '@/components/HomeModule.vue'
import { onMounted, ref, type Ref } from 'vue'
@ -33,13 +34,9 @@ onMounted(async () => {
</p>
<div>
<ul>
<li v-for="bal in totalBalances" :key="bal.currency.code">
<span>Total in {{ bal.currency.code }}</span>
=
<span>{{ formatMoney(bal.balance, bal.currency) }}</span>
</li>
</ul>
<AppBadge v-for="bal in totalBalances" :key="bal.currency.code">
Total {{ bal.currency.code }}: {{ formatMoney(bal.balance, bal.currency) }}
</AppBadge>
</div>
</template>
<template v-slot:actions>

View File

@ -47,6 +47,11 @@ const router = createRouter({
component: () => import('@/pages/forms/EditAccountPage.vue'),
meta: { title: 'Edit Account' },
},
{
path: 'accounts/:accountId/value-records/:valueRecordId',
component: () => import('@/pages/ValueRecordPage.vue'),
meta: { title: 'Value Record' },
},
{
path: 'add-account',
component: () => import('@/pages/forms/EditAccountPage.vue'),