| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571 |
- <template>
- <div
- v-if="visibleItems.length > 0"
- class="q-mx-md q-mb-md"
- >
- <div class="scroll-wrapper">
- <div
- class="scroll-track"
- :class="{ 'scroll-track--single': visibleItems.length === 1 }"
- >
- <q-card
- v-for="item in visibleItems"
- :key="item.id"
- :class="{ 'today-card--single': visibleItems.length === 1 }"
- :flat="false"
- class="today-card card-border shadow-card bg-surface"
- >
- <q-card-section class="column justify-between q-pa-sm full-height">
- <div class="row no-wrap items-center q-mb-xs">
- <div class="col-8 row">
- <q-avatar size="40px" class="flex-shrink-0 q-mr-sm">
- <img
- v-if="item.customer_photo"
- :src="item.customer_photo"
- style="object-fit:cover"
- />
- <span
- v-else
- :style="avatarColors[item.id % avatarColors.length]"
- class="full-width full-height flex flex-center"
- style="border-radius:50%;"
- >
- {{ getFirstName(item.client_name)?.slice(0, 2).toUpperCase() ?? '??' }}
- </span>
- </q-avatar>
- <div class="col column no-wrap overflow-hidden justify-center">
- <span class="text-text font12">
- <template v-if="cardState(item) === 'finished'">
- {{ $t('provider.dashboard.today_services.finished_label') }}
- <span class="fontbold">
- {{ ' ' + (getFirstName(item.client_name) || '—') }}
- </span>
- {{ ' ' + $t('provider.dashboard.today_services.finished_suffix') }}
- </template>
- <template v-else>
- {{ $t('provider.dashboard.today_services.start_label') }}
- <span class="fontbold">
- {{ ' ' + (getFirstName(item.client_name) || '—') }}
- </span>
- </template>
- </span>
- <div class="row items-center q-mt-xs no-wrap">
- <q-icon
- class="q-mr-xs gradient-diarista"
- name="mdi-clock-outline"
- size="12px"
- />
- <span class="text-grey-5 font9 fontregular">
- {{ $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-right justify-end col-4">
- <div
- v-if="cardState(item) === 'awaiting_code'"
- class="col-12 row items-center justify-end q-pb-sm"
- >
- <div class="hint-text text-text text-center font9 q-mb-xs col-12">
- {{ $t('provider.dashboard.today_services.code_hint') }}
- </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 font9 fontbold"
- inputmode="numeric"
- maxlength="1"
- type="tel"
- @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 font9 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="text-text font9 fontmedium q-my-xs">
- {{ $t('provider.dashboard.today_services.in_progress') }}
- </span>
-
- <div class="code-pill font12 fontbold bg-positive row">
- <q-icon
- class="q-mr-xs q-my-auto"
- name="mdi-check"
- size="12px"
- />
- {{ item.code || localVerified[item.id] }}
- </div>
- </div>
- </div>
- <div v-else class="">
- <div
- v-if="item.provider_reviewed"
- class="rate-btn reviewed-badge column fontsemibold"
- >
- <div class="text-center">
- <q-icon
- class="q-mr-xs"
- name="mdi-star"
- size="14px"
- />
- </div>
- {{ $t('provider.dashboard.today_services.reviewed_badge') }}
- </div>
- <div v-else class="">
- <q-btn
- class="rate-btn font10 fontmedium"
- no-caps
- unelevated
- @click.stop="emit('rate', item)"
- >
- <div class="column items-center">
- <q-icon
- class="q-mr-xs"
- name="mdi-star-outline"
- size="14px"
- />
-
- {{ $t('provider.dashboard.today_services.rate_btn') }}
- </div>
- </q-btn>
- </div>
- </div>
- </div>
- </div>
- <div class="progress-track q-mb-xs">
- <div
- class="progress-fill"
- :class="cardState(item) === 'finished' ? 'progress-fill--finished' : ''"
- :style="{ width: progressByState(item) + '%' }"
- />
- </div>
- <template v-if="cardState(item) === 'in_progress'">
- <div class="row items-center no-wrap">
- <q-icon
- class="q-mr-xs flex-shrink-0 gradient-diarista"
- name="mdi-clock-outline"
- size="13px"
- />
-
- <span class="text-grey-6 text-right text-no-wrap font9 fontmedium">
- {{ $t('provider.dashboard.today_services.end_time_label') }}
-
- <span class="gradient-diarista">
- {{ calculateTimeUntilEnd(item.end_time) }}
- </span>
- </span>
- </div>
- </template>
- <template v-else-if="cardState(item) === 'awaiting_code'">
- <div class="address-row">
- <q-icon
- class="flex-shrink-0"
- color="grey-6"
- name="mdi-map-marker-outline"
- size="13px"
- />
-
- <span class="text-grey-6 address-text text-right font9 fontmedium">
- {{ [item.address?.address, item.address?.number, item.address?.district].filter(Boolean).join(', ') || '—' }}
-
- </span>
- <q-icon
- class="flex-shrink-0 q-ml-sm"
- color="primary"
- name="mdi-content-copy"
- size="16px"
- @click="copyAddress(item.address)"
- />
- </div>
- </template>
- <div class="row items-center no-wrap">
- <q-btn
- class="flex-shrink-0"
- color="primary"
- flat no-caps dense
- size="sm"
- :label="$t('provider.dashboard.today_services.help')"
- @click.stop="openHelp"
- />
- </div>
- </q-card-section>
- </q-card>
- </div>
- </div>
- </div>
- </template>
- <script setup>
- import { avatarColors, formatAddress, getFirstName } from 'src/helpers/utils';
- import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
- import { useI18n } from 'vue-i18n'
- import { useQuasar } from 'quasar'
- import { verifyScheduleCode } from 'src/api/schedule'
- import ProfileHelpDialog from 'src/components/profile/ProfileHelpDialog.vue'
- const props = defineProps({ data: { type: Array, default: () => [] } })
- const emit = defineEmits(['rate', 'refresh'])
- const { t } = useI18n()
- const $q = useQuasar()
- const now = ref(Date.now())
- let timer = null
- onMounted(() => {
- timer = setInterval(() => { now.value = Date.now() }, 1000)
- })
- onUnmounted(() => {
- if (timer) clearInterval(timer)
- })
- const codeInputs = reactive({})
- const codeRefs = reactive({})
- const codeError = reactive({})
- const localVerified = reactive({})
- const ensureCode = (id) => {
- if (!codeInputs[id]) codeInputs[id] = ['', '', '', '']
- if (!codeRefs[id]) codeRefs[id] = [null, null, null, null]
- }
- props.data.forEach(item => ensureCode(item.id))
- const setCodeRef = (id, idx, el) => {
- ensureCode(id)
- codeRefs[id][idx] = el
- }
- //
- 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(id, code)
- if (response?.payload) {
- localVerified[id] = code
- emit('refresh')
- } else {
- const msg = response?.data?.message || response?.message || t('provider.dashboard.today_services.code_error')
- codeError[id] = msg
- codeInputs[id] = ['', '', '', '']
- codeRefs[id][0]?.focus()
- }
- } 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 calculateTimeUntilEnd = (endTimeStr) => {
- const [h, m] = (endTimeStr || '23:59').slice(0, 5).split(':').map(Number)
- const endTime = new Date()
- endTime.setHours(h, m, 0, 0)
- const diffMs = endTime.getTime() - now.value
- if (diffMs <= 0) return '00:00:00'
- const diffH = Math.floor(diffMs / (1000 * 60 * 60))
- const diffM = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
- const diffS = Math.floor((diffMs % (1000 * 60)) / 1000)
- return `${String(diffH).padStart(2, '0')}:${String(diffM).padStart(2, '0')}:${String(diffS).padStart(2, '0')}`
- }
- 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 now.value >= endTime.getTime() ? 'finished' : 'in_progress'
- }
- const visibleItems = computed(() =>
- props.data.filter((item) => !item.provider_reviewed)
- )
- const copyAddress = (address) => {
- const formatted = formatAddress(address);
- if (formatted) {
- navigator.clipboard.writeText(formatted);
- }
- $q.notify({ message: t('provider.dashboard.next_schedules.address_copied'), color: 'positive' });
- };
- //
- const progressByState = (item) => {
- const state = cardState(item)
- if (state === 'awaiting_code') return 50
- if (state === 'finished') return 100
- const [sh, sm] = (item.start_time || '00:00').slice(0, 5).split(':').map(Number)
- const [eh, em] = (item.end_time || '23:59').slice(0, 5).split(':').map(Number)
- const startTime = new Date()
- startTime.setHours(sh, sm, 0, 0)
- const endTime = new Date()
- endTime.setHours(eh, em, 0, 0)
- const total = endTime.getTime() - startTime.getTime()
- if (total <= 0) return 80
- const elapsed = now.value - startTime.getTime()
- return Math.min(Math.max((elapsed / total) * 100, 10), 100)
- }
- //
- const openHelp = () => {
- $q.dialog({ component: ProfileHelpDialog })
- }
- </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: 82vw;
- scroll-snap-align: start;
- border-radius: 12px;
- }
- .today-card--single {
- min-width: 100%;
- width: 100%;
- }
- .hint-text {
- max-width: 100px;
- line-height: 1.3;
- }
- .code-input-row {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 4px;
- background: #d1d5db;
- border-radius: 999px;
- padding: 4px 8px;
- width: 110px;
- height: 28px;
- margin: 0;
- }
- .code-input-box {
- width: 12px;
- height: 18px;
- background: transparent;
- border: none;
- border-bottom: 1px solid white;
- border-radius: 0;
- text-align: center;
- color: #1a1a2e;
- outline: none;
- caret-color: transparent;
- &:focus {
- border-bottom: 2px solid #ffffff;
- box-shadow: none;
- }
- }
- .code-error-text {
- max-width: 110px;
- line-height: 1.2;
- }
- .address-row {
- display: grid;
- grid-template-columns: auto 1fr auto;
- align-items: center;
- gap: 4px;
- }
- .address-text {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- min-width: 0;
- }
- .code-pill {
- color: white;
- letter-spacing: 2px;
- border-radius: 20px;
- padding: 1px 8px;
- }
- .rate-btn {
- background: #EC4899;
- color: white;
- white-space: nowrap;
- border-radius: 10px !important;
- min-width: 55px;
- min-height: 50px;
- padding: 3px 5px;
- gap: 4px;
- }
- .reviewed-badge {
- cursor: default;
- opacity: 0.85;
- }
- .progress-track {
- width: 100%;
- height: 8px;
- background: #E2E8F0;
- border-radius: 4px;
- overflow: hidden;
- }
- .progress-fill {
- height: 100%;
- border-radius: 4px;
- position: relative;
- overflow: hidden;
- background: linear-gradient(90deg, #8B5CF6, #EC4899);
- transition: width 0.4s ease;
- &--finished {
- background: #22c55e;
- }
- }
- .progress-fill::after {
- content: "";
- position: absolute;
- inset: 0;
- left: -40%;
- width: 40%;
- background: linear-gradient(
- 90deg,
- transparent,
- rgba(255,255,255,.35),
- transparent
- );
- transform: skewX(-20deg);
- animation: shine 2s linear infinite;
- }
- .progress-fill--finished::after {
- display: none;
- }
- @keyframes shine {
- from { left: -40%; }
- to { left: 140%; }
- }
- </style>
|