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