Просмотр исходного кода

crud oportunidades (custom_schedules)

Gustavo Zanatta 2 недель назад
Родитель
Сommit
0cc68e44f6

+ 26 - 0
src/api/customSchedule.js

@@ -0,0 +1,26 @@
+import api from './index'
+
+export async function getCustomSchedules() {
+  const response = await api.get('/custom-schedule')
+  return response.data.payload
+}
+
+export async function getCustomSchedule(id) {
+  const response = await api.get(`/custom-schedule/${id}`)
+  return response.data.payload
+}
+
+export async function createCustomSchedule(data) {
+  const response = await api.post('/custom-schedule', data)
+  return response.data.payload
+}
+
+export async function updateCustomSchedule(id, data) {
+  const response = await api.put(`/custom-schedule/${id}`, data)
+  return response.data.payload
+}
+
+export async function deleteCustomSchedule(id) {
+  const response = await api.delete(`/custom-schedule/${id}`)
+  return response.data
+}

+ 33 - 20
src/components/address/AddressSelect.vue

@@ -28,7 +28,7 @@
 </template>
 
 <script setup>
-import { ref, watch } from 'vue'
+import { reactive, ref, watch } from 'vue'
 import { getAddresses } from 'src/api/address'
 import { normalizeString } from 'src/helpers/utils'
 
@@ -60,6 +60,10 @@ const props = defineProps({
   disabled: {
     type: Boolean,
     default: false
+  },
+  type: {
+    type: String,
+    default: null // home / commercial
   }
 })
 
