v6 update

This commit is contained in:
Mohit Panjwani
2022-01-10 16:06:17 +05:30
parent b770e6277f
commit bdea879273
722 changed files with 19047 additions and 9186 deletions

View File

@ -0,0 +1,756 @@
<template>
<BasePage>
<form @submit.prevent="submitCustomerData">
<BasePageHeader :title="pageTitle">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$tc('customers.customer', 2)"
to="/admin/customers"
/>
<BaseBreadcrumb-item :title="pageTitle" to="#" active />
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end">
<BaseButton type="submit" :loading="isSaving" :disabled="isSaving">
<template #left="slotProps">
<BaseIcon name="SaveIcon" :class="slotProps.class" />
</template>
{{
isEdit
? $t('customers.update_customer')
: $t('customers.save_customer')
}}
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseCard class="mt-5">
<!-- Basic Info -->
<div class="grid grid-cols-5 gap-4 mb-8">
<h6 class="col-span-5 text-lg font-semibold text-left lg:col-span-1">
{{ $t('customers.basic_info') }}
</h6>
<BaseInputGrid class="col-span-5 lg:col-span-4">
<BaseInputGroup
:label="$t('customers.display_name')"
required
:error="
v$.currentCustomer.name.$error &&
v$.currentCustomer.name.$errors[0].$message
"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="customerStore.currentCustomer.name"
:content-loading="isFetchingInitialData"
type="text"
name="name"
class=""
:invalid="v$.currentCustomer.name.$error"
@input="v$.currentCustomer.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.primary_contact_name')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.contact_name"
:content-loading="isFetchingInitialData"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:error="
v$.currentCustomer.email.$error &&
v$.currentCustomer.email.$errors[0].$message
"
:content-loading="isFetchingInitialData"
:label="$t('customers.email')"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.email"
:content-loading="isFetchingInitialData"
type="text"
name="email"
:invalid="v$.currentCustomer.email.$error"
@input="v$.currentCustomer.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.phone')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.phone"
:content-loading="isFetchingInitialData"
type="text"
name="phone"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.primary_currency')"
:content-loading="isFetchingInitialData"
:error="
v$.currentCustomer.currency_id.$error &&
v$.currentCustomer.currency_id.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="customerStore.currentCustomer.currency_id"
value-prop="id"
label="name"
track-by="name"
:content-loading="isFetchingInitialData"
:options="globalStore.currencies"
searchable
:can-deselect="false"
:placeholder="$t('customers.select_currency')"
:invalid="v$.currentCustomer.currency_id.$error"
class="w-full"
>
</BaseMultiselect>
</BaseInputGroup>
<BaseInputGroup
:error="
v$.currentCustomer.website.$error &&
v$.currentCustomer.website.$errors[0].$message
"
:label="$t('customers.website')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="customerStore.currentCustomer.website"
:content-loading="isFetchingInitialData"
type="url"
@input="v$.currentCustomer.website.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.prefix')"
:error="
v$.currentCustomer.prefix.$error &&
v$.currentCustomer.prefix.$errors[0].$message
"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="customerStore.currentCustomer.prefix"
:content-loading="isFetchingInitialData"
type="text"
name="name"
class=""
:invalid="v$.currentCustomer.prefix.$error"
@input="v$.currentCustomer.prefix.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
<BaseDivider class="mb-5 md:mb-8" />
<!-- Portal Access-->
<div class="grid grid-cols-5 gap-4 mb-8">
<h6 class="col-span-5 text-lg font-semibold text-left lg:col-span-1">
{{ $t('customers.portal_access') }}
</h6>
<BaseInputGrid class="col-span-5 lg:col-span-4">
<div class="md:col-span-2">
<p class="text-sm text-gray-500">
{{ $t('customers.portal_access_text') }}
</p>
<BaseSwitch
v-model="customerStore.currentCustomer.enable_portal"
class="mt-1 flex"
/>
</div>
<BaseInputGroup
v-if="customerStore.currentCustomer.enable_portal"
:content-loading="isFetchingInitialData"
:label="$t('customers.portal_access_url')"
class="md:col-span-2"
:help-text="$t('customers.portal_access_url_help')"
>
<CopyInputField :token="getCustomerPortalUrl" />
</BaseInputGroup>
<BaseInputGroup
v-if="customerStore.currentCustomer.enable_portal"
:content-loading="isFetchingInitialData"
:error="
v$.currentCustomer.password.$error &&
v$.currentCustomer.password.$errors[0].$message
"
:label="$t('customers.password')"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.password"
:content-loading="isFetchingInitialData"
:type="isShowPassword ? 'text' : 'password'"
name="password"
:invalid="v$.currentCustomer.password.$error"
@input="v$.currentCustomer.password.$touch()"
>
<template #right>
<BaseIcon
v-if="isShowPassword"
name="EyeOffIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
<BaseIcon
v-else
name="EyeIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowPassword = !isShowPassword"
/> </template
></BaseInput>
</BaseInputGroup>
<BaseInputGroup
v-if="customerStore.currentCustomer.enable_portal"
:error="
v$.currentCustomer.confirm_password.$error &&
v$.currentCustomer.confirm_password.$errors[0].$message
"
:content-loading="isFetchingInitialData"
label="Confirm Password"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.confirm_password"
:content-loading="isFetchingInitialData"
:type="isShowConfirmPassword ? 'text' : 'password'"
name="confirm_password"
:invalid="v$.currentCustomer.confirm_password.$error"
@input="v$.currentCustomer.confirm_password.$touch()"
>
<template #right>
<BaseIcon
v-if="isShowConfirmPassword"
name="EyeOffIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowConfirmPassword = !isShowConfirmPassword"
/>
<BaseIcon
v-else
name="EyeIcon"
class="w-5 h-5 mr-1 text-gray-500 cursor-pointer"
@click="isShowConfirmPassword = !isShowConfirmPassword"
/> </template
></BaseInput>
</BaseInputGroup>
</BaseInputGrid>
</div>
<BaseDivider class="mb-5 md:mb-8" />
<!-- Billing Address -->
<div class="grid grid-cols-5 gap-4 mb-8">
<h6 class="col-span-5 text-lg font-semibold text-left lg:col-span-1">
{{ $t('customers.billing_address') }}
</h6>
<BaseInputGrid
v-if="customerStore.currentCustomer.billing"
class="col-span-5 lg:col-span-4"
>
<BaseInputGroup
:label="$t('customers.name')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.billing.name"
:content-loading="isFetchingInitialData"
type="text"
class="w-full"
name="address_name"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.country')"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="customerStore.currentCustomer.billing.country_id"
value-prop="id"
label="name"
track-by="name"
resolve-on-load
searchable
:content-loading="isFetchingInitialData"
:options="globalStore.countries"
:placeholder="$t('general.select_country')"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.state')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="customerStore.currentCustomer.billing.state"
:content-loading="isFetchingInitialData"
name="billing.state"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('customers.city')"
>
<BaseInput
v-model="customerStore.currentCustomer.billing.city"
:content-loading="isFetchingInitialData"
name="billing.city"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.address')"
:error="
(v$.currentCustomer.billing.address_street_1.$error &&
v$.currentCustomer.billing.address_street_1.$errors[0]
.$message) ||
(v$.currentCustomer.billing.address_street_2.$error &&
v$.currentCustomer.billing.address_street_2.$errors[0]
.$message)
"
:content-loading="isFetchingInitialData"
>
<BaseTextarea
v-model.trim="
customerStore.currentCustomer.billing.address_street_1
"
:content-loading="isFetchingInitialData"
:placeholder="$t('general.street_1')"
type="text"
name="billing_street1"
:container-class="`mt-3`"
@input="v$.currentCustomer.billing.address_street_1.$touch()"
/>
<BaseTextarea
v-model.trim="
customerStore.currentCustomer.billing.address_street_2
"
:content-loading="isFetchingInitialData"
:placeholder="$t('general.street_2')"
type="text"
class="mt-3"
name="billing_street2"
:container-class="`mt-3`"
@input="v$.currentCustomer.billing.address_street_2.$touch()"
/>
</BaseInputGroup>
<div class="space-y-6">
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('customers.phone')"
class="text-left"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.billing.phone"
:content-loading="isFetchingInitialData"
type="text"
name="phone"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.zip_code')"
:content-loading="isFetchingInitialData"
class="mt-2 text-left"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.billing.zip"
:content-loading="isFetchingInitialData"
type="text"
name="zip"
/>
</BaseInputGroup>
</div>
</BaseInputGrid>
</div>
<BaseDivider class="mb-5 md:mb-8" />
<!-- Billing Address Copy Button -->
<div
class="flex items-center justify-start mb-6 md:justify-end md:mb-0"
>
<div class="p-1">
<BaseButton
type="button"
:content-loading="isFetchingInitialData"
size="sm"
variant="primary-outline"
@click="customerStore.copyAddress(true)"
>
<template #left="slotProps">
<BaseIcon
name="DocumentDuplicateIcon"
:class="slotProps.class"
/>
</template>
{{ $t('customers.copy_billing_address') }}
</BaseButton>
</div>
</div>
<!-- Shipping Address -->
<div
v-if="customerStore.currentCustomer.shipping"
class="grid grid-cols-5 gap-4 mb-8"
>
<h6 class="col-span-5 text-lg font-semibold text-left lg:col-span-1">
{{ $t('customers.shipping_address') }}
</h6>
<BaseInputGrid class="col-span-5 lg:col-span-4">
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('customers.name')"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.shipping.name"
:content-loading="isFetchingInitialData"
type="text"
name="address_name"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.country')"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="customerStore.currentCustomer.shipping.country_id"
value-prop="id"
label="name"
track-by="name"
resolve-on-load
searchable
:content-loading="isFetchingInitialData"
:options="globalStore.countries"
:placeholder="$t('general.select_country')"
class="w-full"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.state')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="customerStore.currentCustomer.shipping.state"
:content-loading="isFetchingInitialData"
name="shipping.state"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('customers.city')"
>
<BaseInput
v-model="customerStore.currentCustomer.shipping.city"
:content-loading="isFetchingInitialData"
name="shipping.city"
type="text"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.address')"
:content-loading="isFetchingInitialData"
:error="
(v$.currentCustomer.shipping.address_street_1.$error &&
v$.currentCustomer.shipping.address_street_1.$errors[0]
.$message) ||
(v$.currentCustomer.shipping.address_street_2.$error &&
v$.currentCustomer.shipping.address_street_2.$errors[0]
.$message)
"
>
<BaseTextarea
v-model.trim="
customerStore.currentCustomer.shipping.address_street_1
"
:content-loading="isFetchingInitialData"
type="text"
:placeholder="$t('general.street_1')"
name="shipping_street1"
@input="v$.currentCustomer.shipping.address_street_1.$touch()"
/>
<BaseTextarea
v-model.trim="
customerStore.currentCustomer.shipping.address_street_2
"
:content-loading="isFetchingInitialData"
type="text"
:placeholder="$t('general.street_2')"
name="shipping_street2"
class="mt-3"
:container-class="`mt-3`"
@input="v$.currentCustomer.shipping.address_street_2.$touch()"
/>
</BaseInputGroup>
<div class="space-y-6">
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('customers.phone')"
class="text-left"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.shipping.phone"
:content-loading="isFetchingInitialData"
type="text"
name="phone"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('customers.zip_code')"
:content-loading="isFetchingInitialData"
class="mt-2 text-left"
>
<BaseInput
v-model.trim="customerStore.currentCustomer.shipping.zip"
:content-loading="isFetchingInitialData"
type="text"
name="zip"
/>
</BaseInputGroup>
</div>
</BaseInputGrid>
</div>
<BaseDivider
v-if="customFieldStore.customFields.length > 0"
class="mb-5 md:mb-8"
/>
<!-- Customer Custom Fields -->
<div class="grid grid-cols-5 gap-2 mb-8">
<h6
v-if="customFieldStore.customFields.length > 0"
class="col-span-5 text-lg font-semibold text-left lg:col-span-1"
>
{{ $t('settings.custom_fields.title') }}
</h6>
<div class="col-span-5 lg:col-span-4">
<CustomerCustomFields
type="Customer"
:store="customerStore"
store-prop="currentCustomer"
:is-edit="isEdit"
:is-loading="isLoadingContent"
:custom-field-scope="customFieldValidationScope"
/>
</div>
</div>
</BaseCard>
</form>
</BasePage>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
required,
minLength,
url,
maxLength,
helpers,
email,
sameAs,
requiredIf,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import { useCustomFieldStore } from '@/scripts/admin/stores/custom-field'
import CustomerCustomFields from '@/scripts/admin/components/custom-fields/CreateCustomFields.vue'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import CopyInputField from '@/scripts/admin/components/CopyInputField.vue'
import { useCompanyStore } from '@/scripts/admin/stores/company'
const customerStore = useCustomerStore()
const customFieldStore = useCustomFieldStore()
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()
const customFieldValidationScope = 'customFields'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
let isFetchingInitialData = ref(false)
let isShowPassword = ref(false)
let isShowConfirmPassword = ref(false)
let active = ref(false)
const isSaving = ref(false)
const isEdit = computed(() => route.name === 'customers.edit')
let isLoadingContent = computed(() => customerStore.isFetchingInitialSettings)
const pageTitle = computed(() =>
isEdit.value ? t('customers.edit_customer') : t('customers.new_customer')
)
const rules = computed(() => {
return {
currentCustomer: {
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
prefix: {
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
currency_id: {
required: helpers.withMessage(t('validation.required'), required),
},
email: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(customerStore.currentCustomer.enable_portal == true)
),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
password: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(
customerStore.currentCustomer.enable_portal == true &&
!customerStore.currentCustomer.password_added
)
),
minLength: helpers.withMessage(
t('validation.password_min_length', { count: 8 }),
minLength(8)
),
},
confirm_password: {
sameAsPassword: helpers.withMessage(
t('validation.password_incorrect'),
sameAs(customerStore.currentCustomer.password)
),
},
website: {
url: helpers.withMessage(t('validation.invalid_url'), url),
},
billing: {
address_street_1: {
maxLength: helpers.withMessage(
t('validation.address_maxlength', { count: 255 }),
maxLength(255)
),
},
address_street_2: {
maxLength: helpers.withMessage(
t('validation.address_maxlength', { count: 255 }),
maxLength(255)
),
},
},
shipping: {
address_street_1: {
maxLength: helpers.withMessage(
t('validation.address_maxlength', { count: 255 }),
maxLength(255)
),
},
address_street_2: {
maxLength: helpers.withMessage(
t('validation.address_maxlength', { count: 255 }),
maxLength(255)
),
},
},
},
}
})
const getCustomerPortalUrl = computed(() => {
return `${window.location.origin}/${companyStore.selectedCompany.slug}/customer/login`
})
const v$ = useVuelidate(rules, customerStore, {
$scope: customFieldValidationScope,
})
customerStore.resetCurrentCustomer()
customerStore.fetchCustomerInitialSettings(isEdit.value)
async function submitCustomerData() {
v$.value.$touch()
if (v$.value.$invalid) {
return true
}
isSaving.value = true
let data = {
...customerStore.currentCustomer,
}
let response = null
try {
const action = isEdit.value
? customerStore.updateCustomer
: customerStore.addCustomer
response = await action(data)
} catch (err) {
isSaving.value = false
return
}
router.push(`/admin/customers/${response.data.data.id}/view`)
}
</script>

