Browse Source

feat: :sparkles: feat (agendamentos) criando regras de negocio para contratacao

foram aplicadas as regras de negocio de contratacao condicional, obedecendo as regras de negocio de disponibilidade, alem de melhoria no fluxo de simulacao de pagamento para redirecionar à dashboard após confirmacao

fase:dev | origin:escopo
Gustavo Zanatta 2 tuần trước cách đây
mục cha
commit
d864c8dd3f

+ 0 - 1
src/components/dashboard/ScheduleAcceptedDialog.vue

@@ -95,7 +95,6 @@ const serviceFee = computed(() => Number(props.schedule.total_amount) * SERVICE_
 const total = computed(() => Number(props.schedule.total_amount) + serviceFee.value)
 
 const onGoToPayment = () => {
-  onDialogHide()
   $q.dialog({
     component: SchedulePaymentDialog,
     componentProps: {

+ 2 - 2
src/components/dashboard/SchedulePaymentDialog.vue

@@ -1,5 +1,5 @@
 <template>
-  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-up" transition-hide="slide-down">
+  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-up" transition-hide="slide-down" @hide="onDialogHide">
     <div class="bg-page full-height column">
 
       <div class="row items-center q-px-md q-pt-md q-pb-sm bg-surface shadow-header">
@@ -120,7 +120,7 @@ const props = defineProps({
 
 defineEmits([...useDialogPluginComponent.emits])
 
-const { dialogRef, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
 const $q = useQuasar()
 const { t } = useI18n()
 const store = userStore()

+ 67 - 7
src/components/dashboard/SchedulePaymentPixDialog.vue

@@ -1,5 +1,5 @@
 <template>
-  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-up" transition-hide="slide-down">
+  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-up" transition-hide="slide-down" @hide="onDialogHide">
     <div class="bg-page full-height column">
 
       <div class="row items-center q-px-md q-pt-md q-pb-sm bg-surface shadow-header">
@@ -12,7 +12,29 @@
         <div style="width: 32px" />
       </div>
 
-      <div class="col scroll q-px-lg q-pt-lg q-pb-xl column">
+      <div v-if="success" class="col column items-center justify-center q-px-xl">
+        <q-btn
+          flat round icon="mdi-close" color="grey-5"
+          class="self-end q-mb-md"
+          @click="onDialogOK"
+        />
+        <div class="success-icon-wrapper q-mb-lg">
+          <q-icon name="mdi-check-circle" size="100px" color="primary" />
+        </div>
+        <div class="success-title text-primary text-weight-bold text-center q-mb-sm">
+          {{ $t('payment.success_title') }}
+        </div>
+        <i18n-t keypath="payment.success_message" tag="div" class="success-message text-grey-6 text-center">
+          <template #nextServices>
+            <strong class="text-text">{{ $t('payment.success_next_services') }}</strong>
+          </template>
+          <template #agenda>
+            <strong class="text-text">{{ $t('payment.success_agenda') }}</strong>
+          </template>
+        </i18n-t>
+      </div>
+
+      <div v-else class="col scroll q-px-lg q-pt-lg q-pb-xl column">
 
         <div class="row items-center justify-between q-mb-sm">
           <span class="pix-label">{{ $t('payment.pix_total') }}</span>
@@ -106,6 +128,7 @@
           color="primary"
           class="full-width copy-btn q-mb-lg"
           :label="$t('payment.pix_copy_btn')"
+          :loading="processing"
           @click="copyCode"
         />
 
@@ -125,6 +148,7 @@
 import { ref, onMounted, onUnmounted } from 'vue'
 import { useDialogPluginComponent, useQuasar, copyToClipboard } from 'quasar'
 import { formatCurrency } from 'src/helpers/utils'
+import { updateScheduleStatus } from 'src/api/schedule'
 
 const props = defineProps({
   schedule: { type: Object, required: true },
@@ -133,15 +157,33 @@ const props = defineProps({
 
 defineEmits([...useDialogPluginComponent.emits])
 
-const { dialogRef, onDialogCancel } = useDialogPluginComponent()
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
 const $q = useQuasar()
 
 const pixCode = `00020126580014br.gov.bcb.pix0136sfp-diaria-${props.schedule.id}-${props.schedule.provider_id}5204000053039865406${(props.total * 100).toFixed(0).padStart(8, '0')}5802BR5913Diaria App6009SAO PAULO62070503***6304ABCD`
 
-const copyCode = () => {
-  copyToClipboard(pixCode)
-    .then(() => $q.notify({ type: 'positive', message: 'Código copiado!' }))
-    .catch(() => $q.notify({ type: 'negative', message: 'Erro ao copiar.' }))
+const success = ref(false)
+const processing = ref(false)
+
+const copyCode = async () => {
+  try {
+    await copyToClipboard(pixCode)
+    $q.notify({ type: 'positive', message: 'Código copiado!' })
+  } catch {
+    $q.notify({ type: 'negative', message: 'Erro ao copiar.' })
+    return
+  }
+
+  processing.value = true
+  try {
+    await updateScheduleStatus(props.schedule.id, 'paid')
+  } catch (e) {
+    console.error('Erro ao atualizar status:', e)
+  } finally {
+    processing.value = false
+  }
+  success.value = true
+  setTimeout(() => onDialogOK(), 3000)
 }
 
 const totalSeconds = ref(20 * 60)
@@ -210,4 +252,22 @@ onUnmounted(() => {
   line-height: 1.5;
   color: #3a3a4a;
 }
+
+.success-icon-wrapper {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.success-title {
+  font-size: 24px;
+  line-height: 1.3;
+  max-width: 280px;
+}
+
+.success-message {
+  font-size: 14px;
+  line-height: 1.6;
+  max-width: 280px;
+}
 </style>

+ 3 - 3
src/components/dashboard/SchedulePaymentProcessingDialog.vue

@@ -1,5 +1,5 @@
 <template>
-  <q-dialog ref="dialogRef" persistent maximized transition-show="fade" transition-hide="fade">
+  <q-dialog ref="dialogRef" persistent maximized transition-show="fade" transition-hide="fade" @hide="onDialogHide">
     <div class="bg-surface full-height column items-center justify-center q-px-xl">
 
       <template v-if="!success">
@@ -19,7 +19,7 @@
           icon="mdi-close"
           color="grey-5"
           class="self-end q-mb-md"
-          @click="onDialogHide"
+          @click="onDialogOK"
         />
         <img
           src="/logo_diaria_branco.svg"
@@ -58,7 +58,7 @@ const props = defineProps({
 
 defineEmits([...useDialogPluginComponent.emits])
 
-const { dialogRef, onDialogHide } = useDialogPluginComponent()
+const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent()
 
 const success = ref(false)
 

+ 85 - 1
src/components/defaults/DefaultInputDatePicker.vue

@@ -18,7 +18,7 @@
         >
           <q-popup-proxy cover transition-show="scale" transition-hide="scale">
             <template v-if="!time">
-              <q-date v-model="date" mask="YYYY-MM-DD" color="primary" class="bg-surface text-text">
+              <q-date v-model="date" mask="YYYY-MM-DD" color="primary" class="bg-surface text-text calendar-custom">
                 <div class="row items-center justify-end">
                   <q-btn v-close-popup label="Close" color="primary" flat />
                 </div>
@@ -160,3 +160,87 @@ watch(
   { immediate: true },
 );
 </script>
+
+<style scoped lang="scss">
+
+.calendar-custom {
+  border-radius: 20px;
+  background-color: white !important;
+
+  :deep(.q-date__main) {
+    background-color: white !important;
+  }
+
+  :deep(.q-date__content) {
+    background-color: white !important;
+  }
+
+  :deep(.q-date__calendar) {
+    background-color: white !important;
+  }
+
+  :deep(.q-date__calendar-item--out) {
+    .q-btn__content {
+      color: #CBD5E1 !important;
+    }
+  }
+
+  :deep(.q-date__calendar-days .q-btn__content) {
+    font-family: 'Inter', sans-serif;
+    font-weight: 500;
+    color: #1E293B;
+  }
+
+  :deep(.q-date__calendar-weekdays > div) {
+    color: #6366F1;
+    font-weight: 700;
+    opacity: 0.8;
+  }
+
+  :deep(.q-date__navigation) {
+    .q-btn {
+      color: #1E293B !important;
+    }
+    .q-btn__content {
+      color: #1E293B !important;
+    }
+  }
+
+  :deep(.q-date__nav-btn-month),
+  :deep(.q-date__nav-btn-year) {
+    color: #6366F1 !important;
+    font-weight: 700;
+  }
+
+  :deep(.q-date__event) {
+    bottom: 4px;
+    height: 6px;
+    width: 6px;
+    border-radius: 50%;
+  }
+
+  :deep(.q-date__today) {
+    .q-btn__content {
+      color: #7c4dff !important;
+      background: #7c4dff15;
+      border-radius: 50%;
+    }
+  }
+
+  :deep(.q-date__selected) {
+    .q-btn__content {
+      background: #6366F1 !important;
+      color: white !important;
+      border-radius: 50%;
+      box-shadow: 0 4px 10px rgba(99, 102, 241, 0.4);
+    }
+  }
+
+  :deep(.q-date__view--months),
+  :deep(.q-date__view--years) {
+    .q-btn {
+      color: #1E293B !important;
+    }
+  }
+}
+</style>

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

@@ -606,6 +606,7 @@
     "no_reviews": "No reviews found.",
     "unknown_client": "Client",
     "select_service": "Select service",
+    "no_slots_available": "No time slots available for this day.",
     "book": "schedule",
     "no_price": "to be agreed",
     "time_selection": {

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

@@ -606,6 +606,7 @@
     "no_reviews": "No se encontraron reseñas.",
     "unknown_client": "Cliente",
     "select_service": "Seleccionar servicio",
+    "no_slots_available": "No hay horarios disponibles para este día.",
     "book": "agendar",
     "no_price": "a convenir",
     "time_selection": {

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

@@ -606,6 +606,7 @@
     "no_reviews": "Nenhuma avaliação encontrada.",
     "unknown_client": "Cliente",
     "select_service": "Selecionar serviço",
+    "no_slots_available": "Não há horários disponíveis para este dia.",
     "book": "agendar",
     "no_price": "a combinar",
     "time_selection": {

+ 31 - 3
src/pages/search/components/OrderSummaryDialog.vue

@@ -144,7 +144,7 @@ const availableWeekDays = computed(() =>
 );
 
 const blockedDateSet = computed(() =>
-  new Set(blockedDays.value.map(bd => bd.date))
+  new Set(blockedDays.value.filter(bd => bd.period === 'all').map(bd => bd.date))
 );
 
 const dateOptions = (d) => {
@@ -196,13 +196,41 @@ const normalizeDate = (d) => d.replace(/\//g, '-');
 const onAddDateSelected = (val) => {
   if (!val) return;
   addDateValue.value = null;
+  const valFormatted = val.replace(/\//g, '-');
+
+  const blocksOfDate = blockedDays.value.filter(
+    bd => bd.date === valFormatted && bd.period !== 'all'
+  );
+
+  const dayOfWeek = new Date(`${valFormatted}T12:00:00`).getDay();
+  const dayPeriods = workingDays.value
+    .filter(wd => wd.day === dayOfWeek)
+    .map(wd => wd.period);
+
+  const workingDayBlocks = [];
+  if (!dayPeriods.includes('afternoon')) {
+    workingDayBlocks.push({ init_hour: '14:00:00', end_hour: '20:00:00' });
+  }
+  if (!dayPeriods.includes('morning')) {
+    workingDayBlocks.push({ init_hour: '7:00:00', end_hour: '13:00:00' });
+  }
+
+  const existingBookingBlocks = bookings.value
+    .filter(b => b.date.replace(/\//g, '-') === valFormatted)
+    .map(b => ({
+      init_hour: `${b.slot.startHour}:00:00`,
+      end_hour:  `${b.slot.endHour}:00:00`,
+    }));
+
+  const partialBlocks = [...blocksOfDate, ...workingDayBlocks, ...existingBookingBlocks];
+
   $q.dialog({
     component: ServiceSelectionSheet,
-    componentProps: { provider: props.provider, selectedDate: val },
+    componentProps: { provider: props.provider, selectedDate: val, partialBlocks },
   }).onOk(({ serviceType, date: date_, provider: prov }) => {
     $q.dialog({
       component: ServiceTimeSelectionDialog,
-      componentProps: { serviceType, selectedDate: date_, provider: prov },
+      componentProps: { serviceType, selectedDate: date_, provider: prov, partialBlocks },
     }).onOk((booking) => {
       if (wouldExceedWeekLimit(booking.date)) {
         $q.notify({ type: 'negative', message: t('scheduling_page.order_summary.week_limit_error') });

+ 24 - 3
src/pages/search/components/SchedulingDialog.vue

@@ -149,13 +149,34 @@ const $q = useQuasar();
 const onDateSelected = (val) => {
   if (!val) return;
   selectedDate.value = null;
+  const valFormatted = val.replace(/\//g, '-');
+
+  const blocksOfDate = blockedDays.value.filter(
+    (bd) => bd.date === valFormatted && bd.period !== 'all'
+  );
+
+  const dayOfWeek = new Date(`${valFormatted}T12:00:00`).getDay();
+  const dayPeriods = workingDays.value
+    .filter((wd) => wd.day === dayOfWeek)
+    .map((wd) => wd.period);
+
+  const workingDayBlocks = [];
+  if (!dayPeriods.includes('afternoon')) {
+    workingDayBlocks.push({ init_hour: '14:00:00', end_hour: '20:00:00' });
+  }
+  if (!dayPeriods.includes('morning')) {
+    workingDayBlocks.push({ init_hour: '7:00:00', end_hour: '13:00:00' });
+  }
+
+  const partialBlocks = [...blocksOfDate, ...workingDayBlocks];
+
   $q.dialog({
     component: ServiceSelectionSheet,
-    componentProps: { provider: props.provider, selectedDate: val },
+    componentProps: { provider: props.provider, selectedDate: val, partialBlocks },
   }).onOk(({ serviceType, date: date_, provider: prov }) => {
     $q.dialog({
       component: ServiceTimeSelectionDialog,
-      componentProps: { serviceType, selectedDate: date_, provider: prov },
+      componentProps: { serviceType, selectedDate: date_, provider: prov, partialBlocks },
     }).onOk((booking) => {
       $q.dialog({
         component: OrderSummaryDialog,
@@ -195,7 +216,7 @@ const availableWeekDays = computed(() =>
 );
 
 const blockedDateSet = computed(() =>
-  new Set(blockedDays.value.map((bd) => bd.date))
+  new Set(blockedDays.value.map((bd) => bd.date && bd.period == 'all'))
 );
 
 const dateOptions = (d) => {

+ 0 - 1
src/pages/search/components/SearchFilterDialog.vue

@@ -43,7 +43,6 @@
           class="date-picker-primary"
           input-class="text-text"
         />
-
       </q-card-section>
 
       <q-separator />

+ 56 - 35
src/pages/search/components/ServiceSelectionSheet.vue

@@ -11,8 +11,12 @@
       <q-separator class="q-mt-sm" />
 
       <q-card-section class="q-pt-sm q-pb-md">
+        <div v-if="availableServiceTypes.length === 0" class="text-center text-grey-6 text-body2 q-py-md">
+          {{ $t('scheduling_page.no_slots_available') }}
+        </div>
+
         <div
-          v-for="type in serviceTypes"
+          v-for="type in availableServiceTypes"
           :key="type.key"
           class="row items-center no-wrap q-py-sm"
         >
@@ -61,6 +65,7 @@ import ServiceTypeInfoDialog from './ServiceTypeInfoDialog.vue';
 const props = defineProps({
   provider: { type: Object, required: true },
   selectedDate: { type: String, required: true },
+  partialBlocks: { type: Array, required: false, default: () => [] },
 });
 
 defineEmits([...useDialogPluginComponent.emits]);
@@ -69,40 +74,56 @@ const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginC
 const $q = useQuasar();
 const { t } = useI18n();
 
-const serviceTypes = computed(() => [
-  {
-    key: 'integral',
-    hoursCount: 8,
-    label: t('scheduling_page.service_types.integral.label'),
-    hours: t('scheduling_page.service_types.integral.hours'),
-    description: t('scheduling_page.service_types.integral.description'),
-    price: props.provider?.daily_price_8h ?? null,
-  },
-  {
-    key: 'padrao',
-    hoursCount: 6,
-    label: t('scheduling_page.service_types.padrao.label'),
-    hours: t('scheduling_page.service_types.padrao.hours'),
-    description: t('scheduling_page.service_types.padrao.description'),
-    price: props.provider?.daily_price_6h ?? null,
-  },
-  {
-    key: 'meio_periodo',
-    hoursCount: 4,
-    label: t('scheduling_page.service_types.meio_periodo.label'),
-    hours: t('scheduling_page.service_types.meio_periodo.hours'),
-    description: t('scheduling_page.service_types.meio_periodo.description'),
-    price: props.provider?.daily_price_4h ?? null,
-  },
-  {
-    key: 'diaria_rapida',
-    hoursCount: 2,
-    label: t('scheduling_page.service_types.diaria_rapida.label'),
-    hours: t('scheduling_page.service_types.diaria_rapida.hours'),
-    description: t('scheduling_page.service_types.diaria_rapida.description'),
-    price: props.provider?.daily_price_2h ?? null,
-  },
-]);
+const slotConflicts = (slotStart, slotEnd, blocks) =>
+  blocks.some(b => {
+    const blockStart = parseInt(b.init_hour);
+    const blockEnd   = parseInt(b.end_hour);
+    return slotEnd >= blockStart && slotStart <= blockEnd;
+  });
+
+const hasValidSlots = (hoursCount) => {
+  for (let s = 7; s + hoursCount <= 20; s++) {
+    if (!slotConflicts(s, s + hoursCount, props.partialBlocks)) return true;
+  }
+  return false;
+};
+
+const availableServiceTypes = computed(() =>
+  [
+    {
+      key: 'integral',
+      hoursCount: 8,
+      label: t('scheduling_page.service_types.integral.label'),
+      hours: t('scheduling_page.service_types.integral.hours'),
+      description: t('scheduling_page.service_types.integral.description'),
+      price: props.provider?.daily_price_8h ?? null,
+    },
+    {
+      key: 'padrao',
+      hoursCount: 6,
+      label: t('scheduling_page.service_types.padrao.label'),
+      hours: t('scheduling_page.service_types.padrao.hours'),
+      description: t('scheduling_page.service_types.padrao.description'),
+      price: props.provider?.daily_price_6h ?? null,
+    },
+    {
+      key: 'meio_periodo',
+      hoursCount: 4,
+      label: t('scheduling_page.service_types.meio_periodo.label'),
+      hours: t('scheduling_page.service_types.meio_periodo.hours'),
+      description: t('scheduling_page.service_types.meio_periodo.description'),
+      price: props.provider?.daily_price_4h ?? null,
+    },
+    {
+      key: 'diaria_rapida',
+      hoursCount: 2,
+      label: t('scheduling_page.service_types.diaria_rapida.label'),
+      hours: t('scheduling_page.service_types.diaria_rapida.hours'),
+      description: t('scheduling_page.service_types.diaria_rapida.description'),
+      price: props.provider?.daily_price_2h ?? null,
+    },
+  ].filter(type => hasValidSlots(type.hoursCount))
+);
 
 const formatPrice = (value) =>
   Number(value).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });

+ 12 - 3
src/pages/search/components/ServiceTimeSelectionDialog.vue

@@ -93,9 +93,10 @@ import { useDialogPluginComponent } from 'quasar';
 import { useI18n } from 'vue-i18n';
 
 const props = defineProps({
-  serviceType: { type: Object, required: true },
-  provider:    { type: Object, required: true },
-  selectedDate:{ type: String, required: true },
+  serviceType:   { type: Object, required: true },
+  provider:      { type: Object, required: true },
+  selectedDate:  { type: String, required: true },
+  partialBlocks: { type: Array,  required: false, default: () => [] },
 });
 
 defineEmits([...useDialogPluginComponent.emits]);
@@ -116,11 +117,19 @@ const handleContinue = () => {
   });
 };
 
+const slotConflicts = (slotStart, slotEnd, blocks) =>
+  blocks.some(b => {
+    const blockStart = parseInt(b.init_hour);
+    const blockEnd   = parseInt(b.end_hour);
+    return slotEnd >= blockStart && slotStart <= blockEnd;
+  });
+
 const timeSlots = computed(() => {
   const h = props.serviceType.hoursCount;
   const slots = [];
   for (let start = 7; start + h <= 20; start++) {
     const end = start + h;
+    if (slotConflicts(start, end, props.partialBlocks)) continue;
     slots.push({
       value: `${start}-${end}`,
       startHour: start,