fix(documents): repair inline add-item modal on invoice/estimate create

Render a single shared ItemModal (was one per row, so stacked dialogs closed each other), validate the modal's own local form (vuelidate did not track the shared store object in the persistent modal), surface save errors, and carry the typed item name into the new-item form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darko Gjorgjijoski
2026-06-14 23:42:12 +02:00
parent 9e496102d4
commit 3ca4027871
4 changed files with 134 additions and 83 deletions

View File

@@ -1,19 +1,17 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
required,
minLength,
maxLength,
helpers,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '../../../../stores/modal.store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useUserStore } from '../../../../stores/user.store'
import { useItemStore } from '../store'
import { useTaxTypes } from '../use-tax-types'
import ItemUnitModal from '@/scripts/features/company/settings/components/ItemUnitModal.vue'
import { useNotificationStore } from '../../../../stores/notification.store'
import {
handleApiError,
getErrorTranslationKey,
} from '../../../../utils/error-handling'
import type { TaxType } from '@/scripts/types/domain/tax'
interface TaxOption {
@@ -25,6 +23,14 @@ interface TaxOption {
tax_name: string
}
interface ItemFormState {
name: string
description: string
price: number
unit_id: string | number | null
taxes: TaxOption[]
}
const ABILITIES = {
VIEW_TAX_TYPE: 'view-tax-type',
} as const
@@ -39,10 +45,12 @@ const modalStore = useModalStore()
const itemStore = useItemStore()
const companyStore = useCompanyStore()
const userStore = useUserStore()
const notificationStore = useNotificationStore()
const { taxTypes, fetchTaxTypes } = useTaxTypes()
const { t } = useI18n()
const isLoading = ref<boolean>(false)
const triedSubmit = ref<boolean>(false)
const taxPerItemSetting = ref<string>(
companyStore.selectedCompanySettings.tax_per_item || 'NO'
)
@@ -51,32 +59,64 @@ const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'ItemModal'
)
// Local form state owned by this modal. ItemModal is permanently mounted (inside
// DocumentItemsTable, via BaseModal's `static` dialog); @vuelidate did not track
// this reactive form's values in that context (it kept validating an empty
// snapshot), so validation here is done with plain reactive computeds instead —
// the same reactivity the price/taxes computeds below already rely on.
const form = reactive<ItemFormState>({
name: '',
description: '',
price: 0,
unit_id: '',
taxes: [],
})
const nameError = computed<string>(() => {
const value = (form.name ?? '').trim()
if (!value) {
return t('validation.required')
}
if (value.length < 3) {
return t('validation.name_min_length', { count: 3 })
}
return ''
})
const descriptionError = computed<string>(() => {
if ((form.description ?? '').length > 255) {
return t('validation.description_maxlength', { count: 255 })
}
return ''
})
const isFormValid = computed<boolean>(
() => !nameError.value && !descriptionError.value
)
const price = computed<number>({
get: () => itemStore.currentItem.price / 100,
get: () => form.price / 100,
set: (value: number) => {
itemStore.currentItem.price = Math.round(value * 100)
form.price = Math.round(value * 100)
},
})
const taxes = computed({
const taxes = computed<TaxOption[]>({
get: () =>
itemStore.currentItem.taxes?.map((tax) => {
if (tax) {
const currencySymbol = companyStore.selectedCompanyCurrency?.symbol ?? '$'
return {
...tax,
tax_type_id: tax.id,
tax_name: `${tax.name} (${
tax.calculation_type === 'fixed'
? tax.fixed_amount + currencySymbol
: tax.percent + '%'
})`,
}
form.taxes?.map((tax) => {
const currencySymbol = companyStore.selectedCompanyCurrency?.symbol ?? '$'
return {
...tax,
tax_type_id: tax.id,
tax_name: `${tax.name} (${
tax.calculation_type === 'fixed'
? tax.fixed_amount + currencySymbol
: tax.percent + '%'
})`,
}
return tax
}) ?? [],
set: (value: TaxOption[]) => {
itemStore.currentItem.taxes = value as unknown as typeof itemStore.currentItem.taxes
form.taxes = value
},
})
@@ -84,27 +124,6 @@ const isTaxPerItemEnabled = computed<boolean>(() => {
return taxPerItemSetting.value === 'YES'
})
const rules = {
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
description: {
maxLength: helpers.withMessage(
t('validation.description_maxlength', { count: 255 }),
maxLength(255)
),
},
}
const v$ = useVuelidate(
rules,
computed(() => itemStore.currentItem)
)
const getTaxTypes = computed<TaxOption[]>(() => {
return taxTypes.value.map((tax: TaxType) => {
const currencyCode = companyStore.selectedCompanyCurrency?.code ?? 'USD'
@@ -123,8 +142,24 @@ const getTaxTypes = computed<TaxOption[]>(() => {
}) as TaxOption[]
})
// Reset + prefill the form every time the modal opens. The typed item-search
// text is handed in via modalStore.data.name by BaseItemSelect.
watch(modalActive, (active) => {
if (!active) {
return
}
const data = modalStore.data as { name?: string } | null
Object.assign(form, {
name: data?.name ?? '',
description: '',
price: 0,
unit_id: '',
taxes: [],
})
triedSubmit.value = false
})
onMounted(async () => {
v$.value.$reset()
await itemStore.fetchItemUnits({ limit: 'all' })
if (userStore.hasAbilities(ABILITIES.VIEW_TAX_TYPE)) {
@@ -133,20 +168,27 @@ onMounted(async () => {
})
async function submitItemData(): Promise<void> {
v$.value.$touch()
triedSubmit.value = true
if (v$.value.$invalid) {
if (!isFormValid.value) {
notificationStore.showNotification({
type: 'error',
message: nameError.value || descriptionError.value,
})
return
}
const data: Record<string, unknown> = {
...itemStore.currentItem,
taxes: itemStore.currentItem.taxes.map((tax) => ({
name: form.name,
description: form.description,
price: form.price,
unit_id: form.unit_id || null,
taxes: (form.taxes ?? []).map((tax) => ({
tax_type_id: tax.id,
amount:
tax.calculation_type === 'fixed'
? tax.fixed_amount
: Math.round(price.value * tax.percent),
: Math.round((form.price / 100) * tax.percent),
percent: tax.percent,
fixed_amount: tax.fixed_amount,
calculation_type: tax.calculation_type,
@@ -157,19 +199,21 @@ async function submitItemData(): Promise<void> {
isLoading.value = true
const action = itemStore.isEdit ? itemStore.updateItem : itemStore.addItem
try {
const res = await action(data)
const res = await itemStore.addItem(data)
isLoading.value = false
if (res.data) {
if (modalStore.refreshData) {
modalStore.refreshData(res.data)
}
if (res.data && modalStore.refreshData) {
modalStore.refreshData(res.data)
}
closeItemModal()
} catch {
} catch (err: unknown) {
isLoading.value = false
const normalized = handleApiError(err)
const translationKey = getErrorTranslationKey(normalized.message)
notificationStore.showNotification({
type: 'error',
message: translationKey ? t(translationKey) : normalized.message,
})
}
}
@@ -179,7 +223,7 @@ function addItemUnit(): void {
componentName: 'ItemUnitModal',
size: 'sm',
refreshData: (unit: { id: number }) => {
itemStore.currentItem.unit_id = unit.id
form.unit_id = unit.id
},
})
}
@@ -187,8 +231,7 @@ function addItemUnit(): void {
function closeItemModal(): void {
modalStore.closeModal()
setTimeout(() => {
itemStore.resetCurrentItem()
v$.value.$reset()
triedSubmit.value = false
}, 300)
}
</script>
@@ -212,13 +255,12 @@ function closeItemModal(): void {
<BaseInputGroup
:label="$t('items.name')"
required
:error="v$.name.$error && v$.name.$errors[0].$message"
:error="triedSubmit ? nameError : ''"
>
<BaseInput
v-model="itemStore.currentItem.name"
v-model="form.name"
type="text"
:invalid="v$.name.$error"
@input="v$.name.$touch()"
:invalid="Boolean(triedSubmit && nameError)"
/>
</BaseInputGroup>
@@ -237,7 +279,7 @@ function closeItemModal(): void {
<BaseInputGroup :label="$t('items.unit')">
<BaseMultiselect
v-model="itemStore.currentItem.unit_id"
v-model="form.unit_id"
label="name"
:options="itemStore.itemUnits"
value-prop="id"
@@ -281,16 +323,13 @@ function closeItemModal(): void {
<BaseInputGroup
:label="$t('items.description')"
:error="
v$.description.$error && v$.description.$errors[0].$message
"
:error="triedSubmit ? descriptionError : ''"
>
<BaseTextarea
v-model="itemStore.currentItem.description"
v-model="form.description"
rows="4"
cols="50"
:invalid="v$.description.$error"
@input="v$.description.$touch()"
:invalid="Boolean(triedSubmit && descriptionError)"
/>
</BaseInputGroup>
</BaseInputGrid>
@@ -315,7 +354,7 @@ function closeItemModal(): void {
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ itemStore.isEdit ? $t('general.update') : $t('general.save') }}
{{ $t('general.save') }}
</BaseButton>
</div>
</form>

View File

@@ -61,7 +61,12 @@ export const useItemStore = defineStore('item', () => {
// Actions
function resetCurrentItem(): void {
currentItem.value = createItemStub()
// Mutate in place (don't replace the object) so that vuelidate / v-model
// bindings attached to currentItem stay valid. Replacing it leaves the
// ItemModal validator bound to the old, empty object (it then reports a
// stale "name is required" even after you type). Stale/extra keys are
// harmless — the backend uses $request->validated().
Object.assign(currentItem.value, createItemStub(), { id: undefined })
}
async function fetchItems(params?: ItemListParams): Promise<ItemListResponse> {

View File

@@ -1,5 +1,9 @@
<template>
<div class="rounded-xl border border-line-light shadow bg-surface">
<!-- Single shared item-create modal for the whole table (one instance, not one
per row stacked HeadlessUI dialogs would otherwise close each other). -->
<ItemModal />
<!-- Tax Included Toggle -->
<div
v-if="taxIncludedSetting === 'YES'"
@@ -109,6 +113,7 @@
import { computed } from 'vue'
import draggable from 'vuedraggable'
import DocumentItemRow from './DocumentItemRow.vue'
import ItemModal from '@/scripts/features/company/items/components/ItemModal.vue'
import type { Currency } from '../../../types/domain/currency'
import type { DocumentFormData } from './use-document-calculations'