浏览代码

Merge branch 'feature/diariaapp-gus-agendamentos-apps' of Softpar/sfp_front_vue_diarista_prestador into development

aprovado
zntt 2 周之前
父节点
当前提交
c1500b71f4

+ 16 - 0
src/api/schedule.js

@@ -0,0 +1,16 @@
+import api from 'src/api'
+
+export const updateScheduleStatus = async (id, status) => {
+  const { data } = await api.patch(`/schedule/${id}/status`, { status })
+  return data.payload
+}
+
+export const cancelSchedule = async (id, cancelText) => {
+  const { data } = await api.patch(`/schedule/${id}/cancel`, { cancel_text: cancelText })
+  return data.payload
+}
+
+export const verifyScheduleCode = async (scheduleId, code) => {
+  const response = await api.post(`/custom-schedule-verify-code/${scheduleId}`, { code })
+  return response.data
+}

+ 2 - 0
src/components/dashboard/DashboardNextSchedules.vue

@@ -56,6 +56,7 @@
                 size="sm"
                 class="col-auto q-px-none btn-details"
                 :label="$t('provider.dashboard.next_schedules.details')"
+                @click="emit('view-details', item)"
               />
               <q-space />
               <div class="row items-center q-gutter-x-xs text-grey-7" style="font-size: 11px;">
@@ -83,6 +84,7 @@ import { labelsPeriodTypes } from 'src/helpers/arraysOptions/labelsPeriodTypes.j
 import { useQuasar } from 'quasar';
 import { formatAddress } from 'src/helpers/utils';
 defineProps({ data: { type: Array, default: () => [] } });
+const emit = defineEmits(['view-details']);
 
 const t = useI18n().t;
 const $q = useQuasar();

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

