|
@@ -0,0 +1,427 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <q-dialog ref="dialogRef" @hide="onDialogHide">
|
|
|
|
|
+ <q-card class="q-dialog-plugin column" style="width: 900px; max-width: 80vw; height: 90vh">
|
|
|
|
|
+ <DefaultDialogHeader :title="title" @close="onDialogCancel" />
|
|
|
|
|
+
|
|
|
|
|
+ <q-form ref="formRef" class="col scroll" @submit="onSubmit">
|
|
|
|
|
+ <q-card-section class="row q-col-gutter-md">
|
|
|
|
|
+ <ClientSelect
|
|
|
|
|
+ v-model="selectedClient"
|
|
|
|
|
+ :label="$t('schedules.client')"
|
|
|
|
|
+ :rules="[inputRules.required]"
|
|
|
|
|
+ :error="!!serverErrors?.client_id"
|
|
|
|
|
+ :error-message="serverErrors?.client_id"
|
|
|
|
|
+ :initial-id="opportunity ? opportunity.schedule?.client_id : null"
|
|
|
|
|
+ class="col-12"
|
|
|
|
|
+ @update:model-value="onClientChange"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <div class="col-12">
|
|
|
|
|
+ <div class="text-subtitle2 q-mb-sm">{{ $t('opportunities.address_type') }}</div>
|
|
|
|
|
+ <q-option-group
|
|
|
|
|
+ v-model="formData.address_type"
|
|
|
|
|
+ :options="addressTypeOptions"
|
|
|
|
|
+ color="primary"
|
|
|
|
|
+ inline
|
|
|
|
|
+ @update:model-value="onAddressTypeChange"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <AddressSelect
|
|
|
|
|
+ v-model="selectedAddress"
|
|
|
|
|
+ :label="$t('schedules.address')"
|
|
|
|
|
+ :rules="[inputRules.required]"
|
|
|
|
|
+ :error="!!serverErrors?.address_id"
|
|
|
|
|
+ :error-message="serverErrors?.address_id"
|
|
|
|
|
+ :initial-id="opportunity ? opportunity.schedule?.address_id : null"
|
|
|
|
|
+ :client-id="formData.client_id"
|
|
|
|
|
+ :disable="!formData.client_id || !formData.address_type"
|
|
|
|
|
+ class="col-12"
|
|
|
|
|
+ :type="formData.address_type"
|
|
|
|
|
+ @update:model-value="onAddressChange"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <ServiceTypeSelect
|
|
|
|
|
+ v-model="selectedServiceType"
|
|
|
|
|
+ :label="$t('opportunities.service_type')"
|
|
|
|
|
+ :rules="[inputRules.required]"
|
|
|
|
|
+ :error="!!serverErrors?.service_type_id"
|
|
|
|
|
+ :error-message="serverErrors?.service_type_id"
|
|
|
|
|
+ :initial-id="opportunity ? opportunity.service_type_id : null"
|
|
|
|
|
+ class="col-12"
|
|
|
|
|
+ @update:model-value="onServiceTypeChange"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <div class="col-12">
|
|
|
|
|
+ <div class="text-subtitle2 q-mb-sm col-12">
|
|
|
|
|
+ {{ $t('opportunities.specialities') }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="row items-center">
|
|
|
|
|
+ <SpecialitySelect
|
|
|
|
|
+ v-model="selectedSpeciality"
|
|
|
|
|
+ :label="$t('ui.navigation.speciality')"
|
|
|
|
|
+ :exclude-ids="excludedSpecialityIds"
|
|
|
|
|
+ class="col-md-8 col-12 q-mr-sm q-pb-none"
|
|
|
|
|
+ />
|
|
|
|
|
+ <div class="col-auto">
|
|
|
|
|
+ <q-btn
|
|
|
|
|
+ color="primary"
|
|
|
|
|
+ icon="mdi-plus"
|
|
|
|
|
+ padding="16px 16px"
|
|
|
|
|
+ :disable="!selectedSpeciality"
|
|
|
|
|
+ @click="handleAddSpeciality"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="formData.speciality_ids.length > 0" class="q-mt-sm q-gutter-sm">
|
|
|
|
|
+ <q-chip
|
|
|
|
|
+ v-for="(specialityId, index) in formData.speciality_ids"
|
|
|
|
|
+ :key="specialityId"
|
|
|
|
|
+ removable
|
|
|
|
|
+ color="primary"
|
|
|
|
|
+ text-color="white"
|
|
|
|
|
+ @remove="handleRemoveSpeciality(index)"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ getSpecialityName(specialityId) }}
|
|
|
|
|
+ </q-chip>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <q-input
|
|
|
|
|
+ v-model="formData.description"
|
|
|
|
|
+ :label="$t('opportunities.description_label')"
|
|
|
|
|
+ type="textarea"
|
|
|
|
|
+ outlined
|
|
|
|
|
+ rows="3"
|
|
|
|
|
+ :hint="$t('common.validation.optional')"
|
|
|
|
|
+ class="col-12"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <q-input
|
|
|
|
|
+ v-model.number="formData.quantity"
|
|
|
|
|
+ :label="$t('opportunities.quantity')"
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ outlined
|
|
|
|
|
+ min="1"
|
|
|
|
|
+ max="10"
|
|
|
|
|
+ :hint="$t('opportunities.quantity_hint')"
|
|
|
|
|
+ :rules="[
|
|
|
|
|
+ val => val >= 1 || $t('common.validation.min_value', { min: 1 }),
|
|
|
|
|
+ val => val <= 10 || $t('common.validation.max_value', { max: 10 })
|
|
|
|
|
+ ]"
|
|
|
|
|
+ class="col-12"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <div class="col-12">
|
|
|
|
|
+ <div class="text-subtitle2 q-mb-sm">{{ $t('schedules.period') }}</div>
|
|
|
|
|
+ <q-option-group
|
|
|
|
|
+ v-model="formData.period_type"
|
|
|
|
|
+ :options="periodOptions"
|
|
|
|
|
+ color="primary"
|
|
|
|
|
+ inline
|
|
|
|
|
+ @update:model-value="updateAvailableTimeSlots"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <DefaultCurrencyInput
|
|
|
|
|
+ v-model="formData.min_price"
|
|
|
|
|
+ :label="$t('opportunities.min_price')"
|
|
|
|
|
+ class="col-6"
|
|
|
|
|
+ />
|
|
|
|
|
+ <DefaultCurrencyInput
|
|
|
|
|
+ v-model="formData.max_price"
|
|
|
|
|
+ :label="$t('opportunities.max_price')"
|
|
|
|
|
+ class="col-6"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <div class="col-12 text-caption text-grey-7">
|
|
|
|
|
+ {{ $t('opportunities.price_note') }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <DefaultInputDatePicker
|
|
|
|
|
+ v-model:untreated-date="formData.date"
|
|
|
|
|
+ :label="$t('opportunities.date')"
|
|
|
|
|
+ :rules="[inputRules.required]"
|
|
|
|
|
+ :error="!!serverErrors?.date"
|
|
|
|
|
+ :error-message="serverErrors?.date"
|
|
|
|
|
+ class="col-12"
|
|
|
|
|
+ @update:untreated-date="updateAvailableTimeSlots"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <div v-show="formData.period_type && formData.date" class="col-12">
|
|
|
|
|
+ <div class="text-subtitle2 q-mb-sm">{{ $t('opportunities.select_time') }}</div>
|
|
|
|
|
+ <q-option-group
|
|
|
|
|
+ v-model="selectedTimeSlot"
|
|
|
|
|
+ :options="availableTimeSlots"
|
|
|
|
|
+ color="primary"
|
|
|
|
|
+ type="radio"
|
|
|
|
|
+ @update:model-value="onTimeSlotChange"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="col-12">
|
|
|
|
|
+ <div class="text-subtitle2 q-mb-sm">{{ $t('opportunities.offers_meal') }}</div>
|
|
|
|
|
+ <q-option-group
|
|
|
|
|
+ v-model="formData.offers_meal"
|
|
|
|
|
+ :options="mealOptions"
|
|
|
|
|
+ color="primary"
|
|
|
|
|
+ inline
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ </q-card-section>
|
|
|
|
|
+ <q-card-actions class="col-12 row q-gutter-sm justify-end q-mt-md">
|
|
|
|
|
+ <q-btn
|
|
|
|
|
+ :label="$t('common.actions.cancel')"
|
|
|
|
|
+ flat
|
|
|
|
|
+ color="grey-7"
|
|
|
|
|
+ @click="onDialogCancel"
|
|
|
|
|
+ />
|
|
|
|
|
+ <q-btn
|
|
|
|
|
+ type="submit"
|
|
|
|
|
+ :label="$t('common.actions.save')"
|
|
|
|
|
+ :loading="isLoading"
|
|
|
|
|
+ color="primary"
|
|
|
|
|
+ unelevated
|
|
|
|
|
+ />
|
|
|
|
|
+ </q-card-actions>
|
|
|
|
|
+ </q-form>
|
|
|
|
|
+ </q-card>
|
|
|
|
|
+ </q-dialog>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup>
|
|
|
|
|
+import { ref, computed, onMounted, watch } from 'vue'
|
|
|
|
|
+import { useDialogPluginComponent, useQuasar } from 'quasar'
|
|
|
|
|
+import { useI18n } from 'vue-i18n'
|
|
|
|
|
+import DefaultDialogHeader from 'src/components/defaults/DefaultDialogHeader.vue'
|
|
|
|
|
+import ClientSelect from 'src/components/client/ClientSelect.vue'
|
|
|
|
|
+import AddressSelect from 'src/components/address/AddressSelect.vue'
|
|
|
|
|
+import ServiceTypeSelect from 'src/components/serviceType/ServiceTypeSelect.vue'
|
|
|
|
|
+import SpecialitySelect from 'src/components/speciality/SpecialitySelect.vue'
|
|
|
|
|
+import DefaultInputDatePicker from 'src/components/defaults/DefaultInputDatePicker.vue'
|
|
|
|
|
+import DefaultCurrencyInput from 'src/components/defaults/DefaultCurrencyInput.vue'
|
|
|
|
|
+import { useInputRules } from 'src/composables/useInputRules';
|
|
|
|
|
+import { createCustomSchedule, updateCustomSchedule } from 'src/api/customSchedule'
|
|
|
|
|
+import { getSpecialities } from 'src/api/speciality'
|
|
|
|
|
+import { nextTick } from 'vue'
|
|
|
|
|
+
|
|
|
|
|
+const props = defineProps({
|
|
|
|
|
+ opportunity: {
|
|
|
|
|
+ type: Object,
|
|
|
|
|
+ default: null
|
|
|
|
|
+ },
|
|
|
|
|
+ title: {
|
|
|
|
|
+ type: Function,
|
|
|
|
|
+ required: true
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+defineEmits([
|
|
|
|
|
+ ...useDialogPluginComponent.emits
|
|
|
|
|
+])
|
|
|
|
|
+
|
|
|
|
|
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
|
|
|
|
|
+const $q = useQuasar()
|
|
|
|
|
+const { t } = useI18n()
|
|
|
|
|
+const { inputRules } = useInputRules();
|
|
|
|
|
+const isLoading = ref(false)
|
|
|
|
|
+const serverErrors = ref({})
|
|
|
|
|
+const selectedClient = ref(null)
|
|
|
|
|
+const selectedAddress = ref(null)
|
|
|
|
|
+const selectedServiceType = ref(null)
|
|
|
|
|
+const selectedSpeciality = ref(null)
|
|
|
|
|
+const selectedTimeSlot = ref(null)
|
|
|
|
|
+const allSpecialities = ref([])
|
|
|
|
|
+const isMounted = ref(false);
|
|
|
|
|
+const formData = ref({
|
|
|
|
|
+ client_id: null,
|
|
|
|
|
+ address_id: null,
|
|
|
|
|
+ address_type: null,
|
|
|
|
|
+ service_type_id: null,
|
|
|
|
|
+ speciality_ids: [],
|
|
|
|
|
+ description: null,
|
|
|
|
|
+ quantity: 1,
|
|
|
|
|
+ period_type: null,
|
|
|
|
|
+ min_price: 0,
|
|
|
|
|
+ max_price: 0,
|
|
|
|
|
+ offers_meal: false,
|
|
|
|
|
+ date: null,
|
|
|
|
|
+ start_time: null,
|
|
|
|
|
+ end_time: null
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const addressTypeOptions = computed(() => [
|
|
|
|
|
+ { label: t('opportunities.types.residencial'), value: 'home' },
|
|
|
|
|
+ { label: t('opportunities.types.comercial'), value: 'commercial' }
|
|
|
|
|
+])
|
|
|
|
|
+
|
|
|
|
|
+const periodOptions = computed(() => [
|
|
|
|
|
+ { label: '2h', value: '2' },
|
|
|
|
|
+ { label: '4h', value: '4' },
|
|
|
|
|
+ { label: '6h', value: '6' },
|
|
|
|
|
+ { label: '8h', value: '8' }
|
|
|
|
|
+])
|
|
|
|
|
+
|
|
|
|
|
+const mealOptions = computed(() => [
|
|
|
|
|
+ { label: t('opportunities.offers_meal_yes'), value: true },
|
|
|
|
|
+ { label: t('opportunities.offers_meal_no'), value: false }
|
|
|
|
|
+])
|
|
|
|
|
+
|
|
|
|
|
+const excludedSpecialityIds = computed(() => {
|
|
|
|
|
+ return formData.value.speciality_ids
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const availableTimeSlots = computed(() => {
|
|
|
|
|
+ if (!formData.value.period_type) return []
|
|
|
|
|
+
|
|
|
|
|
+ const period = parseInt(formData.value.period_type)
|
|
|
|
|
+ const slots = []
|
|
|
|
|
+
|
|
|
|
|
+ for (let hour = 7; hour <= 20 - period; hour++) {
|
|
|
|
|
+ const startTime = `${hour.toString().padStart(2, '0')}:00:00`
|
|
|
|
|
+ const endHour = hour + period
|
|
|
|
|
+ const endTime = `${endHour.toString().padStart(2, '0')}:00:00`
|
|
|
|
|
+ const slotValue = `${startTime}|${endTime}`
|
|
|
|
|
+
|
|
|
|
|
+ slots.push({
|
|
|
|
|
+ label: `${startTime} ${t('schedules.to')} ${endTime}`,
|
|
|
|
|
+ value: slotValue
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return slots;
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const getSpecialityName = (specialityId) => {
|
|
|
|
|
+ const speciality = allSpecialities.value.find(s => s.id === specialityId)
|
|
|
|
|
+ return speciality ? speciality.description : ''
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleAddSpeciality = () => {
|
|
|
|
|
+ if (selectedSpeciality.value && !formData.value.speciality_ids.includes(selectedSpeciality.value.id)) {
|
|
|
|
|
+ formData.value.speciality_ids.push(selectedSpeciality.value.value)
|
|
|
|
|
+ selectedSpeciality.value = null
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleRemoveSpeciality = (index) => {
|
|
|
|
|
+ formData.value.speciality_ids.splice(index, 1)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const onClientChange = (client) => {
|
|
|
|
|
+ formData.value.client_id = client?.value || null
|
|
|
|
|
+ formData.value.address_id = null
|
|
|
|
|
+ selectedAddress.value = null
|
|
|
|
|
+ serverErrors.value.client_id = null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const onAddressChange = (address) => {
|
|
|
|
|
+ formData.value.address_id = address?.id || null
|
|
|
|
|
+ serverErrors.value.address_id = null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const onServiceTypeChange = (serviceType) => {
|
|
|
|
|
+ formData.value.service_type_id = serviceType?.value || null
|
|
|
|
|
+ serverErrors.value.service_type_id = null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const onAddressTypeChange = () => {
|
|
|
|
|
+ formData.value.address_id = null
|
|
|
|
|
+ selectedAddress.value = null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const onTimeSlotChange = (slotValue) => {
|
|
|
|
|
+ if (!slotValue) return
|
|
|
|
|
+
|
|
|
|
|
+ const [start, end] = slotValue.split('|')
|
|
|
|
|
+
|
|
|
|
|
+ formData.value.start_time = start
|
|
|
|
|
+ formData.value.end_time = end
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const updateAvailableTimeSlots = () => {
|
|
|
|
|
+ if(!isMounted.value) return;
|
|
|
|
|
+ selectedTimeSlot.value = null;
|
|
|
|
|
+ formData.value.start_time = null;
|
|
|
|
|
+ formData.value.end_time = null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const loadSpecialities = async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ allSpecialities.value = await getSpecialities()
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ $q.notify({
|
|
|
|
|
+ type: 'negative',
|
|
|
|
|
+ message: error.message || t('common.ui.messages.error_loading_data')
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const onSubmit = async () => {
|
|
|
|
|
+ isLoading.value = true
|
|
|
|
|
+ serverErrors.value = {}
|
|
|
|
|
+ try {
|
|
|
|
|
+ const payload = {
|
|
|
|
|
+ ...formData.value
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (props.opportunity) {
|
|
|
|
|
+ await updateCustomSchedule(props.opportunity.id, payload)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await createCustomSchedule(payload)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ onDialogOK(true)
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (error.response?.data?.errors) {
|
|
|
|
|
+ serverErrors.value = error.response.data.errors
|
|
|
|
|
+ }
|
|
|
|
|
+ $q.notify({
|
|
|
|
|
+ type: 'negative',
|
|
|
|
|
+ message: error.message || t('common.ui.messages.error')
|
|
|
|
|
+ })
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ isLoading.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+watch(
|
|
|
|
|
+ () => [availableTimeSlots.value, formData.value.start_time, formData.value.end_time],
|
|
|
|
|
+ ([slots, startTime, endTime]) => {
|
|
|
|
|
+ if (!slots || !slots.length) return
|
|
|
|
|
+ if (!startTime || !endTime) return
|
|
|
|
|
+
|
|
|
|
|
+ const value = `${startTime}|${endTime}`
|
|
|
|
|
+
|
|
|
|
|
+ if (slots.some(s => s.value === value)) {
|
|
|
|
|
+ nextTick(() => {
|
|
|
|
|
+ selectedTimeSlot.value = value
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ { immediate: true, deep: true }
|
|
|
|
|
+)
|
|
|
|
|
+onMounted(async () => {
|
|
|
|
|
+ await loadSpecialities()
|
|
|
|
|
+
|
|
|
|
|
+ if (props.opportunity?.id ) {
|
|
|
|
|
+ formData.value = {
|
|
|
|
|
+ client_id: props.opportunity.schedule?.client_id,
|
|
|
|
|
+ address_id: props.opportunity.schedule?.address_id,
|
|
|
|
|
+ address_type: props.opportunity.address_type,
|
|
|
|
|
+ service_type_id: props.opportunity.service_type_id,
|
|
|
|
|
+ speciality_ids: props.opportunity.specialities?.map(s => s.id) || [],
|
|
|
|
|
+ description: props.opportunity.description,
|
|
|
|
|
+ quantity: 1,
|
|
|
|
|
+ period_type: String(props.opportunity.schedule?.period_type),
|
|
|
|
|
+ min_price: parseFloat(props.opportunity.min_price),
|
|
|
|
|
+ max_price: parseFloat(props.opportunity.max_price),
|
|
|
|
|
+ offers_meal: props.opportunity.offers_meal,
|
|
|
|
|
+ date: props.opportunity.schedule?.date,
|
|
|
|
|
+ start_time: props.opportunity.schedule?.start_time,
|
|
|
|
|
+ end_time: props.opportunity.schedule?.end_time
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ isMounted.value = true;
|
|
|
|
|
+})
|
|
|
|
|
+</script>
|