mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-18 14:55:21 +00:00
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>
364 lines
10 KiB
Vue
364 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
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 {
|
|
id: number
|
|
name: string
|
|
percent: number
|
|
fixed_amount: number
|
|
calculation_type: string | null
|
|
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
|
|
|
|
interface Emits {
|
|
(e: 'newItem', item: unknown): void
|
|
}
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
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'
|
|
)
|
|
|
|
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: () => form.price / 100,
|
|
set: (value: number) => {
|
|
form.price = Math.round(value * 100)
|
|
},
|
|
})
|
|
|
|
const taxes = computed<TaxOption[]>({
|
|
get: () =>
|
|
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 + '%'
|
|
})`,
|
|
}
|
|
}) ?? [],
|
|
set: (value: TaxOption[]) => {
|
|
form.taxes = value
|
|
},
|
|
})
|
|
|
|
const isTaxPerItemEnabled = computed<boolean>(() => {
|
|
return taxPerItemSetting.value === 'YES'
|
|
})
|
|
|
|
const getTaxTypes = computed<TaxOption[]>(() => {
|
|
return taxTypes.value.map((tax: TaxType) => {
|
|
const currencyCode = companyStore.selectedCompanyCurrency?.code ?? 'USD'
|
|
const amount =
|
|
tax.calculation_type === 'fixed'
|
|
? new Intl.NumberFormat(undefined, {
|
|
style: 'currency',
|
|
currency: currencyCode,
|
|
}).format(tax.fixed_amount / 100)
|
|
: `${tax.percent}%`
|
|
|
|
return {
|
|
...tax,
|
|
tax_name: `${tax.name} (${amount})`,
|
|
}
|
|
}) 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 () => {
|
|
await itemStore.fetchItemUnits({ limit: 'all' })
|
|
|
|
if (userStore.hasAbilities(ABILITIES.VIEW_TAX_TYPE)) {
|
|
await fetchTaxTypes()
|
|
}
|
|
})
|
|
|
|
async function submitItemData(): Promise<void> {
|
|
triedSubmit.value = true
|
|
|
|
if (!isFormValid.value) {
|
|
notificationStore.showNotification({
|
|
type: 'error',
|
|
message: nameError.value || descriptionError.value,
|
|
})
|
|
return
|
|
}
|
|
|
|
const data: Record<string, unknown> = {
|
|
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((form.price / 100) * tax.percent),
|
|
percent: tax.percent,
|
|
fixed_amount: tax.fixed_amount,
|
|
calculation_type: tax.calculation_type,
|
|
name: tax.name,
|
|
collective_tax: 0,
|
|
})),
|
|
}
|
|
|
|
isLoading.value = true
|
|
|
|
try {
|
|
const res = await itemStore.addItem(data)
|
|
isLoading.value = false
|
|
if (res.data && modalStore.refreshData) {
|
|
modalStore.refreshData(res.data)
|
|
}
|
|
closeItemModal()
|
|
} 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
function addItemUnit(): void {
|
|
modalStore.openModal({
|
|
title: t('settings.customization.items.add_item_unit'),
|
|
componentName: 'ItemUnitModal',
|
|
size: 'sm',
|
|
refreshData: (unit: { id: number }) => {
|
|
form.unit_id = unit.id
|
|
},
|
|
})
|
|
}
|
|
|
|
function closeItemModal(): void {
|
|
modalStore.closeModal()
|
|
setTimeout(() => {
|
|
triedSubmit.value = false
|
|
}, 300)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<BaseModal :show="modalActive" @close="closeItemModal">
|
|
<template #header>
|
|
<div class="flex justify-between w-full">
|
|
{{ modalStore.title }}
|
|
<BaseIcon
|
|
name="XMarkIcon"
|
|
class="h-6 w-6 text-muted cursor-pointer"
|
|
@click="closeItemModal"
|
|
/>
|
|
</div>
|
|
</template>
|
|
<div class="item-modal">
|
|
<form action="" @submit.prevent="submitItemData">
|
|
<div class="px-8 py-8 sm:p-6">
|
|
<BaseInputGrid layout="one-column">
|
|
<BaseInputGroup
|
|
:label="$t('items.name')"
|
|
required
|
|
:error="triedSubmit ? nameError : ''"
|
|
>
|
|
<BaseInput
|
|
v-model="form.name"
|
|
type="text"
|
|
:invalid="Boolean(triedSubmit && nameError)"
|
|
/>
|
|
</BaseInputGroup>
|
|
|
|
<BaseInputGroup :label="$t('items.price')">
|
|
<BaseMoney
|
|
:key="companyStore.selectedCompanyCurrency?.id"
|
|
v-model="price"
|
|
:currency="companyStore.selectedCompanyCurrency"
|
|
class="
|
|
relative
|
|
w-full
|
|
focus:border focus:border-solid focus:border-primary
|
|
"
|
|
/>
|
|
</BaseInputGroup>
|
|
|
|
<BaseInputGroup :label="$t('items.unit')">
|
|
<BaseMultiselect
|
|
v-model="form.unit_id"
|
|
label="name"
|
|
:options="itemStore.itemUnits"
|
|
value-prop="id"
|
|
:can-deselect="false"
|
|
:can-clear="false"
|
|
:placeholder="$t('items.select_a_unit')"
|
|
searchable
|
|
track-by="name"
|
|
>
|
|
<template #action>
|
|
<BaseSelectAction @click="addItemUnit">
|
|
<BaseIcon
|
|
name="PlusCircleIcon"
|
|
class="h-4 mr-2 -ml-2 text-center text-primary-400"
|
|
/>
|
|
{{ $t('settings.customization.items.add_item_unit') }}
|
|
</BaseSelectAction>
|
|
</template>
|
|
</BaseMultiselect>
|
|
<ItemUnitModal />
|
|
</BaseInputGroup>
|
|
|
|
<BaseInputGroup
|
|
v-if="isTaxPerItemEnabled"
|
|
:label="$t('items.taxes')"
|
|
>
|
|
<BaseMultiselect
|
|
v-model="taxes"
|
|
:options="getTaxTypes"
|
|
mode="tags"
|
|
label="tax_name"
|
|
value-prop="id"
|
|
class="w-full"
|
|
:can-deselect="false"
|
|
:can-clear="false"
|
|
searchable
|
|
track-by="tax_name"
|
|
object
|
|
/>
|
|
</BaseInputGroup>
|
|
|
|
<BaseInputGroup
|
|
:label="$t('items.description')"
|
|
:error="triedSubmit ? descriptionError : ''"
|
|
>
|
|
<BaseTextarea
|
|
v-model="form.description"
|
|
rows="4"
|
|
cols="50"
|
|
:invalid="Boolean(triedSubmit && descriptionError)"
|
|
/>
|
|
</BaseInputGroup>
|
|
</BaseInputGrid>
|
|
</div>
|
|
<div
|
|
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
|
|
>
|
|
<BaseButton
|
|
class="mr-3"
|
|
variant="primary-outline"
|
|
type="button"
|
|
@click="closeItemModal"
|
|
>
|
|
{{ $t('general.cancel') }}
|
|
</BaseButton>
|
|
<BaseButton
|
|
:loading="isLoading"
|
|
:disabled="isLoading"
|
|
variant="primary"
|
|
type="submit"
|
|
>
|
|
<template #left="slotProps">
|
|
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
|
|
</template>
|
|
{{ $t('general.save') }}
|
|
</BaseButton>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</BaseModal>
|
|
</template>
|