Files
InvoiceShelf/resources/scripts-v2/features/company/estimates/views/EstimateCreateView.vue
Darko Gjorgjijoski 45f347ebef Fix global tax recalculation and fractional cent totals
Global percentage taxes are now recalculated when items or discount
change, preventing stale tax amounts. Math.round() applied to
sub_total, total, and tax in invoice/estimate submit payloads to
ensure the backend always receives whole-cent integers.
2026-04-06 22:56:31 +02:00

244 lines
6.8 KiB
Vue

<template>
<BasePage class="relative estimate-create-page">
<form @submit.prevent="submitForm">
<BasePageHeader :title="pageTitle">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
<BaseBreadcrumbItem
:title="$t('estimates.estimate', 2)"
to="/admin/estimates"
/>
<BaseBreadcrumbItem
v-if="isEdit"
:title="$t('estimates.edit_estimate')"
to="#"
active
/>
<BaseBreadcrumbItem v-else :title="$t('estimates.new_estimate')" to="#" active />
</BaseBreadcrumb>
<template #actions>
<router-link
v-if="isEdit"
:to="`/estimates/pdf/${estimateStore.newEstimate.unique_hash}`"
target="_blank"
>
<BaseButton class="mr-3" variant="primary-outline" type="button">
<span class="flex">
{{ $t('general.view_pdf') }}
</span>
</BaseButton>
</router-link>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
:content-loading="isLoadingContent"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('estimates.save_estimate') }}
</BaseButton>
</template>
</BasePageHeader>
<!-- Select Customer & Basic Fields -->
<EstimateBasicFields
:v="v$"
:is-loading="isLoadingContent"
:is-edit="isEdit"
/>
<BaseScrollPane>
<!-- Estimate Items -->
<DocumentItemsTable
:currency="estimateStore.newEstimate.selectedCurrency"
:is-loading="isLoadingContent"
:item-validation-scope="estimateValidationScope"
:store="estimateStore"
store-prop="newEstimate"
/>
<!-- Estimate Footer Section -->
<div
class="block mt-10 estimate-foot lg:flex lg:justify-between lg:items-start"
>
<div class="relative w-full lg:w-1/2">
<!-- Estimate Custom Notes -->
<DocumentNotes
:store="estimateStore"
store-prop="newEstimate"
:fields="estimateNoteFieldList"
type="Estimate"
/>
<!-- Estimate Template Button -->
<TemplateSelectButton
:store="estimateStore"
store-prop="newEstimate"
:is-mark-as-default="isMarkAsDefault"
/>
<SelectTemplateModal />
</div>
<DocumentTotals
:currency="estimateStore.newEstimate.selectedCurrency"
:is-loading="isLoadingContent"
:store="estimateStore"
store-prop="newEstimate"
tax-popup-type="estimate"
/>
</div>
</BaseScrollPane>
</form>
</BasePage>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import cloneDeep from 'lodash/cloneDeep'
import {
required,
maxLength,
helpers,
requiredIf,
decimal,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useEstimateStore } from '../store'
import EstimateBasicFields from '../components/EstimateBasicFields.vue'
import {
DocumentItemsTable,
DocumentTotals,
DocumentNotes,
TemplateSelectButton,
SelectTemplateModal,
} from '../../../shared/document-form'
const estimateStore = useEstimateStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const estimateValidationScope = 'newEstimate'
const isSaving = ref<boolean>(false)
const isMarkAsDefault = ref<boolean>(false)
const estimateNoteFieldList = ref<string[]>(['customer', 'company', 'estimate'])
const isLoadingContent = computed<boolean>(
() => estimateStore.isFetchingInitialSettings,
)
const pageTitle = computed<string>(() =>
isEdit.value ? t('estimates.edit_estimate') : t('estimates.new_estimate'),
)
const isEdit = computed<boolean>(() => route.name === 'estimates.edit')
const rules = {
estimate_date: {
required: helpers.withMessage(t('validation.required'), required),
},
estimate_number: {
required: helpers.withMessage(t('validation.required'), required),
},
reference_number: {
maxLength: helpers.withMessage(t('validation.price_maxlength'), maxLength(255)),
},
customer_id: {
required: helpers.withMessage(t('validation.required'), required),
},
exchange_rate: {
required: requiredIf(() => estimateStore.showExchangeRate),
},
}
const v$ = useVuelidate(
rules,
computed(() => estimateStore.newEstimate),
{ $scope: estimateValidationScope },
)
// Initialization
estimateStore.resetCurrentEstimate()
v$.value.$reset
estimateStore.fetchEstimateInitialSettings(
isEdit.value,
{ id: route.params.id as string, query: route.query as Record<string, string> },
)
watch(
() => estimateStore.newEstimate.customer,
(newVal) => {
if (newVal && (newVal as Record<string, unknown>).currency) {
estimateStore.newEstimate.selectedCurrency = (
newVal as Record<string, unknown>
).currency as Record<string, unknown>
}
},
)
async function submitForm(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
console.log('Estimate form invalid. Errors:', JSON.stringify(
v$.value.$errors.map((e: { $property: string; $message: string }) => `${e.$property}: ${e.$message}`)
))
return
}
isSaving.value = true
const data: Record<string, unknown> = {
...cloneDeep(estimateStore.newEstimate),
sub_total: Math.round(estimateStore.getSubTotal),
total: Math.round(estimateStore.getTotal),
tax: Math.round(estimateStore.getTotalTax),
}
const items = data.items as Array<Record<string, unknown>>
if (data.discount_per_item === 'YES') {
items.forEach((item, index) => {
if (item.discount_type === 'fixed') {
items[index].discount = Math.round((item.discount as number) * 100)
}
})
} else {
if (data.discount_type === 'fixed') {
data.discount = Math.round((data.discount as number) * 100)
}
}
const taxes = data.taxes as Array<Record<string, unknown>>
if (data.tax_per_item !== 'YES' && taxes.length) {
data.tax_type_ids = taxes.map((tax) => tax.tax_type_id)
}
try {
const action = isEdit.value
? estimateStore.updateEstimate
: estimateStore.addEstimate
const res = await action(data)
if (res.data.data) {
router.push(`/admin/estimates/${res.data.data.id}/view`)
}
} catch (err) {
console.error(err)
}
isSaving.value = false
}
</script>