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