Cleaned up line items editor with new card component.
Build and Deploy Web App / build-and-deploy (push) Successful in 18s Details

This commit is contained in:
andrewlalis 2025-09-19 18:52:40 -04:00
parent 2f1197fb92
commit 943ce13be2
7 changed files with 130 additions and 63 deletions

View File

@ -27,3 +27,7 @@ export function formatMoney(amount: number, currency: Currency) {
})
return format.format(amount / Math.pow(10, currency.fractionalDigits))
}
export function floatMoneyToInteger(amount: number, currency: Currency) {
return Math.round(amount * Math.pow(10, currency.fractionalDigits ?? 0))
}

View File

@ -0,0 +1,60 @@
<script setup lang="ts">
import type { TransactionDetailLineItem } from '@/api/transaction';
import AppButton from './common/AppButton.vue';
import { formatMoney, type Currency } from '@/api/data';
defineProps<{
lineItem: TransactionDetailLineItem
currency: Currency
totalCount?: number
editable?: boolean
}>()
defineEmits<{
'deleted': void,
'movedUp': void,
'movedDown': void
}>()
</script>
<template>
<div class="line-item-card">
<div class="line-item-card-description">
{{ lineItem.description }}
</div>
<div class="line-item-card-details">
<span class="font-mono font-size-small align-right text-muted">
{{ lineItem.quantity }}x
</span>
<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')" />
</div>
</div>
</template>
<style lang="css">
.line-item-card {
display: flex;
flex-direction: row;
background-color: var(--bg);
border-radius: 0.5rem;
margin: 0.5rem 0;
}
.line-item-card-description {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: space-around;
margin: 0.25rem;
}
.line-item-card-details {
display: inline-block;
margin: 0.25rem;
}
</style>

View File

