mirror of
https://github.com/crater-invoice/crater.git
synced 2025-10-28 04:01:10 -04:00
v6 update
This commit is contained in:
756
resources/scripts/admin/views/customers/Create.vue
Normal file
756
resources/scripts/admin/views/customers/Create.vue
Normal 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>
|
||||
368
resources/scripts/admin/views/customers/Index.vue
Normal file
368
resources/scripts/admin/views/customers/Index.vue
Normal 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>
|
||||
144
resources/scripts/admin/views/customers/View.vue
Normal file
144
resources/scripts/admin/views/customers/View.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
Reference in New Issue
Block a user