mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-16 22:05:20 +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>
186 lines
4.6 KiB
Vue
186 lines
4.6 KiB
Vue
<script setup lang="ts">
|
|
import { computed, reactive, ref, nextTick } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useUserStore } from '@/scripts/stores/user.store'
|
|
import { useModalStore } from '@/scripts/stores/modal.store'
|
|
import { useItemStore } from '@/scripts/features/company/items/store'
|
|
import { ABILITIES } from '@/scripts/config/abilities'
|
|
import type { Item } from '@/scripts/types/domain'
|
|
import type { Tax } from '@/scripts/types/domain'
|
|
|
|
interface LineItem {
|
|
item_id: number | null
|
|
name: string
|
|
description: string | null
|
|
[key: string]: unknown
|
|
}
|
|
|
|
interface Props {
|
|
contentLoading?: boolean
|
|
type?: string | null
|
|
item: LineItem
|
|
index?: number
|
|
invalid?: boolean
|
|
invalidDescription?: boolean
|
|
taxPerItem?: string
|
|
taxes?: Tax[] | null
|
|
store?: { deselectItem: (index: number) => void } | null
|
|
storeProp?: string
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'search', val: string): void
|
|
(e: 'select', val: Item): void
|
|
(e: 'deselect', index: number): void
|
|
(e: 'update:description', value: string): void
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
contentLoading: false,
|
|
type: null,
|
|
index: 0,
|
|
invalid: false,
|
|
invalidDescription: false,
|
|
taxPerItem: '',
|
|
taxes: null,
|
|
store: null,
|
|
storeProp: '',
|
|
})
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
const userStore = useUserStore()
|
|
const modalStore = useModalStore()
|
|
const itemStore = useItemStore()
|
|
const { t } = useI18n()
|
|
|
|
const itemSelect = ref<Item | null>(null)
|
|
const multiselectRef = ref<{ close?: () => void } | null>(null)
|
|
const loading = ref<boolean>(false)
|
|
const searchQuery = ref<string>('')
|
|
const itemData = reactive<LineItem>({ ...props.item })
|
|
|
|
async function searchItems(search: string): Promise<Item[]> {
|
|
const res = await itemStore.fetchItems({ search })
|
|
return res.data as unknown as Item[]
|
|
}
|
|
|
|
function onSearchChange(val: string): void {
|
|
searchQuery.value = val
|
|
emit('search', val)
|
|
}
|
|
|
|
const description = computed<string | null>({
|
|
get: () => props.item.description,
|
|
set: (value: string | null) => {
|
|
emit('update:description', value ?? '')
|
|
},
|
|
})
|
|
|
|
function openItemModal(): void {
|
|
// Close the multiselect dropdown before opening the modal
|
|
;(document.activeElement as HTMLElement)?.blur()
|
|
|
|
nextTick(() => {
|
|
modalStore.openModal({
|
|
title: t('items.add_item'),
|
|
componentName: 'ItemModal',
|
|
refreshData: (val: Item) => emit('select', val),
|
|
// ItemModal owns its own form; hand it the typed search text to pre-fill the name.
|
|
data: {
|
|
name: searchQuery.value,
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
function deselectItem(index: number): void {
|
|
if (props.store) {
|
|
props.store.deselectItem(index)
|
|
}
|
|
emit('deselect', index)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex-1 text-sm">
|
|
<!-- Selected Item Field -->
|
|
<div
|
|
v-if="item.item_id"
|
|
class="
|
|
relative
|
|
flex
|
|
items-center
|
|
h-10
|
|
pl-2
|
|
bg-surface-muted
|
|
border border-line-default border-solid
|
|
rounded
|
|
"
|
|
>
|
|
{{ item.name }}
|
|
|
|
<span
|
|
class="absolute text-subtle cursor-pointer top-[8px] right-[10px]"
|
|
@click="deselectItem(index)"
|
|
>
|
|
<BaseIcon name="XCircleIcon" />
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Select Item Field -->
|
|
<BaseMultiselect
|
|
v-else
|
|
ref="multiselectRef"
|
|
v-model="itemSelect"
|
|
:content-loading="contentLoading"
|
|
value-prop="id"
|
|
track-by="name"
|
|
:invalid="invalid"
|
|
preserve-search
|
|
:initial-search="itemData.name"
|
|
label="name"
|
|
:filter-results="false"
|
|
resolve-on-load
|
|
:delay="500"
|
|
searchable
|
|
:options="searchItems"
|
|
object
|
|
@update:model-value="(val: Item) => $emit('select', val)"
|
|
@search-change="onSearchChange"
|
|
>
|
|
<!-- Add Item Action -->
|
|
<template #action>
|
|
<BaseSelectAction
|
|
v-if="userStore.hasAbilities(ABILITIES.CREATE_ITEM)"
|
|
@click="openItemModal"
|
|
>
|
|
<BaseIcon
|
|
name="PlusCircleIcon"
|
|
class="h-4 mr-2 -ml-2 text-center text-primary-400"
|
|
/>
|
|
{{ $t('general.add_new_item') }}
|
|
</BaseSelectAction>
|
|
</template>
|
|
</BaseMultiselect>
|
|
|
|
<!-- Item Description -->
|
|
<div class="w-full pt-1 text-xs text-light">
|
|
<BaseTextarea
|
|
v-model="description"
|
|
:content-loading="contentLoading"
|
|
:autosize="true"
|
|
class="text-xs"
|
|
:borderless="true"
|
|
:placeholder="$t('estimates.item.type_item_description')"
|
|
:invalid="invalidDescription"
|
|
/>
|
|
<div v-if="invalidDescription">
|
|
<span class="text-red-600">
|
|
{{ $t('validation.description_maxlength') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|