@@ -53,7 +53,7 @@ defineProps({
 }
 .text-suggest-label {
   font-family: "Inter", sans-serif;
-  font-size: 13px;
+  font-size: 11px;
   color: #3a3a4a;
 }
 .price-badge {
@@ -64,11 +64,11 @@ defineProps({
 }
 .text-my-price {
   font-family: "Inter", sans-serif;
-  font-size: 13px;
+  font-size: 11px;
   font-weight: 600;
   color: #3a3a4a;
 }
 .btn-alter {
-  font-size: 13px;
+  font-size: 11px;
 }
 </style>

+ 17 - 11
src/components/dashboard/DashboardSolicitations.vue

@@ -34,7 +34,7 @@
                   {{ $t('common.from') }}
                   <span class="text-schedule-date-bold">
                     {{ item.start_time?.slice(0, 5) }}
-                    
+
                     {{ $t('common.to') }}
 
                     {{ item.end_time?.slice(0, 5) }}
@@ -61,6 +61,7 @@
                   size="sm"
                   class="col-auto q-px-none btn-details"
                   :label="$t('common.details')"
+                  @click="emit('view-details', item)"
                 />
                 <div class="text-schedule-date-bold text-text text-weight-bold">
                   {{ item.time_since_request }}
@@ -74,6 +75,7 @@
                 class="col-auto bg-grey-3 text-grey-8 btn-action"
                 size="sm"
                 :label="$t('common.refuse')"
+                @click="emit('reject', item)"
               />
               <q-btn
                 unelevated
@@ -83,6 +85,7 @@
                 class="col-auto btn-action"
                 size="sm"
                 :label="$t('common.accept')"
+                @click="emit('accept', item)"
               />
             </div>
           </q-card-section>
@@ -104,25 +107,28 @@ defineProps({
   }
 });
 
+const emit = defineEmits(['accept', 'reject', 'view-details']);
+
 const t = useI18n().t;
 
+const parseLocalDate = (iso) => {
+  if (!iso) return null;
+  const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
+  return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null;
+};
+
 const formatWeekday = (iso) => {
-  if (!iso) return '';
-  const d = new Date(iso);
+  const d = parseLocalDate(iso);
+  if (!d) return '';
   const w = d.toLocaleDateString('pt-BR', { weekday: 'long' });
   return w.charAt(0).toUpperCase() + w.slice(1);
 };
 
 const formatDayMonth = (iso) => {
-  if (!iso) return '';
-  return new Date(iso).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
+  const d = parseLocalDate(iso);
+  if (!d) return '';
+  return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
 };
-
-// const openTimeSynceCreatedSolicitation = (id) => {
-//   // Implementar lógica para abrir detalhes da solicitação
-  
-// };
-
 </script>
 
 <style scoped lang="scss">

+ 243 - 0
src/components/dashboard/DashboardTodayServices.vue

@@ -0,0 +1,243 @@
+<template>
+  <div v-if="props.data.length > 0" class="q-mx-md q-mb-md">
+    <q-card
+      v-for="item in props.data"
+      :key="item.id"
+      class="today-card card-border shadow-card bg-surface q-mb-sm"
+      :flat="false"
+    >
+      <q-card-section class="q-pa-sm">
+
+        <div class="row no-wrap items-center q-mb-sm">
+          <q-avatar size="40px" class="q-mr-sm">
+            <img :src="item.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'" />
+          </q-avatar>
+          <div class="col column">
+            <span class="text-body2 text-text">
+              {{ $t('provider.dashboard.today_services.start_label') }}
+              <span class="text-weight-bold">{{ item.client_name }}</span>
+            </span>
+            <div class="row items-center q-gutter-x-xs q-mt-xs">
+              <q-icon name="mdi-clock-outline" color="grey-5" size="14px" />
+              <span class="text-caption text-grey-6">
+                {{ $t('common.from') }}
+                <strong class="text-text">{{ item.start_time?.slice(0, 5) }}</strong>
+                {{ $t('common.to') }}
+                <strong class="text-text">{{ item.end_time?.slice(0, 5) }}</strong>
+              </span>
+            </div>
+          </div>
+          <div class="col-auto text-caption text-grey-5 text-right q-pl-xs hint-text">
+            {{ $t('provider.dashboard.today_services.code_hint') }}
+          </div>
+        </div>
+
+        <div
+          class="code-container row justify-center q-gutter-x-sm q-mb-sm"
+          :class="{ 'code-disabled': item.code_verified || !canEnterCode(item) }"
+          @click="focusInput(item.id)"
+        >
+          <div
+            v-for="i in 4"
+            :key="i"
+            class="code-box"
+            :class="{
+              'code-box--filled': (codes[item.id] || '').length >= i,
+              'code-box--verified': item.code_verified
+            }"
+          >
+            <template v-if="item.code_verified">
+              <q-icon v-if="i === 2" name="mdi-check-circle" color="positive" size="18px" />
+              <span v-else></span>
+            </template>
+            <span v-else>{{ (codes[item.id] || '')[i - 1] || '' }}</span>
+          </div>
+          <input
+            :id="`code-input-${item.id}`"
+            v-model="codes[item.id]"
+            type="tel"
+            inputmode="numeric"
+            maxlength="4"
+            class="code-real-input"
+            :disabled="item.code_verified || !canEnterCode(item)"
+            @input="onCodeInput(item)"
+          />
+        </div>
+
+        <q-linear-progress
+          :value="progressValue(item.status)"
+          color="secondary"
+          track-color="grey-3"
+          rounded
+          size="5px"
+          class="q-mb-sm"
+        />
+
+        <div class="row items-center">
+          <q-btn
+            flat
+            no-caps
+            color="primary"
+            size="sm"
+            class="q-px-none btn-help"
+            :label="$t('provider.dashboard.today_services.help')"
+            @click="openHelp"
+          />
+          <q-space />
+          <div class="row items-center no-wrap q-gutter-x-xs">
+            <q-icon name="mdi-map-marker-outline" color="grey-5" size="14px" />
+            <span class="text-caption text-grey-7 ellipsis address-text">
+              {{ formatAddressShort(item.address) }}
+            </span>
+            <q-btn
+              flat
+              round
+              dense
+              icon="mdi-content-copy"
+              color="primary"
+              size="xs"
+              @click.stop="copyAddress(item.address)"
+            />
+          </div>
+        </div>
+
+      </q-card-section>
+    </q-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, nextTick } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { useQuasar } from 'quasar'
+import ProfileHelpDialog from 'src/components/profile/ProfileHelpDialog.vue'
+import { verifyScheduleCode } from 'src/api/schedule'
+
+const props = defineProps({
+  data: {
+    type: Array,
+    default: () => []
+  }
+})
+
+const emit = defineEmits(['refresh'])
+
+const { t } = useI18n()
+const $q = useQuasar()
+
+const codes = ref({})
+const loadingCode = ref({})
+
+const progressValue = (status) => {
+  const map = { accepted: 0.4, paid: 0.6, started: 0.8, finished: 1.0 }
+  return map[status] ?? 0.4
+}
+
+const canEnterCode = (item) => ['paid', 'started'].includes(item.status)
+
+const focusInput = (id) => {
+  nextTick(() => document.getElementById(`code-input-${id}`)?.focus())
+}
+
+const onCodeInput = async (item) => {
+  const val = codes.value[item.id] || ''
+  if (val.length < 4 || item.code_verified || !canEnterCode(item)) return
+  loadingCode.value[item.id] = true
+  try {
+    const response = await verifyScheduleCode(item.id, val)
+    if (response?.data?.success || response?.success) {
+      $q.notify({ type: 'positive', message: t('provider.dashboard.today_services.code_success'), position: 'top' })
+      emit('refresh')
+    } else {
+      $q.notify({ type: 'negative', message: t('provider.dashboard.today_services.code_error'), position: 'top' })
+      codes.value[item.id] = ''
+    }
+  } catch {
+    $q.notify({ type: 'negative', message: t('provider.dashboard.today_services.code_error'), position: 'top' })
+    codes.value[item.id] = ''
+  } finally {
+    loadingCode.value[item.id] = false
+  }
+}
+
+const formatAddressShort = (address) => {
+  if (!address) return ''
+  return [address.address, address.number, address.district].filter(Boolean).join(', ')
+}
+
+const copyAddress = (address) => {
+  const text = formatAddressShort(address)
+  if (text) navigator.clipboard.writeText(text)
+  $q.notify({ message: t('provider.dashboard.next_schedules.address_copied'), color: 'positive', position: 'top' })
+}
+
+const openHelp = () => {
+  $q.dialog({ component: ProfileHelpDialog })
+}
+</script>
+
+<style scoped lang="scss">
+.today-card {
+  border-radius: 12px;
+}
+
+.hint-text {
+  max-width: 100px;
+  line-height: 1.3;
+  font-size: 11px;
+}
+
+/* OTP input */
+.code-container {
+  position: relative;
+  cursor: text;
+  user-select: none;
+}
+
+.code-real-input {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  opacity: 0;
+  pointer-events: none;
+  top: 0;
+  left: 0;
+}
+
+.code-box {
+  width: 52px;
+  height: 44px;
+  background: #efefef;
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 22px;
+  font-weight: 700;
+  color: #3a3a4a;
+  transition: background 0.15s;
+}
+
+.code-box--filled {
+  background: #e0d8f8;
+  color: var(--q-secondary);
+}
+
+.code-box--verified {
+  background: #e8f5e9;
+}
+
+.code-disabled .code-box {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.address-text {
+  max-width: 150px;
+}
+
+.btn-help {
+  font-weight: 700;
+  font-size: 13px;
+}
+</style>

+ 138 - 0
src/components/dashboard/ScheduleCancelDialog.vue

@@ -0,0 +1,138 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="cancel-dialog-card bg-surface shadow-card" :flat="false">
+
+      <div class="row justify-end q-pt-sm q-pr-sm">
+        <q-btn flat round dense icon="close" color="grey-6" size="sm" @click="onDialogCancel" />
+      </div>
+
+      <q-card-section class="q-pt-none q-pb-sm q-px-lg text-center">
+        <div class="cancel-title text-secondary text-weight-bold">
+          {{ $t('provider.dashboard.cancel_schedule.title') }}
+        </div>
+      </q-card-section>
+
+      <q-card-section class="q-pt-none q-pb-sm q-px-lg">
+        <div class="text-body2 text-grey-8 text-weight-bold q-mb-xs">
+          {{ $t('provider.dashboard.cancel_schedule.reason_label') }}
+        </div>
+        <q-input
+          v-model="cancelText"
+          type="textarea"
+          outlined
+          dense
+          :placeholder="$t('provider.dashboard.cancel_schedule.reason_placeholder')"
+          rows="4"
+          color="secondary"
+          :rules="[val => (val && val.trim().length >= 5) || ' ']"
+          hide-bottom-space
+        />
+      </q-card-section>
+
+      <q-card-section class="q-pt-xs q-pb-md q-px-lg">
+        <div class="warning-box row no-wrap q-gutter-x-sm q-pa-sm">
+          <q-icon name="mdi-alert-outline" color="secondary" size="22px" class="q-mt-xs flex-shrink-0" />
+          <div>
+            <span class="text-caption text-weight-bold text-grey-9">
+              {{ $t('provider.dashboard.cancel_schedule.warning_title') }}
+            </span>
+            <span class="text-caption text-grey-8">
+              {{ ' ' + $t('provider.dashboard.cancel_schedule.warning_free') }}
+            </span>
+            <br />
+            <span class="text-caption text-grey-8">
+              {{ $t('provider.dashboard.cancel_schedule.warning_fee') }}
+            </span>
+          </div>
+        </div>
+      </q-card-section>
+
+      <q-card-section class="q-pt-none q-pb-lg q-px-lg">
+        <div class="row justify-center q-gutter-x-md">
+          <q-btn
+            unelevated
+            rounded
+            no-caps
+            class="btn-action bg-grey-3 text-grey-8"
+            :label="$t('provider.dashboard.cancel_schedule.btn_cancel')"
+            @click="onDialogCancel"
+          />
+          <q-btn
+            unelevated
+            rounded
+            no-caps
+            color="secondary"
+            class="btn-action"
+            :loading="loading"
+            :disable="!cancelText || cancelText.trim().length < 5"
+            :label="$t('provider.dashboard.cancel_schedule.btn_keep')"
+            @click="confirmCancel"
+          />
+        </div>
+      </q-card-section>
+
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { useI18n } from 'vue-i18n'
+import { cancelSchedule } from 'src/api/schedule'
+
+const props = defineProps({
+  schedule: {
+    type: Object,
+    required: true
+  }
+})
+
+const { t } = useI18n()
+const $q = useQuasar()
+const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } = useDialogPluginComponent()
+
+const cancelText = ref('')
+const loading = ref(false)
+
+const confirmCancel = async () => {
+  if (!cancelText.value || cancelText.value.trim().length < 5) return
+  loading.value = true
+  try {
+    await cancelSchedule(props.schedule.id, cancelText.value.trim())
+    onDialogOK()
+  } catch {
+    $q.notify({ message: t('http.errors.failed'), color: 'negative' })
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.cancel-dialog-card {
+  width: 340px;
+  max-width: 94vw;
+  border-radius: 20px !important;
+  overflow: hidden;
+}
+
+.cancel-title {
+  font-size: 18px;
+  line-height: 1.35;
+}
+
+.warning-box {
+  background: #e8f4fd;
+  border-radius: 10px;
+}
+
+.btn-action {
+  min-width: 110px;
+  font-weight: 700;
+}
+
+.flex-shrink-0 {
+  flex-shrink: 0;
+}
+</style>

+ 453 - 0
src/components/dashboard/SolicitationDetailsDialog.vue

@@ -0,0 +1,453 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="solicitation-dialog-card bg-surface shadow-card" :flat="false">
+
+      <div class="text-text">
+      </div>
+      <template v-if="isPaid">
+        <div class="row justify-end q-pt-sm q-pr-sm">
+          <q-btn flat round dense icon="close" color="grey-6" size="sm" @click="onDialogCancel" />
+        </div>
+
+        <q-card-section class="column items-center q-pt-xs q-pb-xs">
+          <q-avatar size="72px" class="q-mb-sm">
+            <img :src="solicitation.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'" />
+          </q-avatar>
+          <div class="text-subtitle1 text-weight-bold text-text">
+            {{ solicitation.client_name }}
+          </div>
+          <div v-if="solicitation.address?.district" class="text-caption text-grey-6 q-mt-xs">
+            {{ solicitation.address.district }}
+          </div>
+          <div v-if="solicitation.address" class="text-caption text-grey-5 text-center q-mt-xs">
+            {{ formatAddressShort(solicitation.address) }}
+          </div>
+        </q-card-section>
+
+        <q-separator class="q-mx-md q-my-xs" />
+
+        <q-card-section class="q-pt-xs q-pb-xs q-px-lg">
+          <div
+            v-for="service in servicesList"
+            :key="service.label"
+            class="row items-center q-gutter-x-sm q-mb-xs"
+          >
+            <q-icon
+              :name="service.active ? 'mdi-check' : 'mdi-close'"
+              :color="service.active ? 'secondary' : 'grey-4'"
+              size="16px"
+            />
+            <span class="text-body2 text-grey-8">{{ service.label }}</span>
+          </div>
+        </q-card-section>
+
+        <q-separator class="q-mx-md q-my-xs" />
+
+        <q-card-section class="q-pt-xs q-pb-xs q-px-lg">
+          <div class="detail-row">
+            <span class="detail-label text-secondary text-weight-bold">{{ $t('common.terms.date') }}</span>
+            <span class="detail-value text-grey-9">{{ fullDateLabel }}</span>
+          </div>
+          <div class="detail-row">
+            <span class="detail-label text-secondary text-weight-bold">{{ $t('common.terms.hour') }}</span>
+            <span class="detail-value text-grey-9">
+              {{ solicitation.start_time?.slice(0, 5) }} {{ $t('common.to') }} {{ solicitation.end_time?.slice(0, 5) }}
+            </span>
+          </div>
+          <div class="detail-row">
+            <span class="detail-label text-secondary text-weight-bold">{{ $t('common.terms.total_amount') }}</span>
+            <span class="detail-value text-grey-9">{{ formatCurrency(baseAmount) }}</span>
+          </div>
+          <div class="detail-row">
+            <span class="detail-label text-secondary text-weight-bold">{{ $t('provider.dashboard.cancel_schedule.service_fee') }}</span>
+            <span class="detail-value text-grey-9">{{ formatCurrency(serviceFee) }}</span>
+          </div>
+        </q-card-section>
+
+        <q-separator class="q-mx-md q-my-xs" />
+
+        <q-card-section class="q-pt-xs q-pb-sm text-center">
+          <span class="text-weight-bold text-grey-9">{{ $t('provider.dashboard.cancel_schedule.total') }}</span>
+          <span class="total-value text-secondary text-weight-bold q-ml-xs">{{ formatCurrency(solicitation.total_amount) }}</span>
+        </q-card-section>
+
+        <q-card-section class="q-pt-none q-pb-xs q-px-lg">
+          <q-btn
+            unelevated
+            rounded
+            no-caps
+            color="secondary"
+            class="full-width btn-close-paid"
+            :label="$t('provider.dashboard.cancel_schedule.close')"
+            @click="onDialogCancel"
+          />
+        </q-card-section>
+        <q-card-section class="q-pt-xs q-pb-md text-center">
+          <div class="row justify-center q-gutter-x-lg">
+            <q-btn
+              flat
+              no-caps
+              color="grey-7"
+              size="sm"
+              :label="$t('provider.dashboard.cancel_schedule.btn_cancel_order')"
+              @click="openCancelDialog"
+            />
+            <q-btn
+              flat
+              no-caps
+              color="grey-7"
+              size="sm"
+              :label="$t('provider.dashboard.cancel_schedule.help')"
+            />
+          </div>
+        </q-card-section>
+      </template>
+
+      <template v-else-if="view === 'details'">
+        <div class="row justify-end q-pt-sm q-pr-sm">
+          <q-btn flat round dense icon="close" color="grey-6" size="sm" @click="onDialogCancel" />
+        </div>
+
+        <q-card-section class="column items-center q-pt-sm q-pb-sm">
+          <q-avatar size="72px" class="q-mb-sm">
+            <img :src="solicitation.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'" />
+          </q-avatar>
+
+          <div class="text-subtitle1 text-weight-bold text-text">{{ solicitation.client_name }}</div>
+
+          <div class="text-price q-mt-xs">{{ formatCurrency(solicitation.total_amount) }}</div>
+          <div class="text-caption text-grey-6">{{ periodLabel }}</div>
+        </q-card-section>
+
+        <q-card-section class="column items-center q-pt-none q-pb-sm text-text">
+          <div class="text-body2 text-center">
+            <span class="text-weight-bold">{{ weekdayLabel + ' ,' + dayMonthLabel }}</span>
+          </div>
+          <div class="text-body2 text-center">
+            {{ $t('common.from') }} <strong>{{ solicitation.start_time?.slice(0, 5) }}</strong>
+            {{ $t('common.to') }} <strong>{{ solicitation.end_time?.slice(0, 5) }}</strong>
+          </div>
+        </q-card-section>
+
+        <q-card-section class="column items-center q-pt-none q-pb-xs">
+          <div class="row items-center q-gutter-x-xs">
+            <q-icon
+              :name="solicitation.offers_meal ? 'mdi-food' : 'mdi-food-off'"
+              :color="solicitation.offers_meal ? 'positive' : 'grey-5'"
+              size="18px"
+            />
+            <span class="text-body2 text-grey-7">
+              {{ solicitation.offers_meal
+                ? $t('provider.dashboard.solicitations.offers_meal')
+                : $t('provider.dashboard.solicitations.not_offers_meal') }}
+            </span>
+          </div>
+        </q-card-section>
+
+        <q-card-section class="column items-center q-pt-xs q-pb-sm text-text">
+          <div class="row items-center q-gutter-x-xs">
+            <q-icon name="mdi-map-marker" color="secondary" size="20px" />
+            <span class="text-body1 text-weight-bold">{{ solicitation.address?.district || 'N/A' }}</span>
+          </div>
+          <div class="text-caption text-grey-6 text-center q-mt-xs">
+            {{ $t('provider.dashboard.solicitations.distance_prefix') }}
+            <strong>{{ (solicitation.distance || 0) + ' km' }}</strong>
+            {{ $t('provider.dashboard.solicitations.distance_suffix') }}
+          </div>
+          <div class="q-mt-xs">
+            <span class="text-caption text-grey-6">{{ $t('provider.dashboard.solicitations.via_agenda_text') }} </span>
+            <span class="text-caption text-secondary text-weight-bold">{{ ' ' + $t('provider.dashboard.solicitations.via_agenda') }}</span>
+          </div>
+        </q-card-section>
+
+        <q-separator />
+
+        <q-card-section v-if="solicitation?.status != 'accepted'" class="q-py-sm">
+          <div class="row justify-center q-gutter-x-md">
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              class="btn-action bg-grey-3 text-grey-8"
+              :label="$t('common.refuse')"
+              @click="view = 'confirm-reject'"
+            />
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              color="secondary"
+              class="btn-action"
+              :label="$t('common.accept')"
+              @click="view = 'confirm-accept'"
+            />
+          </div>
+          <div class="row justify-center q-mt-xs">
+            <q-btn flat no-caps color="grey-7" size="sm" :label="$t('common.actions.back')" @click="onDialogCancel" />
+          </div>
+        </q-card-section>
+
+        <div class="q-pa-md">
+          <span class="text-caption text-warning text-weight-bold">{{ $t('provider.dashboard.solicitations.same_day_warning_label') }} </span>
+          <span class="text-caption text-grey-8">{{ $t('provider.dashboard.solicitations.same_day_warning') }}</span>
+        </div>
+      </template>
+
+      <template v-else-if="view === 'confirm-accept'">
+        <q-card-section class="column items-center q-pt-xl q-pb-lg text-center">
+          <div class="confirm-title text-secondary text-weight-bold">
+            {{ $t('provider.dashboard.solicitations.confirm_accept') }}
+          </div>
+        </q-card-section>
+
+        <q-card-section class="q-pt-none q-pb-xl">
+          <div class="row justify-center q-gutter-x-md">
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              color="purple-5"
+              class="btn-action"
+              :label="$t('common.actions.back')"
+              @click="backFromConfirm"
+            />
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              color="secondary"
+              class="btn-action"
+              :loading="loading"
+              :label="$t('common.accept')"
+              @click="confirmAccept"
+            />
+          </div>
+        </q-card-section>
+      </template>
+
+      <template v-else-if="view === 'confirm-reject'">
+        <q-card-section class="column items-center q-pt-xl q-pb-md text-center">
+          <div class="confirm-title text-secondary text-weight-bold">
+            {{ $t('provider.dashboard.solicitations.confirm_reject') }}
+          </div>
+          <div class="q-mt-md text-body2 text-grey-7 confirm-tip">
+            <strong>{{ $t('provider.dashboard.solicitations.tip_label') }} </strong>
+            {{ $t('provider.dashboard.solicitations.reject_tip') }}
+          </div>
+        </q-card-section>
+
+        <q-card-section class="q-pt-none q-pb-xl">
+          <div class="row justify-center q-gutter-x-md">
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              color="purple-5"
+              class="btn-action"
+              :label="$t('common.actions.back')"
+              @click="backFromConfirm"
+            />
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              color="secondary"
+              class="btn-action"
+              :loading="loading"
+              :label="$t('common.refuse')"
+              @click="confirmReject"
+            />
+          </div>
+        </q-card-section>
+      </template>
+
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { useI18n } from 'vue-i18n'
+import { formatCurrency } from 'src/helpers/utils'
+import { labelsPeriodTypes } from 'src/helpers/arraysOptions/labelsPeriodTypes.js'
+import ScheduleCancelDialog from './ScheduleCancelDialog.vue'
+
+const props = defineProps({
+  solicitation: {
+    type: Object,
+    required: true
+  },
+  initialView: {
+    type: String,
+    default: 'details',
+    validator: (v) => ['details', 'confirm-accept', 'confirm-reject'].includes(v)
+  }
+})
+
+const { t } = useI18n()
+const $q = useQuasar()
+const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } = useDialogPluginComponent()
+
+const isPaid = computed(() => props.solicitation.status === 'paid')
+
+const view = ref(props.initialView)
+const loading = ref(false)
+
+const periodLabel = computed(() => {
+  const found = labelsPeriodTypes.find(l => l.value == props.solicitation.period_type)
+  return found ? t(found.label) : ''
+})
+
+const parseLocalDate = (dateStr) => {
+  if (!dateStr) return null
+  const s = String(dateStr)
+  const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/)
+  if (iso) return new Date(+iso[1], +iso[2] - 1, +iso[3])
+  const dmy = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/)
+  if (dmy) return new Date(+dmy[3], +dmy[2] - 1, +dmy[1])
+  return null
+}
+
+const weekdayLabel = computed(() => {
+  const d = parseLocalDate(props.solicitation.date)
+  if (!d) return ''
+  const w = d.toLocaleDateString('pt-BR', { weekday: 'long' })
+  return w.charAt(0).toUpperCase() + w.slice(1)
+})
+
+const dayMonthLabel = computed(() => {
+  const d = parseLocalDate(props.solicitation.date)
+  if (!d) return ''
+  return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })
+})
+
+const fullDateLabel = computed(() => {
+  const d = parseLocalDate(props.solicitation.date)
+  if (!d) return ''
+  return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' })
+})
+
+const SERVICE_FEE_RATE = 0.06
+const serviceFee = computed(() => {
+  const total = parseFloat(props.solicitation.total_amount) || 0
+  return parseFloat((total * SERVICE_FEE_RATE).toFixed(2))
+})
+const baseAmount = computed(() => {
+  const total = parseFloat(props.solicitation.total_amount) || 0
+  return parseFloat((total - serviceFee.value).toFixed(2))
+})
+
+const servicesList = computed(() => {
+  const list = []
+  const s = props.solicitation
+  if (s.schedule_type === 'custom' && s.specialities?.length) {
+    s.specialities.forEach(sp => list.push({ label: sp.name, active: true }))
+  }
+  if (s.offers_meal !== undefined) {
+    list.push({
+      label: s.offers_meal
+        ? t('provider.dashboard.solicitations.offers_meal')
+        : t('provider.dashboard.solicitations.not_offers_meal'),
+      active: !!s.offers_meal
+    })
+  }
+  if (!list.length) {
+    list.push({ label: t('provider.dashboard.next_schedules.default'), active: true })
+  }
+  return list
+})
+
+const formatAddressShort = (addr) => {
+  if (!addr) return ''
+  const parts = [addr.address, addr.number].filter(Boolean)
+  const street = parts.join(', ')
+  const suffix = addr.district ? `, ${addr.district}` : ''
+  return street + suffix
+}
+
+const backFromConfirm = () => {
+  if (props.initialView !== 'details') {
+    onDialogCancel()
+  } else {
+    view.value = 'details'
+  }
+}
+
+const confirmAccept = () => {
+  loading.value = true
+  onDialogOK({ action: 'accept', id: props.solicitation.id })
+}
+
+const confirmReject = () => {
+  loading.value = true
+  onDialogOK({ action: 'reject', id: props.solicitation.id })
+}
+
+const openCancelDialog = () => {
+  $q.dialog({
+    component: ScheduleCancelDialog,
+    componentProps: { schedule: props.solicitation }
+  }).onOk(() => {
+    onDialogOK({ action: 'cancelled', id: props.solicitation.id })
+  })
+}
+</script>
+
+<style scoped lang="scss">
+.solicitation-dialog-card {
+  width: 320px;
+  max-width: 92vw;
+  border-radius: 20px !important;
+  overflow: hidden;
+}
+
+.text-price {
+  font-size: 24px;
+  font-weight: 700;
+  color: var(--q-secondary);
+}
+
+.btn-action {
+  min-width: 110px;
+  font-weight: 700;
+}
+
+.btn-close-paid {
+  font-weight: 700;
+  font-size: 16px;
+  padding: 10px 0;
+}
+
+.confirm-title {
+  font-size: 20px;
+  line-height: 1.35;
+  padding: 0 12px;
+}
+
+.confirm-tip {
+  font-size: 13px;
+  line-height: 1.5;
+  padding: 0 8px;
+}
+
+.detail-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  margin-bottom: 4px;
+}
+
+.detail-label {
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.detail-value {
+  font-size: 13px;
+}
+
+.total-value {
+  font-size: 22px;
+}
+</style>

+ 328 - 0
src/components/profile/ProfileAvailabilityCalendar.vue

@@ -0,0 +1,328 @@
+<template>
+  <div class="avail-calendar">
+
+    <div class="avail-cal__nav row items-center justify-between q-px-md q-pt-md q-pb-sm q-gutter-x-sm">
+      <q-btn flat round dense icon="mdi-chevron-left" size="sm" color="text" @click="prevMonth" />
+      <span class="avail-cal__nav-label month-label">{{ monthLabel }}</span>
+      <q-btn flat round dense icon="mdi-chevron-right" size="sm" color="text" @click="nextMonth" />
+
+      <q-btn flat round dense icon="mdi-chevron-left" size="xs" color="text" @click="prevYear" />
+      <span class="avail-cal__nav-label year-label">{{ currentYear }}</span>
+      <q-btn flat round dense icon="mdi-chevron-right" size="xs" color="text" @click="nextYear" />
+    </div>
+
+    <div class="avail-cal__weekdays row q-px-sm q-pb-xs">
+      <div
+        v-for="wd in weekdayLabels"
+        :key="wd"
+        class="avail-cal__weekday col text-center text-weight-bold"
+      >
+        {{ wd }}
+      </div>
+    </div>
+
+    <div class="avail-cal__grid q-px-sm q-pb-md">
+      <div
+        v-for="n in firstDayOffset"
+        :key="`empty-${n}`"
+        class="avail-cal__cell"
+      />
+
+      <div
+        v-for="day in daysInMonth"
+        :key="day"
+        class="avail-cal__cell column items-center justify-start"
+      >
+        <button
+          class="avail-cal__day-btn"
+          :class="{
+            'avail-cal__day--today': isToday(day),
+            'avail-cal__day--past': isPast(day),
+          }"
+          @click="onDayClick(day)"
+        >
+          {{ day }}
+        </button>
+
+        <div v-if="hasAnyBlock(day)" class="avail-cal__pill" @click="onDayClick(day)">
+          <div
+            class="avail-cal__pill-half avail-cal__pill-left"
+            :class="morningState(day)"
+          />
+          <div
+            class="avail-cal__pill-half avail-cal__pill-right"
+            :class="afternoonState(day)"
+          />
+        </div>
+      </div>
+    </div>
+
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+
+const props = defineProps({
+  blockedDays: {
+    type: Array,
+    default: () => [],
+  },
+})
+
+const emit = defineEmits(['date-click', 'navigation'])
+
+const { locale } = useI18n()
+
+const today = new Date()
+const currentYear  = ref(today.getFullYear())
+const currentMonth = ref(today.getMonth() + 1) // 1-12
+
+const weekdayLabels = computed(() => {
+  const base = new Date(2023, 0, 1)
+  return Array.from({ length: 7 }, (_, i) => {
+    const d = new Date(base)
+    d.setDate(d.getDate() + i)
+    return d.toLocaleDateString(locale.value || 'pt-BR', { weekday: 'narrow' })
+      .replace('.', '')
+      .slice(0, 3)
+      .toUpperCase()
+  })
+})
+
+const monthLabel = computed(() => {
+  const d = new Date(currentYear.value, currentMonth.value - 1, 1)
+  return d.toLocaleDateString(locale.value || 'pt-BR', { month: 'long' })
+})
+
+const daysInMonth = computed(() => {
+  return new Date(currentYear.value, currentMonth.value, 0).getDate()
+})
+
+const firstDayOffset = computed(() => {
+  return new Date(currentYear.value, currentMonth.value - 1, 1).getDay()
+})
+
+const prevMonth = () => {
+  if (currentMonth.value === 1) {
+    currentMonth.value = 12
+    currentYear.value--
+  } else {
+    currentMonth.value--
+  }
+  emitNavigation()
+}
+
+const nextMonth = () => {
+  if (currentMonth.value === 12) {
+    currentMonth.value = 1
+    currentYear.value++
+  } else {
+    currentMonth.value++
+  }
+  emitNavigation()
+}
+
+const prevYear = () => {
+  currentYear.value--
+  emitNavigation()
+}
+
+const nextYear = () => {
+  currentYear.value++
+  emitNavigation()
+}
+
+const emitNavigation = () => {
+  emit('navigation', { year: currentYear.value, month: currentMonth.value })
+}
+
+const pad = (n) => String(n).padStart(2, '0')
+
+const dateStrForDay = (day) =>
+  `${currentYear.value}-${pad(currentMonth.value)}-${pad(day)}`
+
+const isToday = (day) => {
+  return (
+    day === today.getDate() &&
+    currentMonth.value === today.getMonth() + 1 &&
+    currentYear.value === today.getFullYear()
+  )
+}
+
+const isPast = (day) => {
+  const d = new Date(currentYear.value, currentMonth.value - 1, day)
+  const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate())
+  return d < todayStart
+}
+
+const blockedPeriodsForDay = (day) => {
+  const dateStr = dateStrForDay(day)
+  return props.blockedDays
+    .filter((bd) => bd.date === dateStr)
+    .map((bd) => bd.period)
+}
+
+const hasAnyBlock = (day) => blockedPeriodsForDay(day).length > 0
+
+const isMorningBlocked = (day) => {
+  const periods = blockedPeriodsForDay(day)
+  return periods.includes('morning') || periods.includes('all')
+}
+
+const isAfternoonBlocked = (day) => {
+  const periods = blockedPeriodsForDay(day)
+  return periods.includes('afternoon') || periods.includes('all')
+}
+
+const morningState = (day) => ({
+  'pill-half--blocked':  isMorningBlocked(day),
+  'pill-half--free':     !isMorningBlocked(day),
+})
+
+const afternoonState = (day) => ({
+  'pill-half--blocked':  isAfternoonBlocked(day),
+  'pill-half--free':     !isAfternoonBlocked(day),
+})
+
+const onDayClick = (day) => {
+  const dateStr = `${currentYear.value}/${pad(currentMonth.value)}/${pad(day)}`
+  emit('date-click', dateStr)
+}
+</script>
+
+<style scoped lang="scss">
+$morning-color:   #f916f9;
+$afternoon-color: #6366F1;
+$blocked-opacity: 1;
+$free-opacity:    0.05;
+
+.avail-calendar {
+  background: #fff;
+  border-radius: 20px;
+  overflow: hidden;
+  user-select: none;
+}
+
+/* ── Navegação ───────────────────────────────────────── */
+.avail-cal__nav {
+  background: #fff;
+}
+
+.avail-cal__nav-label {
+  font-weight: 700;
+  color: #1E293B;
+
+  &.month-label {
+    color: #6366F1;
+    font-size: 15px;
+    text-transform: capitalize;
+  }
+
+  &.year-label {
+    color: #6366F1;
+    font-size: 15px;
+  }
+}
+
+/* ── Cabeçalho weekdays ──────────────────────────────── */
+.avail-cal__weekdays {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+}
+
+.avail-cal__weekday {
+  font-size: 11px;
+  color: #6366F1;
+  opacity: 0.8;
+  padding: 4px 0;
+}
+
+/* ── Grid de dias ────────────────────────────────────── */
+.avail-cal__grid {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+  gap: 0;
+}
+
+.avail-cal__cell {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 2px 0 6px;
+}
+
+/* ── Botão do número ─────────────────────────────────── */
+.avail-cal__day-btn {
+  width: 32px;
+  height: 32px;
+  border: none;
+  background: transparent;
+  border-radius: 50%;
+  font-family: 'Inter', sans-serif;
+  font-size: 13px;
+  font-weight: 500;
+  color: #1E293B;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: background 0.15s;
+
+  &:hover {
+    background: #f1f5f9;
+  }
+
+  &.avail-cal__day--today {
+    color: #7c4dff;
+    background: rgba(124, 77, 255, 0.08);
+  }
+
+  &.avail-cal__day--past {
+    color: #CBD5E1;
+    cursor: default;
+    pointer-events: none;
+  }
+}
+
+/* ── Pílula ──────────────────────────────────────────── */
+.avail-cal__pill {
+  display: flex;
+  width: 28px;
+  height: 9px;
+  border-radius: 999px;
+  overflow: hidden;
+  cursor: pointer;
+  margin-top: 2px;
+}
+
+.avail-cal__pill-half {
+  flex: 1;
+  transition: opacity 0.2s, background-color 0.2s;
+}
+
+.avail-cal__pill-left {
+  background-color: $morning-color;
+  border-radius: 999px 0 0 999px;
+
+  &.pill-half--blocked {
+    opacity: $blocked-opacity;
+  }
+  &.pill-half--free {
+    opacity: $free-opacity;
+  }
+}
+
+.avail-cal__pill-right {
+  background-color: $afternoon-color;
+  border-radius: 0 999px 999px 0;
+
+  &.pill-half--blocked {
+    opacity: $blocked-opacity;
+  }
+  &.pill-half--free {
+    opacity: $free-opacity;
+  }
+}
+</style>

