Переглянути джерело

feat: :sparkles: feat (agendamentos) criado bloco de servicos do dia na dashboard

foi criado o bloco de serviços do dia na dashboard, com indicacao de inicio, codigo preenchido e finalizacao (libera avaliacao ao finalizar)

fase:dev | origin:escopo
Gustavo Zanatta 6 днів тому
батько
коміт
db61be4c63

+ 281 - 181
src/components/dashboard/DashboardTodayServices.vue

@@ -1,174 +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 class="scroll-wrapper">
+      <div class="scroll-track">
+        <q-card
+          v-for="item in props.data"
+          :key="item.id"
+          class="today-card card-border shadow-card bg-surface"
+          :flat="false"
         >
-          <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>
+          <q-card-section class="q-pa-md">
+            <div class="row no-wrap items-start q-mb-xs">
+              <div class="col-7 row">
+                <q-avatar size="40px" class="flex-shrink-0 q-mr-sm">
+                  <span
+                    :style="avatarColors[item.id % avatarColors.length]"
+                    class="text-weight-bold full-width full-height flex flex-center"
+                    style="font-size:14px; border-radius:50%;"
+                  >
+                    {{ item.client_name?.slice(0, 2).toUpperCase() ?? '??' }}
+                  </span>
+                </q-avatar>
+
+                <div class="col column no-wrap overflow-hidden justify-center">
+                  <span class="text-body2 text-text leading-tight">
+                    <template v-if="cardState(item) === 'finished'">
+                      {{ $t('provider.dashboard.today_services.finished_label') }}
+                      <span class="text-weight-bold"> {{ ' ' + item.client_name ?? '—' }}</span>
+                      {{ ' ' + $t('provider.dashboard.today_services.finished_suffix') }}
+                    </template>
+                    <template v-else>
+                      {{ $t('provider.dashboard.today_services.start_label') }}
+                      <span class="text-weight-bold"> {{ ' ' + item.client_name ?? '—' }}</span>
+                    </template>
+                  </span>
+                  <div class="row items-center q-mt-xs">
+                    <q-icon name="mdi-clock-outline" size="13px" class="q-mr-xs gradient-diarista" />
+                    <span class="text-caption text-grey-5">
+                      {{ $t('common.from') }} {{  item.start_time?.slice(0, 5) }} {{ $t('common.to') }} {{ item.end_time?.slice(0, 5) }}
+                    </span>
+                  </div>
+                </div>
+              </div>
+
+              <div class="flex-shrink-0 row items-center justify-center col-5">
+                <div v-if="cardState(item) === 'awaiting_code'" class="col-12 row items-center justify-center q-pb-sm q-px-sm">
+                  <div class="hint-text text-caption text-text text-weight-bold text-center q-mb-xs col-12">
+                    {{ $t('provider.dashboard.today_services.code_hint1') }}
+                  </div>
+                  <div class="hint-text text-caption text-text text-weight-bold text-center q-mb-xs col-12">
+                    {{ $t('provider.dashboard.today_services.code_hint2') }}
+                  </div>
+                  <div class="code-input-row col-12">
+                    <input
+                      v-for="(_, idx) in 4"
+                      :key="idx"
+                      :ref="el => setCodeRef(item.id, idx, el)"
+                      v-model="codeInputs[item.id][idx]"
+                      class="code-input-box"
+                      type="tel"
+                      maxlength="1"
+                      inputmode="numeric"
+                      @input="onCodeInput(item, idx)"
+                      @keydown.delete="onCodeDelete(item.id, idx)"
+                      @paste.prevent="onCodePaste(item, $event)"
+                    />
+                  </div>
+                  <div v-if="codeError[item.id]" class="text-negative code-error-text q-mt-xs text-center">
+                    {{ codeError[item.id] }}
+                  </div>
+                </div>
+
+                <div v-else-if="cardState(item) === 'in_progress'" class="col-12 text-center q-mb-xs">
+                  <div class="column items-center">
+                    <span class="badge-status-text text-text text-weight-bold q-my-xs">
+                      {{ $t('provider.dashboard.today_services.in_progress') }}
+                    </span>
+                    <div class="code-pill bg-positive row">
+                      <q-icon
+                        name="mdi-check" size="14px" class="q-mr-xs q-my-auto"
+                      />
+                      {{ item.code || localVerified[item.id] }}
+                    </div>
+                  </div>
+                </div>
+
+                <div v-else>
+                  <q-btn
+                    unelevated rounded no-caps
+                    class="rate-btn"
+                    icon="mdi-star-outline"
+                    :label="$t('provider.dashboard.today_services.rate_btn')"
+                    size="sm"
+                    @click.stop="emit('rate', item)"
+                  />
+                </div>
+              </div>
+            </div>
+
+            <div class="progress-track q-mb-sm">
+              <div
+                class="progress-fill"
+                :class="cardState(item) === 'finished' ? 'progress-fill--finished' : ''"
+                :style="{ width: progressByState(item) + '%' }"
+              />
+            </div>
+
+            <div class="row items-center no-wrap">
+              <q-btn
+                flat no-caps dense
+                :label="$t('provider.dashboard.today_services.help')"
+                color="primary"
+                size="sm"
+                class="flex-shrink-0"
+                @click.stop="openHelp"
+              />
+              <q-space />
+              <template v-if="cardState(item) === 'in_progress'">
+                <q-icon name="mdi-clock-outline" size="13px" class="q-mr-xs flex-shrink-0 gradient-diarista" />
+                <span class="text-caption text-grey-6 text-right text-no-wrap">
+                  {{ $t('provider.dashboard.today_services.end_time_label') }} 
+                  <span class="gradient-diarista">{{ item.end_time?.slice(0, 5) }}</span>
+                </span>
+              </template>
+              <template v-else-if="cardState(item) === 'awaiting_code'">
+                <q-icon name="mdi-map-marker-outline" size="13px" color="grey-6" class="q-mr-xs flex-shrink-0" />
+                <span class="text-caption text-grey-6 col ellipsis text-right">
+                  {{ [item.address?.address, item.address?.number, item.address?.district].filter(Boolean).join(', ') || '—' }}
+                </span>
+              </template>
+            </div>
+          </q-card-section>
+        </q-card>
+      </div>
+    </div>
   </div>
 </template>
 
 <script setup>