@ -7,17 +7,28 @@ modal for adding a new one.
import { type TransactionCategoryTree, type TransactionDetailLineItem } from '@/api/transaction';
import AppButton from '@/components/common/AppButton.vue';
import FormGroup from '@/components/common/form/FormGroup.vue';
import { formatMoney, type Currency } from '@/api/data';
import { floatMoneyToInteger, formatMoney, type Currency } from '@/api/data';
import ModalWrapper from '@/components/common/ModalWrapper.vue';
import FormControl from '@/components/common/form/FormControl.vue';
import { ref, type Ref, useTemplateRef } from '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 })
defineProps<{
currency: Currency | null
const props = defineProps<{
transactionAmount: number
currency: Currency
}>()
const computedTotal = computed(() => {
let sum = 0
for (const item of model.value) {
sum += item.quantity * item.valuePerItem
}
return sum
})
const addLineItemDescription = ref('')
const addLineItemValuePerItem = ref(0)
const addLineItemQuantity = ref(0)
@ -39,13 +50,14 @@ function showAddLineItemModal() {
}
async function addLineItem() {
const idxs: number[] = model.value.map(i => i.idx)
const newIdx = Math.max(...idxs)
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: addLineItemValuePerItem.value,
valuePerItem: floatMoneyToInteger(addLineItemValuePerItem.value, props.currency),
category: selectedCategory.value
})
addLineItemModal.value?.close()
@ -57,36 +69,40 @@ function removeLineItem(idx: number) {
model.value[i].idx = i
}
}
function moveItemUp(idx: number) {
if (idx <= 0) return
const item = model.value[idx]
model.value[idx] = model.value[idx - 1]
model.value[idx - 1] = item
for (let i = 0; i < model.value.length; i++) {
model.value[i].idx = i
}
}
function moveItemDown(idx: number) {
if (idx >= model.value.length - 1) return
const item = model.value[idx]
model.value[idx] = model.value[idx + 1]
model.value[idx + 1] = item
for (let i = 0; i < model.value.length; i++) {
model.value[i].idx = i
}
}
</script>
<template>
<FormGroup>
<table style="width: 100%;">
<thead>
<tr>
<th>Description</th>
<th>Value</th>
<th>Quantity</th>
<th>Category</th>
</tr>
</thead>
<tbody>
<tr v-for="lineItem in model" :key="lineItem.idx">
<td>{{ lineItem.description }}</td>
<td style="text-align: right;">{{ currency ? formatMoney(lineItem.valuePerItem, currency) :
lineItem.valuePerItem }}</td>
<td style="text-align: right;">{{ lineItem.quantity }}</td>
<td style="text-align: right;">{{ lineItem.category?.name ?? 'None' }}</td>
<td>
<AppButton icon="trash" @click="removeLineItem(lineItem.idx)"></AppButton>
</td>
</tr>
<tr v-if="model.length === 0">
<td colspan="4">No line items present.</td>
</tr>
</tbody>
</table>
<div>
<AppButton button-type="button" @click="showAddLineItemModal()">Add</AppButton>
<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
}">
Items Total: {{ formatMoney(computedTotal, currency) }}
</AppBadge>
</div>
<!-- Modal for adding a new line item. -->
@ -113,5 +129,5 @@ function removeLineItem(idx: number) {
<AppButton button-style="secondary" @click="addLineItemModal?.close()">Cancel</AppButton>
</template>
</ModalWrapper>
</FormGroup>
</div>
</template>

View File

@ -29,7 +29,7 @@ const buttonStyle = computed(() => ({
}))
</script>
<template>
<button class="app-button" :class="buttonStyle" :type="type" :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>

View File

@ -6,8 +6,8 @@ defineProps<{ submitText?: string, cancelText?: string, disabled?: boolean }>()
</script>
<template>
<div class="app-form-actions">
<AppButton button-type="submit" :disabled="disabled ?? false">{{ submitText ?? 'Submit' }}</AppButton>
<AppButton button-style="secondary" @click="$emit('cancel')">{{ cancelText ?? 'Cancel'
<AppButton type="submit" :disabled="disabled ?? false">{{ submitText ?? 'Submit' }}</AppButton>
<AppButton theme="secondary" @click="$emit('cancel')">{{ cancelText ?? 'Cancel'
}}</AppButton>
</div>
</template>

View File

@ -12,6 +12,7 @@ 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()
@ -97,26 +98,8 @@ async function deleteTransaction() {
<div v-if="transaction.lineItems.length > 0">
<h3>Line Items</h3>
<table class="app-table">
<thead>
<tr>
<th>#</th>
<th>Amount per Item</th>
<th>Quantity</th>
<th>Description</th>
<th>Category</th>
</tr>
</thead>
<tbody>
<tr v-for="i in transaction.lineItems" :key="i.idx">
<td>{{ i.idx + 1 }}</td>
<td>{{ formatMoney(i.valuePerItem, transaction.currency) }}</td>
<td>{{ i.quantity }}</td>
<td>{{ i.description }}</td>
<td>{{ i.category?.name }}</td>
</tr>
</tbody>
</table>
<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">

View File

@ -11,7 +11,7 @@ The form consists of a few main sections:
-->
<script setup lang="ts">
import { AccountApiClient, type Account } from '@/api/account';
import { DataApiClient, type Currency } from '@/api/data';
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';
@ -116,11 +116,14 @@ onMounted(async () => {
* created.
*/
async function doSubmit() {
if (currency.value === null) {
return
}
const localDate = new Date(timestamp.value)
const scaledAmount = Math.round(amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0))
const payload: AddTransactionPayload = {
timestamp: localDate.toISOString(),
amount: scaledAmount,
amount: floatMoneyToInteger(amount.value, currency.value),
currencyCode: currency.value?.code ?? '',
description: description.value,
vendorId: vendorId.value,
@ -309,7 +312,8 @@ function isEdited() {
</FormControl>
</FormGroup>
<LineItemsEditor v-model="lineItems" :currency="currency" />
<LineItemsEditor v-if="currency" v-model="lineItems" :currency="currency"
:transaction-amount="floatMoneyToInteger(amount, currency)" />
<FormGroup>
<!-- Tags -->