Files
InvoiceShelf/resources/scripts-v2/features/company/estimates/views/EstimateIndexView.vue
Darko Gjorgjijoski c328d1cd10 Mount send modals on index views and dashboard, pass missing props
SendInvoiceModal and SendEstimateModal were only mounted on detail
views. Resend from table dropdowns did nothing because the modal
component was not in the DOM. Added to index views and dashboard.

Pass canCreatePayment and canCreateEstimate props to InvoiceDropdown
from detail view and dashboard.
2026-04-06 22:56:57 +02:00

512 lines
14 KiB
Vue

<template>
<BasePage>
<BasePageHeader :title="$t('estimates.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$t('estimates.estimate', 2)" to="#" active />
</BaseBreadcrumb>
<template #actions>
<BaseButton
v-show="estimateStore.totalEstimateCount"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
:class="slotProps.class"
name="FunnelIcon"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
<router-link v-if="canCreate" to="estimates/create">
<BaseButton variant="primary" class="ml-4">
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('estimates.new_estimate') }}
</BaseButton>
</router-link>
</template>
</BasePageHeader>
<!-- Filters -->
<BaseFilterWrapper
v-show="showFilters"
:row-on-xl="true"
@clear="clearFilter"
>
<BaseInputGroup :label="$t('customers.customer', 1)">
<BaseCustomerSelectInput
v-model="filters.customer_id"
:placeholder="$t('customers.type_or_click')"
value-prop="id"
label="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('estimates.status')">
<BaseMultiselect
v-model="filters.status"
:options="statusOptions"
searchable
:placeholder="$t('general.select_a_status')"
@update:model-value="setActiveTab"
@remove="clearStatusSearch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('general.from')">
<BaseDatePicker
v-model="filters.from_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<div
class="hidden w-8 h-0 mx-4 border border-gray-400 border-solid xl:block"
style="margin-top: 1.5rem"
/>
<BaseInputGroup :label="$t('general.to')">
<BaseDatePicker
v-model="filters.to_date"
:calendar-button="true"
calendar-button-icon="calendar"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('estimates.estimate_number')">
<BaseInput v-model="filters.estimate_number">
<template #left="slotProps">
<BaseIcon name="HashtagIcon" :class="slotProps.class" />
</template>
</BaseInput>
</BaseInputGroup>
</BaseFilterWrapper>
<!-- Empty State -->
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('estimates.no_estimates')"
:description="$t('estimates.list_of_estimates')"
>
<template #actions>
<BaseButton
v-if="canCreate"
variant="primary-outline"
@click="$router.push('/admin/estimates/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('estimates.add_new_estimate') }}
</BaseButton>
</template>
</BaseEmptyPlaceholder>
<!-- Table -->
<div v-show="!showEmptyScreen" class="relative table-container">
<div
class="relative flex items-center justify-between mt-5 list-none"
>
<BaseTabGroup @change="setStatusFilter">
<BaseTab :title="$t('general.all')" filter="" />
<BaseTab :title="$t('general.draft')" filter="DRAFT" />
<BaseTab :title="$t('general.sent')" filter="SENT" />
</BaseTabGroup>
<BaseDropdown
v-if="estimateStore.selectedEstimates.length && canDelete"
class="absolute float-right"
>
<template #activator>
<span
class="flex text-sm font-medium cursor-pointer select-none text-primary-400"
>
{{ $t('general.actions') }}
<BaseIcon name="ChevronDownIcon" />
</span>
</template>
<BaseDropdownItem @click="removeMultipleEstimates">
<BaseIcon name="TrashIcon" class="mr-3 text-body" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<BaseTable
ref="tableRef"
:data="fetchData"
:columns="estimateColumns"
:placeholder-count="estimateStore.totalEstimateCount >= 20 ? 10 : 5"
:key="tableKey"
class="mt-4"
>
<template #header>
<div class="absolute items-center left-6 top-2.5 select-none">
<BaseCheckbox
v-model="estimateStore.selectAllField"
variant="primary"
@change="estimateStore.selectAllEstimates"
/>
</div>
</template>
<template #cell-checkbox="{ row }">
<div class="relative block">
<BaseCheckbox
:id="row.id"
v-model="selectField"
:value="row.data.id"
/>
</div>
</template>
<template #cell-estimate_date="{ row }">
{{ row.data.formatted_estimate_date }}
</template>
<template #cell-estimate_number="{ row }">
<router-link
:to="{ path: `estimates/${row.data.id}/view` }"
class="font-medium text-primary-500"
>
{{ row.data.estimate_number }}
</router-link>
</template>
<template #cell-name="{ row }">
<router-link
v-if="row.data.customer?.id"
:to="`/admin/customers/${row.data.customer.id}/view`"
class="font-medium text-primary-500 hover:text-primary-600"
>
{{ row.data.customer.name }}
</router-link>
<span v-else>{{ row.data.customer?.name ?? '-' }}</span>
</template>
<template #cell-status="{ row }">
<BaseEstimateStatusBadge :status="row.data.status" class="px-3 py-1">
<BaseEstimateStatusLabel :status="row.data.status" />
</BaseEstimateStatusBadge>
</template>
<template #cell-total="{ row }">
<BaseFormatMoney
:amount="row.data.total"
:currency="row.data.customer.currency"
/>
</template>
<template v-if="hasAtLeastOneAbility" #cell-actions="{ row }">
<EstimateDropdown
:row="row.data"
:table="tableRef"
:can-edit="canEdit"
:can-view="canView"
:can-create="canCreate"
:can-delete="canDelete"
:can-send="canSend"
/>
</template>
</BaseTable>
</div>
</BasePage>
<SendEstimateModal />
</template>
<script setup lang="ts">
import { computed, onUnmounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { debouncedWatch } from '@vueuse/core'
import { useEstimateStore } from '../store'
import EstimateDropdown from '../components/EstimateDropdown.vue'
import SendEstimateModal from '../components/SendEstimateModal.vue'
import { useUserStore } from '../../../../stores/user.store'
import { useDialogStore } from '../../../../stores/dialog.store'
import type { Estimate } from '../../../../types/domain/estimate'
interface Props {
canCreate?: boolean
canEdit?: boolean
canView?: boolean
canDelete?: boolean
canSend?: boolean
}
const props = withDefaults(defineProps<Props>(), {
canCreate: false,
canEdit: false,
canView: false,
canDelete: false,
canSend: false,
})
const ABILITIES = {
CREATE: 'create-estimate',
EDIT: 'edit-estimate',
VIEW: 'view-estimate',
DELETE: 'delete-estimate',
SEND: 'send-estimate',
} as const
const estimateStore = useEstimateStore()
const userStore = useUserStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const tableRef = ref<{ refresh: () => void } | null>(null)
const tableKey = ref<number>(0)
const showFilters = ref<boolean>(false)
const isRequestOngoing = ref<boolean>(true)
const activeTab = ref<string>('general.draft')
interface StatusOption {
label: string
value: string
}
const statusOptions = ref<StatusOption[]>([
{ label: t('estimates.draft'), value: 'DRAFT' },
{ label: t('estimates.sent'), value: 'SENT' },
{ label: t('estimates.viewed'), value: 'VIEWED' },
{ label: t('estimates.expired'), value: 'EXPIRED' },
{ label: t('estimates.accepted'), value: 'ACCEPTED' },
{ label: t('estimates.rejected'), value: 'REJECTED' },
])
interface EstimateFilters {
customer_id: string | number
status: string
from_date: string
to_date: string
estimate_number: string
}
const filters = reactive<EstimateFilters>({
customer_id: '',
status: '',
from_date: '',
to_date: '',
estimate_number: '',
})
const showEmptyScreen = computed<boolean>(
() => !estimateStore.totalEstimateCount && !isRequestOngoing.value,
)
const selectField = computed<number[]>({
get: () => estimateStore.selectedEstimates,
set: (val: number[]) => {
estimateStore.selectEstimate(val)
},
})
const canCreate = computed<boolean>(() => {
return props.canCreate || userStore.hasAbilities(ABILITIES.CREATE)
})
const canEdit = computed<boolean>(() => {
return props.canEdit || userStore.hasAbilities(ABILITIES.EDIT)
})
const canView = computed<boolean>(() => {
return props.canView || userStore.hasAbilities(ABILITIES.VIEW)
})
const canDelete = computed<boolean>(() => {
return props.canDelete || userStore.hasAbilities(ABILITIES.DELETE)
})
const canSend = computed<boolean>(() => {
return props.canSend || userStore.hasAbilities(ABILITIES.SEND)
})
const hasAtLeastOneAbility = computed<boolean>(() => {
return canCreate.value || canEdit.value || canView.value || canSend.value
})
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
const estimateColumns = computed<TableColumn[]>(() => [
{
key: 'checkbox',
thClass: 'extra w-10 pr-0',
sortable: false,
tdClass: 'font-medium text-heading pr-0',
},
{
key: 'estimate_date',
label: t('estimates.date'),
thClass: 'extra',
tdClass: 'font-medium text-muted',
},
{ key: 'estimate_number', label: t('estimates.number', 2) },
{ key: 'name', label: t('estimates.customer') },
{ key: 'status', label: t('estimates.status') },
{
key: 'total',
label: t('estimates.total'),
tdClass: 'font-medium text-heading',
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium pl-0',
thClass: 'text-right pl-0',
sortable: false,
},
])
debouncedWatch(filters, () => setFilters(), { debounce: 500 })
onUnmounted(() => {
if (estimateStore.selectAllField) {
estimateStore.selectAllEstimates()
}
})
function clearStatusSearch(): void {
filters.status = ''
refreshTable()
}
function refreshTable(): void {
tableRef.value?.refresh()
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName?: string; order?: string }
}
interface FetchResult {
data: Estimate[]
pagination: {
totalPages: number
currentPage: number
totalCount: number
limit: number
}
}
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
customer_id: filters.customer_id ? Number(filters.customer_id) : undefined,
status: filters.status || undefined,
from_date: filters.from_date || undefined,
to_date: filters.to_date || undefined,
estimate_number: filters.estimate_number || undefined,
orderByField: sort.fieldName || 'created_at',
orderBy: (sort.order || 'desc') as 'asc' | 'desc',
page,
}
isRequestOngoing.value = true
const response = await estimateStore.fetchEstimates(data)
isRequestOngoing.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function setStatusFilter(val: { title: string }): void {
if (activeTab.value === val.title) return
activeTab.value = val.title
switch (val.title) {
case t('general.draft'):
filters.status = 'DRAFT'
break
case t('general.sent'):
filters.status = 'SENT'
break
default:
filters.status = ''
break
}
}
function setFilters(): void {
estimateStore.$patch((state) => {
state.selectedEstimates = []
state.selectAllField = false
})
tableKey.value += 1
refreshTable()
}
function clearFilter(): void {
filters.customer_id = ''
filters.status = ''
filters.from_date = ''
filters.to_date = ''
filters.estimate_number = ''
activeTab.value = t('general.all')
}
function toggleFilter(): void {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
function removeMultipleEstimates(): void {
dialogStore.openDialog({
title: t('general.are_you_sure'),
message: t('estimates.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
}).then(async (res: boolean) => {
if (res) {
const response = await estimateStore.deleteMultipleEstimates()
if (response.data) {
refreshTable()
estimateStore.$patch((state) => {
state.selectedEstimates = []
state.selectAllField = false
})
}
}
})
}
function setActiveTab(val: string): void {
const tabMap: Record<string, string> = {
DRAFT: t('general.draft'),
SENT: t('general.sent'),
VIEWED: t('estimates.viewed'),
EXPIRED: t('estimates.expired'),
ACCEPTED: t('estimates.accepted'),
REJECTED: t('estimates.rejected'),
}
activeTab.value = tabMap[val] ?? t('general.all')
}
</script>