-import { ref, nextTick } from 'vue'
-import { useI18n } from 'vue-i18n'
+import { reactive } from 'vue'
 import { useQuasar } from 'quasar'
+import { useI18n } from 'vue-i18n'
 import ProfileHelpDialog from 'src/components/profile/ProfileHelpDialog.vue'
 import { verifyScheduleCode } from 'src/api/schedule'
 
-const props = defineProps({
-  data: {
-    type: Array,
-    default: () => []
-  }
-})
+const props = defineProps({ data: { type: Array, default: () => [] } })
+const emit = defineEmits(['rate', 'refresh'])
 
-const emit = defineEmits(['refresh'])
-
-const { t } = useI18n()
 const $q = useQuasar()
+const { t } = useI18n()
 
-const codes = ref({})
-const loadingCode = ref({})
+const codeInputs    = reactive({})
+const codeRefs      = reactive({})
+const codeError     = reactive({})
+const localVerified = reactive({})
 
-const progressValue = (status) => {
-  const map = { accepted: 0.4, paid: 0.6, started: 0.8, finished: 1.0 }
-  return map[status] ?? 0.4
+const ensureCode = (id) => {
+  if (!codeInputs[id]) codeInputs[id] = ['', '', '', '']
+  if (!codeRefs[id])   codeRefs[id]   = [null, null, null, null]
 }
 
-const canEnterCode = (item) => ['paid', 'started'].includes(item.status)
+props.data.forEach(item => ensureCode(item.id))
 
-const focusInput = (id) => {
-  nextTick(() => document.getElementById(`code-input-${id}`)?.focus())
+const setCodeRef = (id, idx, el) => {
+  ensureCode(id)
+  codeRefs[id][idx] = el
 }
 
-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
+const onCodeInput = async (item, idx) => {
+  const id  = item.id
+  const val = codeInputs[id][idx]
+  if (!/^\d$/.test(val)) { codeInputs[id][idx] = ''; return }
+  codeError[id] = null
+  if (idx < 3) {
+    codeRefs[id][idx + 1]?.focus()
+  } else {
+    await submitCode(item)
+  }
+}
+
+const onCodeDelete = (id, idx) => {
+  if (codeInputs[id][idx] === '' && idx > 0) {
+    codeInputs[id][idx - 1] = ''
+    codeRefs[id][idx - 1]?.focus()
+  }
+}
+
+const onCodePaste = async (item, e) => {
+  const text = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 4)
+  if (text.length !== 4) return
+  const id = item.id
+  text.split('').forEach((ch, i) => { codeInputs[id][i] = ch })
+  await submitCode(item)
+}
+
+const submitCode = async (item) => {
+  const id   = item.id
+  const code = codeInputs[id].join('')
+  if (code.length < 4) return
   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' })
+    const response = await verifyScheduleCode(id, code)
+    console.log(response)
+    if (response?.payload) {
+      localVerified[id] = code
       emit('refresh')
     } else {
-      $q.notify({ type: 'negative', message: t('provider.dashboard.today_services.code_error'), position: 'top' })
-      codes.value[item.id] = ''
+      const msg = response?.data?.message || response?.message || t('provider.dashboard.today_services.code_error')
+      codeError[id] = msg
+      codeInputs[id] = ['', '', '', '']
+      codeRefs[id][0]?.focus()
     }
-  } 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
+  } catch (err) {
+    const msg = err?.response?.data?.message || t('provider.dashboard.today_services.code_error')
+    codeError[id] = msg
+    codeInputs[id] = ['', '', '', '']
+    codeRefs[id][0]?.focus()
   }
 }
 
