mirror of
				https://github.com/crater-invoice/crater.git
				synced 2025-10-31 05:31:10 -04:00 
			
		
		
		
	v5.0.0 update
This commit is contained in:
		
							
								
								
									
										621
									
								
								resources/scripts/views/customers/Create.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										621
									
								
								resources/scripts/views/customers/Create.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,621 @@ | ||||
| <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" /> | ||||
|  | ||||
|         <!-- 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, | ||||
| } from '@vuelidate/validators' | ||||
| import useVuelidate from '@vuelidate/core' | ||||
| import { useCustomerStore } from '@/scripts/stores/customer' | ||||
| import { useCustomFieldStore } from '@/scripts/stores/custom-field' | ||||
| import CustomerCustomFields from '@/scripts/components/custom-fields/CreateCustomFields.vue' | ||||
| import { useGlobalStore } from '@/scripts/stores/global' | ||||
|  | ||||
| const customerStore = useCustomerStore() | ||||
| const customFieldStore = useCustomFieldStore() | ||||
| const globalStore = useGlobalStore() | ||||
|  | ||||
| const customFieldValidationScope = 'customFields' | ||||
|  | ||||
| const { t } = useI18n() | ||||
|  | ||||
| const router = useRouter() | ||||
| const route = useRoute() | ||||
|  | ||||
| let isFetchingInitialData = 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: { | ||||
|         email: helpers.withMessage(t('validation.email_incorrect'), email), | ||||
|       }, | ||||
|       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 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> | ||||
							
								
								
									
										363
									
								
								resources/scripts/views/customers/Index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										363
									
								
								resources/scripts/views/customers/Index.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,363 @@ | ||||
| <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` }" | ||||
|             class="font-medium text-primary-500 flex flex-col" | ||||
|           > | ||||
|             {{ row.data.name }} | ||||
|             <span class="text-xs text-gray-400"> | ||||
|               {{ row.data.contact_name ? row.data.contact_name : '' }}</span | ||||
|             > | ||||
|           </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/stores/customer' | ||||
| import { useDialogStore } from '@/scripts/stores/dialog' | ||||
| import { useCompanyStore } from '@/scripts/stores/company' | ||||
| import { useUserStore } from '@/scripts/stores/user' | ||||
|  | ||||
| import abilities from '@/scripts/stub/abilities' | ||||
|  | ||||
| import CustomerDropdown from '@/scripts/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/views/customers/View.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								resources/scripts/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/stores/customer' | ||||
| import { useDialogStore } from '@/scripts/stores/dialog' | ||||
| import { useUserStore } from '@/scripts/stores/user' | ||||
| import CustomerDropdown from '@/scripts/components/dropdowns/CustomerIndexDropdown.vue' | ||||
| import abilities from '@/scripts/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> | ||||
							
								
								
									
										222
									
								
								resources/scripts/views/customers/partials/CustomerChart.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										222
									
								
								resources/scripts/views/customers/partials/CustomerChart.vue
									
									
									
									
									
										Normal 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/components/charts/LineChart.vue' | ||||
| import { ref, computed, watch, reactive, inject } from 'vue' | ||||
| import { useCustomerStore } from '@/scripts/stores/customer' | ||||
| import { useRoute } from 'vue-router' | ||||
| import { useCompanyStore } from '@/scripts/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> | ||||
							
								
								
									
										119
									
								
								resources/scripts/views/customers/partials/CustomerInfo.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								resources/scripts/views/customers/partials/CustomerInfo.vue
									
									
									
									
									
										Normal 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/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,293 @@ | ||||
| <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> | ||||
|             <div | ||||
|               class=" | ||||
|                 pr-2 | ||||
|                 text-sm | ||||
|                 not-italic | ||||
|                 font-normal | ||||
|                 leading-5 | ||||
|                 text-black | ||||
|                 capitalize | ||||
|                 truncate | ||||
|               " | ||||
|             > | ||||
|               {{ customer.name }} | ||||
|             </div> | ||||
|             <div | ||||
|               v-if="customer.contact_name" | ||||
|               class=" | ||||
|                 mt-1 | ||||
|                 text-xs | ||||
|                 not-italic | ||||
|                 font-medium | ||||
|                 leading-5 | ||||
|                 text-gray-600 | ||||
|               " | ||||
|             > | ||||
|               {{ customer.contact_name }} | ||||
|             </div> | ||||
|           </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/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