View File

@ -0,0 +1,368 @@
<template>
<BasePage>
<!-- Page Header Section -->
<BasePageHeader :title="$t('customers.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$tc('customers.customer', 2)"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end space-x-5">
<BaseButton
v-show="customerStore.totalCustomers"
variant="primary-outline"
@click="toggleFilter"
>
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FilterIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XIcon" :class="slotProps.class" />
</template>
</BaseButton>
<BaseButton
v-if="userStore.hasAbilities(abilities.CREATE_CUSTOMER)"
@click="$router.push('customers/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('customers.new_customer') }}
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-5" @clear="clearFilter">
<BaseInputGroup :label="$t('customers.display_name')" class="text-left">
<BaseInput
v-model="filters.display_name"
type="text"
name="name"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.contact_name')" class="text-left">
<BaseInput
v-model="filters.contact_name"
type="text"
name="address_name"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('customers.phone')" class="text-left">
<BaseInput
v-model="filters.phone"
type="text"
name="phone"
autocomplete="off"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('customers.no_customers')"
:description="$t('customers.list_of_customers')"
>
<AstronautIcon class="mt-5 mb-4" />
<template #actions>
<BaseButton
v-if="userStore.hasAbilities(abilities.CREATE_CUSTOMER)"
variant="primary-outline"
@click="$router.push('/admin/customers/create')"
>
<template #left="slotProps">
<BaseIcon name="PlusIcon" :class="slotProps.class" />
</template>
{{ $t('customers.add_new_customer') }}
</BaseButton>
</template>
</BaseEmptyPlaceholder>
<!-- Total no of Customers in Table -->
<div v-show="!showEmptyScreen" class="relative table-container">
<div class="relative flex items-center justify-end h-5">
<BaseDropdown v-if="customerStore.selectedCustomers.length">
<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="removeMultipleCustomers">
<BaseIcon name="TrashIcon" class="mr-3 text-gray-600" />
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</div>
<!-- Table Section -->
<BaseTable
ref="tableComponent"
class="mt-3"
:data="fetchData"
:columns="customerColumns"
>
<!-- Select All Checkbox -->
<template #header>
<div class="absolute z-10 items-center left-6 top-2.5 select-none">
<BaseCheckbox
v-model="selectAllFieldStatus"
variant="primary"
@change="customerStore.selectAllCustomers"
/>
</div>
</template>
<template #cell-status="{ row }">
<div class="relative block">
<BaseCheckbox
:id="row.data.id"
v-model="selectField"
:value="row.data.id"
variant="primary"
/>
</div>
</template>
<template #cell-name="{ row }">
<router-link :to="{ path: `customers/${row.data.id}/view` }">
<BaseText
:text="row.data.name"
:length="30"
tag="span"
class="font-medium text-primary-500 flex flex-col"
/>
<BaseText
:text="row.data.contact_name ? row.data.contact_name : ''"
:length="30"
tag="span"
class="text-xs text-gray-400"
/>
</router-link>
</template>
<template #cell-phone="{ row }">
<span>
{{ row.data.phone ? row.data.phone : '-' }}
</span>
</template>
<template #cell-due_amount="{ row }">
<BaseFormatMoney
:amount="row.data.due_amount || 0"
:currency="row.data.currency"
/>
</template>
<template #cell-created_at="{ row }">
<span>{{ row.data.formatted_created_at }}</span>
</template>
<template v-if="hasAtleastOneAbility()" #cell-actions="{ row }">
<CustomerDropdown
:row="row.data"
:table="tableComponent"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { debouncedWatch } from '@vueuse/core'
import moment from 'moment'
import { reactive, ref, inject, computed, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/stub/abilities'
import CustomerDropdown from '@/scripts/admin/components/dropdowns/CustomerIndexDropdown.vue'
import AstronautIcon from '@/scripts/components/icons/empty/AstronautIcon.vue'
const companyStore = useCompanyStore()
const dialogStore = useDialogStore()
const customerStore = useCustomerStore()
const userStore = useUserStore()
let tableComponent = ref(null)
let showFilters = ref(false)
let isFetchingInitialData = ref(true)
const { t } = useI18n()
let filters = reactive({
display_name: '',
contact_name: '',
phone: '',
})
const showEmptyScreen = computed(
() => !customerStore.totalCustomers && !isFetchingInitialData.value
)
const selectField = computed({
get: () => customerStore.selectedCustomers,
set: (value) => {
return customerStore.selectCustomer(value)
},
})
const selectAllFieldStatus = computed({
get: () => customerStore.selectAllField,
set: (value) => {
return customerStore.setSelectAllState(value)
},
})
const customerColumns = computed(() => {
return [
{
key: 'status',
thClass: 'extra w-10 pr-0',
sortable: false,
tdClass: 'font-medium text-gray-900 pr-0',
},
{
key: 'name',
label: t('customers.name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{ key: 'phone', label: t('customers.phone') },
{ key: 'due_amount', label: t('customers.amount_due') },
{
key: 'created_at',
label: t('items.added_on'),
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium pl-0',
thClass: 'pl-0',
sortable: false,
},
]
})
debouncedWatch(
filters,
() => {
setFilters()
},
{ debounce: 500 }
)
onUnmounted(() => {
if (customerStore.selectAllField) {
customerStore.selectAllCustomers()
}
})
function refreshTable() {
tableComponent.value.refresh()
}
function setFilters() {
refreshTable()
}
function hasAtleastOneAbility() {
return userStore.hasAbilities([
abilities.DELETE_CUSTOMER,
abilities.EDIT_CUSTOMER,
abilities.VIEW_CUSTOMER,
])
}
async function fetchData({ page, filter, sort }) {
let data = {
display_name: filters.display_name,
contact_name: filters.contact_name,
phone: filters.phone,
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
let response = await customerStore.fetchCustomers(data)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function clearFilter() {
filters.display_name = ''
filters.contact_name = ''
filters.phone = ''
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
let date = ref(new Date())
date.value = moment(date).format('YYYY-MM-DD')
function removeMultipleCustomers() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('customers.confirm_delete', 2),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then((res) => {
if (res) {
customerStore.deleteMultipleCustomers().then((response) => {
if (response.data) {
refreshTable()
}
})
}
})
}
</script>

View File

@ -0,0 +1,144 @@
<template>
<BasePage class="xl:pl-96">
<BasePageHeader :title="pageTitle">
<template #actions>
<router-link
v-if="userStore.hasAbilities(abilities.EDIT_CUSTOMER)"
:to="`/admin/customers/${route.params.id}/edit`"
>
<BaseButton
class="mr-3"
variant="primary-outline"
:content-loading="isLoading"
>
{{ $t('general.edit') }}
</BaseButton>
</router-link>
<BaseDropdown
v-if="canCreateTransaction()"
position="bottom-end"
:content-loading="isLoading"
>
<template #activator>
<BaseButton
class="mr-3"
variant="primary"
:content-loading="isLoading"
>
{{ $t('customers.new_transaction') }}
</BaseButton>
</template>
<router-link
v-if="userStore.hasAbilities(abilities.CREATE_ESTIMATE)"
:to="`/admin/estimates/create?customer=${$route.params.id}`"
>
<BaseDropdownItem class="">
<BaseIcon name="DocumentIcon" class="mr-3 text-gray-600" />
{{ $t('estimates.new_estimate') }}
</BaseDropdownItem>
</router-link>
<router-link
v-if="userStore.hasAbilities(abilities.CREATE_INVOICE)"
:to="`/admin/invoices/create?customer=${$route.params.id}`"
>
<BaseDropdownItem>
<BaseIcon name="DocumentTextIcon" class="mr-3 text-gray-600" />
{{ $t('invoices.new_invoice') }}
</BaseDropdownItem>
</router-link>
<router-link
v-if="userStore.hasAbilities(abilities.CREATE_PAYMENT)"
:to="`/admin/payments/create?customer=${$route.params.id}`"
>
<BaseDropdownItem>
<BaseIcon name="CreditCardIcon" class="mr-3 text-gray-600" />
{{ $t('payments.new_payment') }}
</BaseDropdownItem>
</router-link>
<router-link
v-if="userStore.hasAbilities(abilities.CREATE_EXPENSE)"
:to="`/admin/expenses/create?customer=${$route.params.id}`"
>
<BaseDropdownItem>
<BaseIcon name="CalculatorIcon" class="mr-3 text-gray-600" />
{{ $t('expenses.new_expense') }}
</BaseDropdownItem>
</router-link>
</BaseDropdown>
<CustomerDropdown
v-if="hasAtleastOneAbility()"
:class="{
'ml-3': isLoading,
}"
:row="customerStore.selectedViewCustomer"
:load-data="refreshData"
/>
</template>
</BasePageHeader>
<!-- Customer View Sidebar -->
<CustomerViewSidebar />
<!-- Chart -->
<CustomerChart />
</BasePage>
</template>
<script setup>
import CustomerViewSidebar from './partials/CustomerViewSidebar.vue'
import CustomerChart from './partials/CustomerChart.vue'
import { ref, computed, inject } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useUserStore } from '@/scripts/admin/stores/user'
import CustomerDropdown from '@/scripts/admin/components/dropdowns/CustomerIndexDropdown.vue'
import abilities from '@/scripts/admin/stub/abilities'
const utils = inject('utils')
const dialogStore = useDialogStore()
const customerStore = useCustomerStore()
const userStore = useUserStore()
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const customer = ref(null)
const pageTitle = computed(() => {
return customerStore.selectedViewCustomer.customer
? customerStore.selectedViewCustomer.customer.name
: ''
})
let isLoading = computed(() => {
return customerStore.isFetchingViewData
})
function canCreateTransaction() {
return userStore.hasAbilities([
abilities.CREATE_ESTIMATE,
abilities.CREATE_INVOICE,
abilities.CREATE_PAYMENT,
abilities.CREATE_EXPENSE,
])
}
function hasAtleastOneAbility() {
return userStore.hasAbilities([
abilities.DELETE_CUSTOMER,
abilities.EDIT_CUSTOMER,
])
}
function refreshData() {
router.push('/admin/customers')
}
</script>

View File

@ -0,0 +1,222 @@
<template>
<BaseCard class="flex flex-col mt-6">
<ChartPlaceholder v-if="customerStore.isFetchingViewData" />
<div v-else class="grid grid-cols-12">
<div class="col-span-12 xl:col-span-9 xxl:col-span-10">
<div class="flex justify-between mt-1 mb-6">
<h6 class="flex items-center">
<BaseIcon name="ChartSquareBarIcon" class="h-5 text-primary-400" />
{{ $t('dashboard.monthly_chart.title') }}
</h6>
<div class="w-40 h-10">
<BaseMultiselect
v-model="selectedYear"
:options="years"
:allow-empty="false"
:show-labels="false"
:placeholder="$t('dashboard.select_year')"
:can-deselect="false"
@select="onChangeYear"
/>
</div>
</div>
<LineChart
v-if="isLoading"
:invoices="getChartInvoices"
:expenses="getChartExpenses"
:receipts="getReceiptTotals"
:income="getNetProfits"
:labels="getChartMonths"
class="sm:w-full"
/>
</div>
<div
class="
grid
col-span-12
mt-6
text-center
xl:mt-0
sm:grid-cols-4
xl:text-right xl:col-span-3 xl:grid-cols-1
xxl:col-span-2
"
>
<div class="px-6 py-2">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.total_sales') }}
</span>
<br />
<span
v-if="isLoading"
class="block mt-1 text-xl font-semibold leading-8"
>
<BaseFormatMoney
:amount="chartData.salesTotal"
:currency="data.currency"
/>
</span>
</div>
<div class="px-6 py-2">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.total_receipts') }}
</span>
<br />
<span
v-if="isLoading"
class="block mt-1 text-xl font-semibold leading-8"
style="color: #00c99c"
>
<BaseFormatMoney
:amount="chartData.totalExpenses"
:currency="data.currency"
/>
</span>
</div>
<div class="px-6 py-2">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.total_expense') }}
</span>
<br />
<span
v-if="isLoading"
class="block mt-1 text-xl font-semibold leading-8"
style="color: #fb7178"
>
<BaseFormatMoney
:amount="chartData.totalExpenses"
:currency="data.currency"
/>
</span>
</div>
<div class="px-6 py-2">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.net_income') }}
</span>
<br />
<span
v-if="isLoading"
class="block mt-1 text-xl font-semibold leading-8"
style="color: #5851d8"
>
<BaseFormatMoney
:amount="chartData.netProfit"
:currency="data.currency"
/>
</span>
</div>
</div>
</div>
<CustomerInfo />
</BaseCard>
</template>
<script setup>
import CustomerInfo from './CustomerInfo.vue'
import LineChart from '@/scripts/admin/components/charts/LineChart.vue'
import { ref, computed, watch, reactive, inject } from 'vue'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import { useRoute } from 'vue-router'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import ChartPlaceholder from './CustomerChartPlaceholder.vue'
const companyStore = useCompanyStore()
const customerStore = useCustomerStore()
const utils = inject('utils')
const route = useRoute()
let isLoading = ref(false)
let chartData = reactive({})
let data = reactive({})
let years = reactive(['This year', 'Previous year'])
let selectedYear = ref('This year')
const getChartExpenses = computed(() => {
if (chartData.expenseTotals) {
return chartData.expenseTotals
}
return []
})
const getNetProfits = computed(() => {
if (chartData.netProfits) {
return chartData.netProfits
}
return []
})
const getChartMonths = computed(() => {
if (chartData && chartData.months) {
return chartData.months
}
return []
})
const getReceiptTotals = computed(() => {
if (chartData.receiptTotals) {
return chartData.receiptTotals
}
return []
})
const getChartInvoices = computed(() => {
if (chartData.invoiceTotals) {
return chartData.invoiceTotals
}
return []
})
watch(
route,
() => {
if (route.params.id) {
loadCustomer()
}
selectedYear.value = 'This year'
},
{ immediate: true }
)
async function loadCustomer() {
isLoading.value = false
let response = await customerStore.fetchViewCustomer({
id: route.params.id,
})
if (response.data) {
Object.assign(chartData, response.data.meta.chartData)
Object.assign(data, response.data.data)
}
isLoading.value = true
}
async function onChangeYear(data) {
let params = {
id: route.params.id,
}
data === 'Previous year'
? (params.previous_year = true)
: (params.this_year = true)
let response = await customerStore.fetchViewCustomer(params)
if (response.data.meta.chartData) {
Object.assign(chartData, response.data.meta.chartData)
}
return true
}
</script>

View File

@ -0,0 +1,79 @@
<template>
<BaseContentPlaceholders class="grid grid-cols-12">
<div class="col-span-12 xl:col-span-9 xxl:col-span-10">
<div class="flex justify-between mt-1 mb-6">
<BaseContentPlaceholdersText class="h-10 w-36" :lines="1" />
<BaseContentPlaceholdersText class="h-10 w-40 !mt-0" :lines="1" />
</div>
<BaseContentPlaceholdersBox class="h-80 xl:h-72 sm:w-full" />
</div>
<div
class="
grid
col-span-12
mt-6
text-center
xl:mt-0
sm:grid-cols-4
xl:text-right xl:col-span-3 xl:grid-cols-1
xxl:col-span-2
"
>
<div
class="
flex flex-col
items-center
justify-center
px-6
py-2
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
<div
class="
flex flex-col
items-center
justify-center
px-6
py-2
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
<div
class="
flex flex-col
items-center
justify-center
px-6
py-2
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
<div
class="
flex flex-col
items-center
justify-center
px-6
py-2
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
</div>
</BaseContentPlaceholders>
</template>

View File

@ -0,0 +1,119 @@
<template>
<div class="pt-6 mt-5 border-t border-solid lg:pt-8 md:pt-4 border-gray-200">
<!-- Basic Info -->
<BaseHeading>
{{ $t('customers.basic_info') }}
</BaseHeading>
<BaseDescriptionList>
<BaseDescriptionListItem
:content-loading="contentLoading"
:label="$t('customers.display_name')"
:value="selectedViewCustomer?.name"
/>
<BaseDescriptionListItem
:content-loading="contentLoading"
:label="$t('customers.primary_contact_name')"
:value="selectedViewCustomer?.contact_name"
/>
<BaseDescriptionListItem
:content-loading="contentLoading"
:label="$t('customers.email')"
:value="selectedViewCustomer?.email"
/>
</BaseDescriptionList>
<BaseDescriptionList class="mt-5">
<BaseDescriptionListItem
:content-loading="contentLoading"
:label="$t('wizard.currency')"
:value="
selectedViewCustomer?.currency
? `${selectedViewCustomer?.currency?.code} (${selectedViewCustomer?.currency?.symbol})`
: ''
"
/>
<BaseDescriptionListItem
:content-loading="contentLoading"
:label="$t('customers.phone_number')"
:value="selectedViewCustomer?.phone"
/>
<BaseDescriptionListItem
:content-loading="contentLoading"
:label="$t('customers.website')"
:value="selectedViewCustomer?.website"
/>
</BaseDescriptionList>
<!-- Address -->
<BaseHeading
v-if="selectedViewCustomer.billing || selectedViewCustomer.shipping"
class="mt-8"
>
{{ $t('customers.address') }}
</BaseHeading>
<BaseDescriptionList class="mt-5">
<BaseDescriptionListItem
v-if="selectedViewCustomer.billing"
:content-loading="contentLoading"
:label="$t('customers.billing_address')"
>
<BaseCustomerAddressDisplay :address="selectedViewCustomer.billing" />
</BaseDescriptionListItem>
<BaseDescriptionListItem
v-if="selectedViewCustomer.shipping"
:content-loading="contentLoading"
:label="$t('customers.shipping_address')"
>
<BaseCustomerAddressDisplay :address="selectedViewCustomer.shipping" />
</BaseDescriptionListItem>
</BaseDescriptionList>
<!-- Custom Fields -->
<BaseHeading v-if="customerCustomFields.length > 0" class="mt-8">
{{ $t('settings.custom_fields.title') }}
</BaseHeading>
<BaseDescriptionList class="mt-5">
<BaseDescriptionListItem
v-for="(field, index) in customerCustomFields"
:key="index"
:content-loading="contentLoading"
:label="field.custom_field.label"
>
<p
v-if="field.type === 'Switch'"
class="text-sm font-bold leading-5 text-black non-italic"
>
<span v-if="field.default_answer === 1"> Yes </span>
<span v-else> No </span>
</p>
<p v-else class="text-sm font-bold leading-5 text-black non-italic">
{{ field.default_answer }}
</p>
</BaseDescriptionListItem>
</BaseDescriptionList>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
const customerStore = useCustomerStore()
const selectedViewCustomer = computed(() => customerStore.selectedViewCustomer)
const contentLoading = computed(() => customerStore.isFetchingViewData)
const customerCustomFields = computed(() => {
if (selectedViewCustomer?.value?.fields) {
return selectedViewCustomer?.value?.fields
}
return []
})
</script>

View File

@ -0,0 +1,294 @@
<template>
<div
class="
fixed
top-0
left-0
hidden
h-full
pt-16
pb-4
ml-56
bg-white
xl:ml-64
w-88
xl:block
"
>
<div
class="
flex
items-center
justify-between
px-4
pt-8
pb-2
border border-gray-200 border-solid
height-full
"
>
<BaseInput
v-model="searchData.searchText"
:placeholder="$t('general.search')"
container-class="mb-6"
type="text"
variant="gray"
@input="onSearch()"
>
<BaseIcon name="SearchIcon" class="text-gray-500" />
</BaseInput>
<div class="flex mb-6 ml-3" role="group" aria-label="First group">
<BaseDropdown
:close-on-select="false"
position="bottom-start"
width-class="w-40"
position-class="left-0"
>
<template #activator>
<BaseButton variant="gray">
<BaseIcon name="FilterIcon" />
</BaseButton>
</template>
<div
class="
px-4
py-3
pb-2
mb-2
text-sm
border-b border-gray-200 border-solid
"
>
{{ $t('general.sort_by') }}
</div>
<div class="px-2">
<BaseDropdownItem
class="flex px-1 py-2 mt-1 cursor-pointer hover:rounded-md"
>
<BaseInputGroup class="pt-2 -mt-4">
<BaseRadio
id="filter_create_date"
v-model="searchData.orderByField"
:label="$t('customers.create_date')"
size="sm"
name="filter"
value="invoices.created_at"
@update:modelValue="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem class="flex px-1 cursor-pointer hover:rounded-md">
<BaseInputGroup class="pt-2 -mt-4">
<BaseRadio
id="filter_display_name"
v-model="searchData.orderByField"
:label="$t('customers.display_name')"
size="sm"
name="filter"
value="name"
@update:modelValue="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
</BaseDropdown>
<BaseButton class="ml-1" size="md" variant="gray" @click="sortData">
<BaseIcon v-if="getOrderBy" name="SortAscendingIcon" />
<BaseIcon v-else name="SortDescendingIcon" />
</BaseButton>
</div>
</div>
<div
class="
h-full
pb-32
overflow-y-scroll
border-l border-gray-200 border-solid
sidebar
base-scroll
"
>
<div v-for="(customer, index) in customerStore.customers" :key="index">
<router-link
v-if="customer && !isFetching"
:id="'customer-' + customer.id"
:to="`/admin/customers/${customer.id}/view`"
:class="[
'flex justify-between p-4 items-center cursor-pointer hover:bg-gray-100 border-l-4 border-transparent',
{
'bg-gray-100 border-l-4 border-primary-500 border-solid':
hasActiveUrl(customer.id),
},
]"
style="border-top: 1px solid rgba(185, 193, 209, 0.41)"
>
<div>
<BaseText
:text="customer.name"
:length="30"
class="
pr-2
text-sm
not-italic
font-normal
leading-5
text-black
capitalize
truncate
"
/>
<BaseText
v-if="customer.contact_name"
:text="customer.contact_name"
:length="30"
class="
mt-1
text-xs
not-italic
font-medium
leading-5
text-gray-600
"
/>
</div>
<div class="flex-1 font-bold text-right whitespace-nowrap">
<BaseFormatMoney
:amount="customer.due_amount"
:currency="customer.currency"
/>
</div>
</router-link>
</div>
<div class="flex justify-center p-4 items-center">
<LoadingIcon
v-if="isFetching"
class="h-6 m-1 animate-spin text-primary-400"
/>
</div>
<p
v-if="!customerStore.customers.length && !isFetching"
class="flex justify-center px-4 mt-5 text-sm text-gray-600"
>
{{ $t('customers.no_matching_customers') }}
</p>
</div>
</div>
</template>
<script setup>
import { computed, ref, reactive, watch, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
import { debounce } from 'lodash'
const customerStore = useCustomerStore()
const title = 'Customer View'
const route = useRoute()
const { t } = useI18n()
let isSearching = ref(false)
let isFetching = ref(false)
let searchData = reactive({
orderBy: '',
orderByField: '',
searchText: '',
})
onSearch = debounce(onSearch, 500)
const getOrderBy = computed(() => {
if (searchData.orderBy === 'asc' || searchData.orderBy == null) {
return true
}
return false
})
const getOrderName = computed(() =>
getOrderBy.value ? t('general.ascending') : t('general.descending')
)
function hasActiveUrl(id) {
return route.params.id == id
}
async function loadCustomers() {
isFetching.value = true
await customerStore.fetchCustomers({ limit: 'all' })
isFetching.value = false
setTimeout(() => {
scrollToCustomer()
}, 500)
}
function scrollToCustomer() {
const el = document.getElementById(`customer-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
}
}
async function onSearch() {
let data = {}
if (
searchData.searchText !== '' &&
searchData.searchText !== null &&
searchData.searchText !== undefined
) {
data.display_name = searchData.searchText
}
if (searchData.orderBy !== null && searchData.orderBy !== undefined) {
data.orderBy = searchData.orderBy
}
if (
searchData.orderByField !== null &&
searchData.orderByField !== undefined
) {
data.orderByField = searchData.orderByField
}
isSearching.value = true
try {
let response = await customerStore.fetchCustomers(data)
isSearching.value = false
if (response.data) {
customerStore.customers = response.data.data
}
} catch (error) {
isSearching.value = false
}
}
function sortData() {
if (searchData.orderBy === 'asc') {
searchData.orderBy = 'desc'
onSearch()
return true
}
searchData.orderBy = 'asc'
onSearch()
return true
}
loadCustomers()
</script>