-const formatAddressShort = (address) => {
-  if (!address) return ''
-  return [address.address, address.number, address.district].filter(Boolean).join(', ')
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+]
+
+const cardState = (item) => {
+  const verified = item.code_verified || !!localVerified[item.id]
+  if (!verified) return 'awaiting_code'
+  const [h, m] = (item.end_time || '23:59').slice(0, 5).split(':').map(Number)
+  const endTime = new Date()
+  endTime.setHours(h, m, 0, 0)
+  return new Date() >= endTime ? 'finished' : 'in_progress'
 }
 
-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 progressByState = (item) => {
+  const state = cardState(item)
+  if (state === 'awaiting_code') return 60
+  if (state === 'in_progress')   return 80
+  return 100
 }
 
 const openHelp = () => {
@@ -177,67 +246,98 @@ const openHelp = () => {
 </script>
 
 <style scoped lang="scss">
+.scroll-wrapper { overflow: hidden; }
+.scroll-track {
+  display: flex;
+  flex-direction: row;
+  gap: 12px;
+  overflow-x: auto;
+  overscroll-behavior-x: contain;
+  scroll-snap-type: x proximity;
+  padding-bottom: 8px;
+  &::-webkit-scrollbar { display: none; }
+  &::after { content: ''; flex: 0 0 1px; }
+}
+
 .today-card {
+  min-width: 80%;
+  scroll-snap-align: start;
   border-radius: 12px;
 }
 
 .hint-text {
   max-width: 100px;
   line-height: 1.3;
-  font-size: 11px;
+  font-size: 9px;
 }
 
-/* OTP input */
-.code-container {
-  position: relative;
-  cursor: text;
-  user-select: none;
+.code-input-row {
+  display: flex;
+  gap: 5px;
 }
 
-.code-real-input {
-  position: absolute;
-  width: 1px;
-  height: 1px;
-  opacity: 0;
-  pointer-events: none;
-  top: 0;
-  left: 0;
+.code-input-box {
+  width: 26px;
+  height: 32px;
+  background: #d1d5db;
+  border: none;
+  border-radius: 6px;
+  text-align: center;
+  font-size: 15px;
+  font-weight: 700;
+  color: #1a1a2e;
+  outline: none;
+  caret-color: transparent;
+  &:focus {
+    background: #bec3cc;
+    box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.4);
+  }
 }
 
-.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-error-text {
+  font-size: 9px;
+  max-width: 110px;
+  line-height: 1.2;
 }
 
-.code-box--filled {
-  background: #e0d8f8;
-  color: var(--q-secondary);
+.code-pill {
+  color: white;
+  font-weight: 800;
+  font-size: 15px;
+  letter-spacing: 2px;
+  border-radius: 20px;
+  padding: 1px 18px;
 }
 
-.code-box--verified {
-  background: #e8f5e9;
+.badge-status-text {
+  font-size: 11px;
+  
 }
 
-.code-disabled .code-box {
-  opacity: 0.5;
-  cursor: not-allowed;
+.rate-btn {
+  background: linear-gradient(90deg, #8B5CF6, #EC4899);
+  color: white;
+  font-weight: 700;
+  font-size: 12px;
+  white-space: nowrap;
 }
 
-.address-text {
-  max-width: 150px;
+.progress-track {
+  width: 100%;
+  height: 5px;
+  background: #E2E8F0;
+  border-radius: 3px;
+  overflow: hidden;
 }
 
-.btn-help {
-  font-weight: 700;
-  font-size: 13px;
+.progress-fill {
+  height: 100%;
+  border-radius: 3px;
+  background: linear-gradient(90deg, #8B5CF6, #EC4899);
+  transition: width 0.4s ease;
+
+  &--finished {
+    background: #22c55e;
+  }
 }
 </style>

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

@@ -254,7 +254,10 @@
       },
       "today_services": {
         "start_label": "Service start for",
-        "code_hint": "Ask the client for the code to begin",
+        "finished_label": "Service by",
+        "finished_suffix": "completed",
+        "code_hint1": "Ask the client for ",
+        "code_hint2": "the code to begin",
         "code_placeholder": "0000",
         "code_success": "Code verified successfully!",
         "code_error": "Invalid code. Please try again.",
@@ -262,6 +265,9 @@
         "step_paid": "Paid",
         "step_started": "Started",
         "step_finished": "Finished",
+        "in_progress": "Service in progress!",
+        "end_time_label": "Service ends at",
+        "rate_btn": "Rate",
         "help": "help"
       },
       "opportunities": {

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

@@ -254,7 +254,8 @@
       },
       "today_services": {
         "start_label": "Inicio del servicio para",
-        "code_hint": "Solicite al cliente el código para comenzar",
+        "code_hint1": "Solicite al cliente el ",
+        "code_hint2": "código para comenzar",
         "code_placeholder": "0000",
         "code_success": "¡Código verificado con éxito!",
         "code_error": "Código inválido. Inténtalo de nuevo.",
@@ -262,6 +263,9 @@
         "step_paid": "Pagado",
         "step_started": "Iniciado",
         "step_finished": "Concluido",
+        "in_progress": "Servicio en progreso!",
+        "end_time_label": "Término del servicio en",
+        "rate_btn": "Evaluar",
         "help": "ayuda"
       },
       "opportunities": {

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

@@ -254,7 +254,10 @@
       },
       "today_services": {
         "start_label": "Início do serviço para",
-        "code_hint": "Solicite ao cliente o código para começar",
+        "finished_label": "Serviço de",
+        "finished_suffix": "concluído",
+        "code_hint1": "Solicite ao cliente o ",
+        "code_hint2": "código para começar",
         "code_placeholder": "0000",
         "code_success": "Código verificado com sucesso!",
         "code_error": "Código inválido. Tente novamente.",
@@ -262,6 +265,9 @@
         "step_paid": "Pago",
         "step_started": "Iniciado",
         "step_finished": "Concluído",
+        "in_progress": "Serviço em andamento!",
+        "end_time_label": "Término do serviço em",
+        "rate_btn": "Avaliar",
         "help": "ajuda"
       },
       "opportunities": {

+ 3 - 3
src/pages/dashboard/DashboardPage.vue

@@ -58,10 +58,10 @@ const loadDashboard = async () => {
     headerBar.value = response.headerBar;
     summaryInfos.value = response.summaryInfos;
     priceSuggestion.value = response.priceSuggested;
-    solicitations.value = response.solicitations;
+    solicitations.value = response.solicitations ?? [];
     todayServices.value = response.todayServices ?? [];
-    nextSchedules.value = response.nextSchedules;
-    opportunities.value = response.opportunities;
+    nextSchedules.value = response.nextSchedules ?? [];
+    opportunities.value = response.opportunities ?? [];
   }
 };