@@ -70,57 +74,58 @@ const model = defineModel({
 const emit = defineEmits(['update:modelValue'])
 
 const addresses = ref([])
-const filteredAddresses = ref([])
+let filteredAddresses = reactive([])
 const selectedAddress = ref(null)
 const loading = ref(false)
 
 const loadAddresses = async () => {
   if (!props.clientId) {
     addresses.value = []
-    filteredAddresses.value = []
+    filteredAddresses = []
     return
   }
 
   loading.value = true
   try {
-    const response = await getAddresses('client', props.clientId)
+    const response = await getAddresses('client', props.clientId);
     addresses.value = response.map((address) => ({
       label: `${address.address_full}`,
       value: address.id,
       ...address
-    }))
-    filteredAddresses.value = addresses.value
+    }));
+
+    filteredAddresses = addresses.value;
 
     if (props.initialId) {
-      selectAddressById(props.initialId)
+      selectAddressById(props.initialId);
     }
   } catch (error) {
-    console.error('Error loading addresses:', error)
+    console.error('Error loading addresses:', error);
   } finally {
-    loading.value = false
+    loading.value = false;
   }
 }
 
 const filterFn = (val, update) => {
   update(() => {
-    const needle = normalizeString(val)
-    filteredAddresses.value = addresses.value.filter((address) => {
-      const addressString = normalizeString(address.label)
-      return addressString.includes(needle)
+    const needle = normalizeString(val);
+    filteredAddresses = addresses.value.filter((address) => {
+      const addressString = normalizeString(address.label);
+      return addressString.includes(needle);
     })
   })
 }
 
 const updateValue = (value) => {
-  model.value = value
-  emit('update:modelValue', value)
+  model.value = value;
+  emit('update:modelValue', value);
 }
 
 const selectAddressById = (id) => {
-  const address = addresses.value.find((a) => a.value === id)
+  const address = addresses.value.find((a) => a.value === id);
   if (address) {
-    selectedAddress.value = address
-    updateValue(address)
+    selectedAddress.value = address;
+    updateValue(address);
   }
 }
 
@@ -129,11 +134,19 @@ watch(() => props.clientId, (newClientId) => {
     loadAddresses()
   } else {
     addresses.value = []
-    filteredAddresses.value = []
+    filteredAddresses = []
     selectedAddress.value = null
     updateValue(null)
   }
-}, { immediate: true })
+}, { immediate: true });
+
+watch(() => props.type, (newType) => {
+  if (newType) {
+    filteredAddresses = addresses.value.filter(addr => addr.address_type === newType);
+  } else {
+    loadAddresses()
+  }
+})
 
 watch(() => props.initialId, (newId) => {
   if (newId && addresses.value.length > 0) {

+ 2 - 0
src/components/defaults/DefaultCurrencyInput.vue

@@ -21,6 +21,8 @@ const model = defineModel({
   type: Number,
 });
 
+defineEmits(['update:modelValue', 'change'])
+
 const { options, disable, readonly, label, error, errorMessage } = defineProps({
   options: {
     type: Object,

+ 39 - 0
src/i18n/locales/en.json

@@ -100,6 +100,12 @@
     "validations": {
       "required": "Required field"
     },
+    "validation": {
+      "required": "Required field",
+      "optional": "Optional",
+      "min_value": "Minimum value: {min}",
+      "max_value": "Maximum value: {max}"
+    },
     "ui": {
       "file": {
         "choose": "Choose a file",
@@ -121,6 +127,7 @@
         "error": "Error processing request",
         "error_loading_data": "Error loading data",
         "updated": "Successfully updated",
+        "created": "successfully created",
         "are_you_sure_delete": "Are you sure you want to delete this item?",
         "welcome": "Welcome",
         "enjoy_the_event": "Enjoy the event!"
@@ -455,6 +462,7 @@
     "code": "Code",
     "code_verified": "Code Verified",
     "hours": "hours",
+    "to": "to",
     "add_date": "Add Date",
     "selected_dates": "Selected Dates",
     "schedule_dates": "Schedule Dates",
@@ -489,6 +497,37 @@
       "finished": "Finished"
     }
   },
+  "opportunities": {
+    "singular": "Opportunity",
+    "plural": "Opportunities",
+    "title": "Opportunities",
+    "description": "Manage custom scheduling opportunities",
+    "empty_state": "No opportunities found",
+    "client": "Client",
+    "date": "Date",
+    "period": "Period",
+    "price_range": "Price Range",
+    "add": "Add Opportunity",
+    "edit": "Edit Opportunity",
+    "quantity": "Number of Services",
+    "quantity_hint": "N opportunities will be created with the same date",
+    "address_type": "Address Type",
+    "service_type": "Service Type",
+    "specialities": "Preferred Specialties",
+    "description_label": "Request Description",
+    "min_price": "Minimum Price",
+    "max_price": "Maximum Price",
+    "max_price_validation": "Maximum price must be greater than or equal to minimum",
+    "offers_meal": "Offers Meal",
+    "offers_meal_yes": "I offer meal",
+    "offers_meal_no": "I don't offer meal",
+    "price_note": "Price range for 8h per day",
+    "select_time": "Select time",
+    "types": {
+      "residencial": "Residential",
+      "comercial": "Commercial"
+    }
+  },
   "orders": {
     "singular": "Order",
     "plural": "Orders",

+ 39 - 0
src/i18n/locales/es.json

@@ -100,6 +100,12 @@
     "validations": {
       "required": "Campo obligatorio"
     },
+    "validation": {
+      "required": "Campo obligatorio",
+      "optional": "Opcional",
+      "min_value": "Valor mínimo: {min}",
+      "max_value": "Valor máximo: {max}"
+    },
     "ui": {
       "file": {
         "choose": "Elegir un archivo",
@@ -121,6 +127,7 @@
         "error": "Error al procesar solicitud",
         "error_loading_data": "Error al cargar datos",
         "updated": "Actualizado con éxito",
+        "created": "creadas con éxito",
         "are_you_sure_delete": "¿Estás seguro de que quieres eliminar este elemento?",
         "welcome": "Bienvenido",
         "enjoy_the_event": "¡Disfruta el evento!"
@@ -455,6 +462,7 @@
     "code": "Código",
     "code_verified": "Código Verificado",
     "hours": "horas",
+    "to": "hasta",
     "add_date": "Agregar Fecha",
     "selected_dates": "Fechas Seleccionadas",
     "schedule_dates": "Fechas de las Agendas",
@@ -489,6 +497,37 @@
       "finished": "Finalizado"
     }
   },
+  "opportunities": {
+    "singular": "Oportunidad",
+    "plural": "Oportunidades",
+    "title": "Oportunidades",
+    "description": "Gestionar oportunidades de agenda personalizada",
+    "empty_state": "No se encontraron oportunidades",
+    "client": "Cliente",
+    "date": "Fecha",
+    "period": "Período",
+    "price_range": "Rango de Precio",
+    "add": "Agregar Oportunidad",
+    "edit": "Editar Oportunidad",
+    "quantity": "Cantidad de Servicios",
+    "quantity_hint": "Se crearán N oportunidades con la misma fecha",
+    "address_type": "Tipo de Dirección",
+    "service_type": "Tipo de Servicio",
+    "specialities": "Especialidades Preferidas",
+    "description_label": "Descripción del Pedido",
+    "min_price": "Precio Mínimo",
+    "max_price": "Precio Máximo",
+    "max_price_validation": "El precio máximo debe ser mayor o igual al mínimo",
+    "offers_meal": "Ofrece Comida",
+    "offers_meal_yes": "Ofrezco comida",
+    "offers_meal_no": "No ofrezco comida",
+    "price_note": "Rango de precio para 8h por día",
+    "select_time": "Seleccione el horario",
+    "types": {
+      "residencial": "Residencial",
+      "comercial": "Comercial"
+    }
+  },
   "orders": {
     "singular": "Pedido",
     "plural": "Pedidos",

+ 39 - 0
src/i18n/locales/pt.json

@@ -100,6 +100,12 @@
     "validations": {
       "required": "Campo obrigatório"
     },
+    "validation": {
+      "required": "Campo obrigatório",
+      "optional": "Opcional",
+      "min_value": "Valor mínimo: {min}",
+      "max_value": "Valor máximo: {max}"
+    },
     "ui": {
       "file": {
         "choose": "Escolha um arquivo",
@@ -121,6 +127,7 @@
         "error": "Erro ao processar solicitação",
         "error_loading_data": "Erro ao carregar dados",
         "updated": "Atualizado com sucesso",
+        "created": "criadas com sucesso",
         "are_you_sure_delete": "Tem certeza de que deseja excluir este item?",
         "welcome": "Bem-vindo",
         "enjoy_the_event": "Aproveite o evento!"
@@ -455,6 +462,7 @@
     "code": "Código",
     "code_verified": "Código Verificado",
     "hours": "horas",
+    "to": "até",
     "add_date": "Adicionar Data",
     "selected_dates": "Datas Selecionadas",
     "schedule_dates": "Datas dos Agendamentos",
@@ -489,6 +497,37 @@
       "finished": "Finalizado"
     }
   },
+  "opportunities": {
+    "singular": "Oportunidade",
+    "plural": "Oportunidades",
+    "title": "Oportunidades",
+    "description": "Gerencie as oportunidades de agendamento personalizado",
+    "empty_state": "Nenhuma oportunidade encontrada",
+    "client": "Cliente",
+    "date": "Data",
+    "period": "Período",
+    "price_range": "Faixa de Preço",
+    "add": "Adicionar Oportunidade",
+    "edit": "Editar Oportunidade",
+    "quantity": "Quantidade de Serviços",
+    "quantity_hint": "Serão criadas N oportunidades com a mesma data",
+    "address_type": "Tipo de Endereço",
+    "service_type": "Tipo de Serviço",
+    "specialities": "Especialidades Preferenciais",
+    "description_label": "Descrição do Pedido",
+    "min_price": "Preço Mínimo",
+    "max_price": "Preço Máximo",
+    "max_price_validation": "Preço máximo deve ser maior ou igual ao mínimo",
+    "offers_meal": "Oferece Refeição",
+    "offers_meal_yes": "Ofereço refeição",
+    "offers_meal_no": "Não ofereço refeição",
+    "price_note": "Faixa de preço referente a 8h por dia",
+    "select_time": "Selecione o horário",
+    "types": {
+      "residencial": "Residencial",
+      "comercial": "Comercial"
+    }
+  },
   "orders": {
     "singular": "Pedido",
     "plural": "Pedidos",

+ 160 - 0
src/pages/opportunity/OpportunitiesPanel.vue

@@ -0,0 +1,160 @@
+<template>
+  <DefaultTable
+    ref="tableRef"
+    :columns="columns"
+    :loading="loading"
+    :api-call="getCustomSchedules"
+    :add-button-label="$t('opportunities.add')"
+    :empty-message="$t('opportunities.empty_state')"
+    :delete-function="deleteCustomSchedule"
+    :mostrar-selecao-de-colunas="false"
+    :mostrar-botao-fullscreen="false"
+    :mostrar-toggle-inativos="false"
+    :open-item="true"
+    @on-row-click="onRowClick"
+    @on-add-item="onAddItem"
+  >
+    <template #body-cell-status="template_props">
+      <q-td :props="template_props">
+        <q-chip
+          :label="$t(`schedules.statuses.${template_props.row.schedule?.status}`)"
+          :color="getStatusColor(template_props.row.schedule?.status)"
+          text-color="white"
+          size="sm"
+        />
+      </q-td>
+    </template>
+
+    <template #body-cell-price_range="template_props">
+      <q-td :props="template_props">
+        {{ formatCurrency(template_props.row.min_price) }}
+        {{ formatCurrency(template_props.row.max_price) }}
+      </q-td>
+    </template>
+
+    <template #body-cell-period="template_props">
+      <q-td :props="template_props">
+        {{ template_props.row.schedule?.period_type }}{{ $t('schedules.hours') }}
+      </q-td>
+    </template>
+  </DefaultTable>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { useQuasar } from 'quasar'
+import { getCustomSchedules, deleteCustomSchedule } from 'src/api/customSchedule'
+import DefaultTable from 'src/components/defaults/DefaultTable.vue'
+import AddEditCustomScheduleDialog from './components/AddEditCustomScheduleDialog.vue'
+
+const { t } = useI18n()
+const $q = useQuasar()
+const tableRef = ref(null)
+const loading = ref(false)
+
+const columns = computed(() => [
+  {
+    name: 'id',
+    label: 'ID',
+    align: 'left',
+    field: 'id',
+    sortable: true
+  },
+  {
+    name: 'client_name',
+    label: t('opportunities.client'),
+    align: 'left',
+    field: row => row.schedule?.client_name,
+    sortable: true
+  },
+  {
+    name: 'service_type',
+    label: t('opportunities.service_type'),
+    align: 'left',
+    field: 'service_type_name',
+    sortable: true
+  },
+  {
+    name: 'date',
+    label: t('opportunities.date'),
+    align: 'left',
+    field: row => row.schedule?.date,
+    sortable: true,
+  },
+  {
+    name: 'period',
+    label: t('opportunities.period'),
+    align: 'center',
+    field: row => row.schedule?.period_type,
+    sortable: true
+  },
+  {
+    name: 'price_range',
+    label: t('opportunities.price_range'),
+    align: 'right',
+    field: row => `${row.min_price} - ${row.max_price}`,
+    sortable: false
+  },
+  {
+    name: 'status',
+    label: t('common.terms.status'),
+    align: 'center',
+    field: row => row.schedule?.status,
+    sortable: true
+  },
+  {
+    name: 'actions',
+    label: t('common.terms.actions'),
+    align: 'center',
+    field: 'actions'
+  }
+])
+
+const getStatusColor = (status) => {
+  const colors = {
+    pending: 'orange',
+    accepted: 'blue',
+    rejected: 'red',
+    paid: 'green',
+    cancelled: 'grey',
+    started: 'purple',
+    finished: 'teal'
+  }
+  return colors[status] || 'grey'
+}
+
+const formatCurrency = (value) => {
+  return new Intl.NumberFormat('pt-BR', {
+    style: 'currency',
+    currency: 'BRL'
+  }).format(value)
+}
+
+const onAddItem = () => {
+  $q.dialog({
+    component: AddEditCustomScheduleDialog,
+    componentProps: {
+      title: () => t('opportunities.add')
+    }
+  }).onOk((success) => {
+    if (success) {
+      tableRef.value.refresh()
+    }
+  })
+}
+
+const onRowClick = ({ row }) => {
+  $q.dialog({
+    component: AddEditCustomScheduleDialog,
+    componentProps: {
+      opportunity: row,
+      title: () => t('opportunities.edit')
+    }
+  }).onOk((success) => {
+    if (success) {
+      tableRef.value.refresh()
+    }
+  })
+}
+</script>

+ 427 - 0
src/pages/opportunity/components/AddEditCustomScheduleDialog.vue

@@ -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>

+ 22 - 0
src/router/routes/opportunities.route.js

@@ -0,0 +1,22 @@
+export default [
+  {
+    path: 'opportunities',
+    name: 'OpportunitiesPage',
+    component: () => import('src/pages/opportunity/OpportunitiesPanel.vue'),
+    meta: {
+      title: 'opportunities.title',
+      requireAuth: true,
+      requiredPermission: 'config.custom_schedule',
+      breadcrumbs: [
+        {
+          name: 'DashboardPage',
+          title: 'ui.navigation.dashboard'
+        },
+        {
+          name: 'OpportunitiesPage',
+          title: 'opportunities.title'
+        }
+      ]
+    }
+  }
+]

+ 9 - 0
src/stores/navigation.js

@@ -22,6 +22,15 @@ export const navigationStore = defineStore("navigation", () => {
       permission: false,
       permissionScope: "config.schedule",
     },
+    {
+      type: "single",
+      title: "ui.navigation.opportunities",
+      name: "OpportunitiesPage",
+      icon: "mdi-bullseye-arrow",
+      disable: false,
+      permission: false,
+      permissionScope: "config.custom_schedule",
+    },
     {
       type: "expansive",
       title: "ui.navigation.registration",