+ 15 - 16
src/components/profile/ProfileAvailabilityDialog.vue

@@ -69,7 +69,7 @@
             {{ $t('profile.availability.agenda_subtitle') }}
           </div>
 
-          <div class="shadow-card q-mb-xl " style="border-radius: 20px; overflow: hidden;">
+          <!-- <div class="shadow-card q-mb-xl " style="border-radius: 20px; overflow: hidden;">
             <q-date
               ref="calendar"
               v-model="calendarDate"
@@ -83,6 +83,14 @@
               @navigation="onNavigation"
             >
             </q-date>
+          </div> -->
+
+          <div class="shadow-card q-mb-xl" style="border-radius: 20px; overflow: hidden;">
+            <ProfileAvailabilityCalendar
+              :blocked-days="blockedDays"
+              @date-click="onDateClick"
+              @navigation="onNavigation"
+            />
           </div>
 
           <div class="q-pb-xl"></div>
@@ -170,7 +178,7 @@
 
 <script setup>
 import { ref, computed, onMounted } from 'vue';
-import { useDialogPluginComponent, useQuasar, /*date*/ } from 'quasar';
+import { useDialogPluginComponent, useQuasar } from 'quasar';
 import { useI18n } from 'vue-i18n';
 import {
   getProviderWorkingDays,
@@ -184,6 +192,7 @@ import {
 } from 'src/api/providerBlockedDay';
 import { userStore } from 'src/stores/user';
 import ProfileBlockDayDialog from './ProfileBlockDayDialog.vue';
+import ProfileAvailabilityCalendar from './ProfileAvailabilityCalendar.vue';
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -201,7 +210,7 @@ const blockedDays = ref([]);
 const loadingBlock = ref(false);
 const loadingUnblock = ref({});
 
-const calendar = ref(null);
+// const calendar = ref(null);
 const calendarDate = ref(null);
 const currentViewDate = ref(new Date());
 
@@ -216,9 +225,9 @@ const isSelected = (day, period) =>
 const isDayFullySelected = (day) =>
   isSelected(day, 'morning') && isSelected(day, 'afternoon');
 
-const blockedDatesForCalendar = computed(() =>
-  blockedDays.value.map((bd) => bd.date.replace(/-/g, '/'))
-);
+// const blockedDatesForCalendar = computed(() =>
+//   blockedDays.value.map((bd) => bd.date.replace(/-/g, '/'))
+// );
 
 const currentDateBlockedDays = computed(() => {
   if (!currentActionDate.value) return [];
@@ -314,16 +323,6 @@ const onDateClick = (date) => {
   showActionSheet.value = true;
 };
 
-// const nextMonth = () => {
-//   const current = currentViewDate.value || new Date();
-//   calendarDate.value = date.formatDate(date.addToDate(current, { months: 1 }), 'YYYY/MM/DD');
-// };
-
-// const prevMonth = () => {
-//   const current = currentViewDate.value || new Date();
-//   calendarDate.value = date.formatDate(date.subtractFromDate(current, { months: 1 }), 'YYYY/MM/DD');
-// };
-
 const onNavigation = (view) => {
   currentViewDate.value = new Date(view.year, view.month - 1, 1);
 };

+ 3 - 3
src/components/profile/ProfileBankDataDialog.vue

@@ -177,7 +177,7 @@ const savePix = async () => {
   savingPix.value = true;
   try {
     const payload = {
-      provider_id: user.user.id,
+      provider_id: user.user.provider.id,
       account_type: 'pix',
       pix_key: pixForm.pix_key,
     };
@@ -208,7 +208,7 @@ const saveBank = async () => {
   savingBank.value = true;
   try {
     const payload = {
-      provider_id: user.user.id,
+      provider_id: user.user.provider.id,
       account_type: 'bank_account',
       bank_account_type: bankForm.bank_account_type,
       agency: bankForm.agency,
@@ -235,7 +235,7 @@ const saveBank = async () => {
 
 onMounted(async () => {
   try {
-    const methods = await getProviderPaymentMethods(user.user.id);
+    const methods = await getProviderPaymentMethods(user.user.provider.id);
     if (!methods) return;
 
     const pix = methods.find((m) => m.account_type === 'pix');

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

@@ -238,7 +238,31 @@
         "until_8h": "Full day (up to 8h)",
         "until_6h": "Standard (up to 6h)",
         "until_4h": "Medium (up to 4h)",
-        "until_2h": "Quick (up to 2h)"
+        "until_2h": "Quick (up to 2h)",
+        "offers_meal": "Offers meal",
+        "not_offers_meal": "Does not offer meal",
+        "distance_prefix": "At",
+        "distance_suffix": "km from your registered address.",
+        "via_agenda_text": "Request made via",
+        "via_agenda": "schedule",
+        "same_day_warning_label": "Note:",
+        "same_day_warning": "When receiving more than one request for the same day, check the time and distance to plan your travel time.",
+        "confirm_accept": "Are you sure you want to accept this service?",
+        "confirm_reject": "Are you sure you want to refuse this service?",
+        "tip_label": "Tip:",
+        "reject_tip": "If you are not available, access your profile and block the days you do not want to receive requests."
+      },
+      "today_services": {
+        "start_label": "Service start for",
+        "code_hint": "Ask the client for the code to begin",
+        "code_placeholder": "0000",
+        "code_success": "Code verified successfully!",
+        "code_error": "Invalid code. Please try again.",
+        "step_accepted": "Accepted",
+        "step_paid": "Paid",
+        "step_started": "Started",
+        "step_finished": "Finished",
+        "help": "help"
       },
       "opportunities": {
         "title": "Opportunities"
@@ -269,6 +293,22 @@
         "title": "Last ones performed",
         "reschedule": "Reschedule"
       },
+      "cancel_schedule": {
+        "title": "Are you sure you want to cancel your order?",
+        "reason_label": "What is the reason for cancellation?",
+        "reason_placeholder": "Describe the reason...",
+        "warning_title": "Attention!",
+        "warning_free": "Free cancellations up to 12 hours before the scheduled time.",
+        "warning_fee": "Cancellations after this period result in a 50% refund as compensation for the professional.",
+        "btn_cancel": "cancel",
+        "btn_keep": "keep the order",
+        "btn_cancel_order": "Cancel order",
+        "help": "Help",
+        "schedule_details_title": "Details",
+        "service_fee": "Service fee:",
+        "total": "Total:",
+        "close": "close"
+      },
       "providers_close": {
         "title": "Close to you",
         "until_8h": "Up to 8h",
@@ -298,6 +338,10 @@
     "placeholder_name": "Enter your name",
     "placeholder_email": "Enter your e-mail",
     "placeholder_phone": "Enter your phone",
+    "language": "Language",
+    "lang_pt": "PT-br",
+    "lang_en": "EN-us",
+    "lang_es": "ES-es",
     "bank_data": {
       "title": "Bank data",
       "description": "Pix, agency and account",
@@ -630,5 +674,11 @@
       "home": "Residential",
       "commercial": "Commercial"
     }
+  },
+  "nav": {
+    "home": "Home",
+    "payments": "Payments",
+    "agenda": "Schedule",
+    "profile": "Profile"
   }
 }

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

@@ -238,7 +238,31 @@
         "until_8h": "Integral (hasta 8h)",
         "until_6h": "Estándar (Hasta 6h)",
         "until_4h": "Medio Período (Hasta 4h)",
-        "until_2h": "Jornada Rápida (Hasta 2h)"
+        "until_2h": "Jornada Rápida (Hasta 2h)",
+        "offers_meal": "Ofrece comida",
+        "not_offers_meal": "No ofrece comida",
+        "distance_prefix": "A",
+        "distance_suffix": "km de su dirección registrada.",
+        "via_agenda_text": "Pedido hecho vía",
+        "via_agenda": "agenda",
+        "same_day_warning_label": "Aviso:",
+        "same_day_warning": "Al recibir más de una solicitud para el mismo día, verifique el horario y la distancia para planificar su tiempo de desplazamiento.",
+        "confirm_accept": "¿Está seguro de que desea aceptar este servicio?",
+        "confirm_reject": "¿Está seguro de que desea rechazar este servicio?",
+        "tip_label": "Consejo:",
+        "reject_tip": "Si no tienes disponibilidad, accede a tu perfil y cierra los días en que no deseas recibir solicitudes."
+      },
+      "today_services": {
+        "start_label": "Inicio del servicio para",
+        "code_hint": "Solicite al cliente el código para comenzar",
+        "code_placeholder": "0000",
+        "code_success": "¡Código verificado con éxito!",
+        "code_error": "Código inválido. Inténtalo de nuevo.",
+        "step_accepted": "Aceptado",
+        "step_paid": "Pagado",
+        "step_started": "Iniciado",
+        "step_finished": "Concluido",
+        "help": "ayuda"
       },
       "opportunities": {
         "title": "Oportunidades"
@@ -269,6 +293,22 @@
         "title": "Últimas realizadas",
         "reschedule": "Reprogramar"
       },
+      "cancel_schedule": {
+        "title": "¿Estás seguro de que deseas cancelar tu pedido?",
+        "reason_label": "¿Cuál es el motivo de la cancelación?",
+        "reason_placeholder": "Describe el motivo...",
+        "warning_title": "¡Atención!",
+        "warning_free": "Cancelaciones gratuitas hasta 12 horas antes del horario programado.",
+        "warning_fee": "Las cancelaciones después de este período generan un reembolso del 50% como compensación al profesional.",
+        "btn_cancel": "cancelar",
+        "btn_keep": "mantener el pedido",
+        "btn_cancel_order": "Cancelar pedido",
+        "help": "Ayuda",
+        "schedule_details_title": "Detalles",
+        "service_fee": "Tarifa de servicio:",
+        "total": "Total:",
+        "close": "cerrar"
+      },
       "providers_close": {
         "title": "Cerca de ti",
         "until_8h": "Hasta 8h",
@@ -298,6 +338,10 @@
     "placeholder_name": "Escriba su nombre",
     "placeholder_email": "Escriba su correo electrónico",
     "placeholder_phone": "Escriba su teléfono",
+    "language": "Idioma",
+    "lang_pt": "PT-br",
+    "lang_en": "EN-us",
+    "lang_es": "ES-es",
     "bank_data": {
       "title": "Datos bancarios",
       "description": "Pix, agencia y cuenta",
@@ -630,5 +674,11 @@
       "home": "Residencial",
       "commercial": "Comercial"
     }
+  },
+  "nav": {
+    "home": "Inicio",
+    "payments": "Pagos",
+    "agenda": "Agenda",
+    "profile": "Perfil"
   }
 }

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

@@ -238,7 +238,31 @@
         "until_8h": "Integral (até 8h)",
         "until_6h": "Padrão (Até 6h)",
         "until_4h": "Meio Período (Até 4h)",
-        "until_2h": "Diária Rápida (Até 2h)"
+        "until_2h": "Diária Rápida (Até 2h)",
+        "offers_meal": "Oferece refeição",
+        "not_offers_meal": "Não oferece refeição",
+        "distance_prefix": "Há",
+        "distance_suffix": "km de distância do seu endereço cadastrado.",
+        "via_agenda_text": "Pedido feito via",
+        "via_agenda": "agenda",
+        "same_day_warning_label": "Aviso:",
+        "same_day_warning": "Ao receber mais de uma solicitação para o mesmo dia, verifique o horário e a distância para planejar seu tempo de deslocamento.",
+        "confirm_accept": "Tem certeza que deseja aceitar esse serviço?",
+        "confirm_reject": "Tem certeza que deseja recusar esse serviço?",
+        "tip_label": "Dica:",
+        "reject_tip": "Se você não tiver disponibilidade, acesse o seu perfil e feche os dias que não deseja receber solicitações."
+      },
+      "today_services": {
+        "start_label": "Início do serviço para",
+        "code_hint": "Solicite ao cliente o código para começar",
+        "code_placeholder": "0000",
+        "code_success": "Código verificado com sucesso!",
+        "code_error": "Código inválido. Tente novamente.",
+        "step_accepted": "Aceito",
+        "step_paid": "Pago",
+        "step_started": "Iniciado",
+        "step_finished": "Concluído",
+        "help": "ajuda"
       },
       "opportunities": {
         "title": "Oportunidades"
@@ -269,6 +293,22 @@
         "title": "Últimas realizadas",
         "reschedule": "Reagendar"
       },
+      "cancel_schedule": {
+        "title": "Tem certeza que deseja cancelar seu pedido?",
+        "reason_label": "Qual é o motivo do cancelamento?",
+        "reason_placeholder": "Descreva o motivo...",
+        "warning_title": "Atenção!",
+        "warning_free": "Cancelamentos gratuitos até 12h antes do horário agendado.",
+        "warning_fee": "Cancelamentos após este período geram reembolso de 50% do valor, como taxa de compensação ao profissional.",
+        "btn_cancel": "cancelar",
+        "btn_keep": "manter o pedido",
+        "btn_cancel_order": "Cancelar pedido",
+        "help": "Ajuda",
+        "schedule_details_title": "Detalhes",
+        "service_fee": "Taxa de serviço:",
+        "total": "Total:",
+        "close": "fechar"
+      },
       "providers_close": {
         "title": "Próximos a você",
         "until_8h": "Até 8h",
@@ -298,6 +338,10 @@
     "placeholder_name": "Digite seu nome",
     "placeholder_email": "Digite seu e-mail",
     "placeholder_phone": "Digite seu telefone",
+    "language": "Idioma",
+    "lang_pt": "PT-br",
+    "lang_en": "EN-us",
+    "lang_es": "ES-es",
     "bank_data": {
       "title": "Dados bancários",
       "description": "Pix, agência e conta",
@@ -630,5 +674,11 @@
       "home": "Casa",
       "commercial": "Comercial"
     }
+  },
+  "nav": {
+    "home": "Início",
+    "payments": "Pagamentos",
+    "agenda": "Agenda",
+    "profile": "Perfil"
   }
 }

+ 8 - 6
src/layouts/MainLayout.vue

@@ -50,6 +50,7 @@
 import { computed, useTemplateRef, watch } from "vue";
 import { useQuasar } from "quasar";
 import { useRoute } from "vue-router";
+import { useI18n } from "vue-i18n";
 
 defineOptions({
   name: "MainLayout",
@@ -58,31 +59,32 @@ defineOptions({
 const $q = useQuasar();
 const route = useRoute();
 const scrollAreaRef = useTemplateRef("scrollAreaRef");
+const { t } = useI18n();
 
 let oldValue = route.path;
 
-const navItems = [
+const navItems = computed(() => [
   {
     name: "DashboardPage",
-    label: "Início",
+    label: t('nav.home'),
     icon: "mdi-home-outline",
   },
   {
     name: "PagamentosPage",
-    label: "Pagamentos",
+    label: t('nav.payments'),
     icon: "mdi-credit-card-outline",
   },
   {
     name: "AgendaPage",
-    label: "Agenda",
+    label: t('nav.agenda'),
     icon: "mdi-calendar-blank-outline",
   },
   {
     name: "ProfilePage",
-    label: "Perfil",
+    label: t('nav.profile'),
     icon: "mdi-account-circle-outline",
   },
-];
+]);
 
 const isNavItemActive = (item) => route.name === item.name;
 

+ 54 - 5
src/pages/dashboard/DashboardPage.vue

@@ -9,10 +9,17 @@
       <DashboardHeaderBar :data="headerBar" />
       <DashboardSummaryInfos :data="summaryInfos" />
       <DashboardPriceSuggest :data="priceSuggestion"/>
+      <DashboardTodayServices v-if="todayServices?.length > 0" :data="todayServices" @refresh="loadDashboard" />
       <DashboardScrollAreaSchedules />
-      <DashboardSolicitations :data="solicitations"/>
-      <DashboardNextSchedules :data="nextSchedules" />
-      <DashboardOpportunities :data="opportunities"/>
+      <DashboardSolicitations
+        v-if="solicitations?.length > 0"
+        :data="solicitations"
+        @accept="(item) => openDetailsDialog(item, 'confirm-accept')"
+        @reject="(item) => openDetailsDialog(item, 'confirm-reject')"
+        @view-details="(item) => openDetailsDialog(item)"
+      />
+      <DashboardNextSchedules v-if="nextSchedules?.length > 0" :data="nextSchedules" @view-details="(item) => openNextScheduleDialog(item)" />
+      <DashboardOpportunities v-if="opportunities?.length > 0" :data="opportunities"/>
     </template>
   </div>
 </template>
@@ -23,29 +30,71 @@ import DashboardSummaryInfos from 'src/components/dashboard/DashboardSummaryInfo
 import DashboardPriceSuggest from 'src/components/dashboard/DashboardPriceSuggest.vue';
 import DashboardScrollAreaSchedules from 'src/components/dashboard/DashboardScrollAreaSchedules.vue';
 import DashboardSolicitations from 'src/components/dashboard/DashboardSolicitations.vue';
+import DashboardTodayServices from 'src/components/dashboard/DashboardTodayServices.vue';
 import DashboardNextSchedules from 'src/components/dashboard/DashboardNextSchedules.vue';
 import DashboardOpportunities from 'src/components/dashboard/DashboardOpportunities.vue';
+import SolicitationDetailsDialog from 'src/components/dashboard/SolicitationDetailsDialog.vue';
 import { onMounted, ref } from 'vue';
+import { useQuasar } from 'quasar';
 import { dadosDashboard } from 'src/api/dashboard';
+import { updateScheduleStatus } from 'src/api/schedule';
 
 const headerBar = ref({});
 const summaryInfos = ref({});
 const priceSuggestion = ref({});
 const solicitations = ref([]);
+const todayServices = ref([]);
 const nextSchedules = ref([]);
 const opportunities = ref([]);
 
+const $q = useQuasar();
+
 const loading = ref(true);
-onMounted( async () => {
+
+const loadDashboard = async () => {
   const response = await dadosDashboard();
-  if(response) {
+  if (response) {
     headerBar.value = response.headerBar;
     summaryInfos.value = response.summaryInfos;
     priceSuggestion.value = response.priceSuggested;
     solicitations.value = response.solicitations;
+    todayServices.value = response.todayServices ?? [];
     nextSchedules.value = response.nextSchedules;
     opportunities.value = response.opportunities;
   }
+};
+
+const handleScheduleAction = async (id, status) => {
+  try {
+    await updateScheduleStatus(id, status);
+    await loadDashboard();
+  } catch (e) {
+    console.log(e);
+  }
+};
+
+const openDetailsDialog = (solicitation, initialView = 'details') => {
+  $q.dialog({
+    component: SolicitationDetailsDialog,
+    componentProps: { solicitation, initialView }
+  }).onOk(async ({ action, id }) => {
+    await handleScheduleAction(id, action === 'accept' ? 'accepted' : 'rejected');
+  });
+};
+
+const openNextScheduleDialog = (schedule) => {
+  $q.dialog({
+    component: SolicitationDetailsDialog,
+    componentProps: { solicitation: schedule }
+  }).onOk(async ({ action }) => {
+    if (action === 'cancelled') {
+      await loadDashboard();
+    }
+  });
+};
+
+onMounted(async () => {
+  await loadDashboard();
   loading.value = false;
 });
 </script>

+ 26 - 3
src/pages/profile/ProfileEditDialog.vue

@@ -57,9 +57,16 @@
                 :placeholder="$t('profile.placeholder_phone')"
               />
             </div>
-          </div>
 
-          <q-space/>
+            <div>
+              <div class="text-weight-bold text-grey-8 q-mb-sm">{{ $t('profile.language') }}</div>
+              <div class="row q-gutter-x-md">
+                <q-radio v-model="selectedLocale" val="pt" :label="$t('profile.lang_pt')" color="primary" class="text-text" keep-color @update:model-value="onLocaleChange" />
+                <q-radio v-model="selectedLocale" val="en" :label="$t('profile.lang_en')" color="primary" class="text-text" keep-color @update:model-value="onLocaleChange" />
+                <q-radio v-model="selectedLocale" val="es" :label="$t('profile.lang_es')" color="primary" class="text-text" keep-color @update:model-value="onLocaleChange" />
+              </div>
+            </div>
+          </div>
 
           <div class="q-pa-xl q-mt-md">
             <q-btn
@@ -83,9 +90,10 @@
 
 <script setup>
 import { ref, onMounted } from 'vue';
-import { useDialogPluginComponent } from 'quasar';
+import { useDialogPluginComponent, Cookies } from 'quasar';
 import { updateUser } from 'src/api/user';
 import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker';
+import { i18n } from 'src/boot/i18n';
 
 const props = defineProps({
   userData: {
@@ -101,6 +109,21 @@ const loading = ref(false);
 const submitting = ref(false);
 const userId = ref(null);
 
+const normalizeLocale = (loc) => {
+  if (!loc) return 'pt'
+  const l = String(loc).toLowerCase()
+  if (l.startsWith('pt')) return 'pt'
+  if (l.startsWith('en')) return 'en'
+  if (l.startsWith('es')) return 'es'
+  return 'pt'
+}
+const selectedLocale = ref(normalizeLocale(i18n.global.locale.value ?? i18n.global.locale))
+
+const onLocaleChange = (val) => {
+  i18n.global.locale.value = val
+  Cookies.set('locale', val, { expires: 365, path: '/' })
+}
+
 const { form, hasUpdatedFields, setUpdateFormAsOriginal } = useFormUpdateTracker({
   name: '',
   email: '',