DashboardTodayServices.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. <template>
  2. <div
  3. v-if="visibleItems.length > 0"
  4. class="q-mx-md q-mb-md"
  5. >
  6. <div class="scroll-wrapper">
  7. <div
  8. class="scroll-track"
  9. :class="{ 'scroll-track--single': visibleItems.length === 1 }"
  10. >
  11. <q-card
  12. v-for="item in visibleItems"
  13. :key="item.id"
  14. :class="{ 'today-card--single': visibleItems.length === 1 }"
  15. :flat="false"
  16. class="today-card card-border shadow-card bg-surface"
  17. >
  18. <q-card-section class="column justify-between q-pa-sm full-height">
  19. <div class="row no-wrap items-center q-mb-xs">
  20. <div class="col-8 row">
  21. <q-avatar size="40px" class="flex-shrink-0 q-mr-sm">
  22. <img
  23. v-if="item.customer_photo"
  24. :src="item.customer_photo"
  25. style="object-fit:cover"
  26. />
  27. <span
  28. v-else
  29. :style="avatarColors[item.id % avatarColors.length]"
  30. class="full-width full-height flex flex-center"
  31. style="border-radius:50%;"
  32. >
  33. {{ getFirstName(item.client_name)?.slice(0, 2).toUpperCase() ?? '??' }}
  34. </span>
  35. </q-avatar>
  36. <div class="col column no-wrap overflow-hidden justify-center">
  37. <span class="text-text font12">
  38. <template v-if="cardState(item) === 'finished'">
  39. {{ $t('provider.dashboard.today_services.finished_label') }}
  40. <span class="fontbold">
  41. {{ ' ' + (getFirstName(item.client_name) || '—') }}
  42. </span>
  43. {{ ' ' + $t('provider.dashboard.today_services.finished_suffix') }}
  44. </template>
  45. <template v-else>
  46. {{ $t('provider.dashboard.today_services.start_label') }}
  47. <span class="fontbold">
  48. {{ ' ' + (getFirstName(item.client_name) || '—') }}
  49. </span>
  50. </template>
  51. </span>
  52. <div class="row items-center q-mt-xs no-wrap">
  53. <q-icon
  54. class="q-mr-xs gradient-diarista"
  55. name="mdi-clock-outline"
  56. size="12px"
  57. />
  58. <span class="text-grey-5 font9 fontregular">
  59. {{ $t('common.from') }} {{ item.start_time?.slice(0, 5) }} {{ $t('common.to') }} {{ item.end_time?.slice(0, 5) }}
  60. </span>
  61. </div>
  62. </div>
  63. </div>
  64. <div class="flex-shrink-0 row items-right justify-end col-4">
  65. <div
  66. v-if="cardState(item) === 'awaiting_code'"
  67. class="col-12 row items-center justify-end q-pb-sm"
  68. >
  69. <div class="hint-text text-text text-center font9 q-mb-xs col-12">
  70. {{ $t('provider.dashboard.today_services.code_hint') }}
  71. </div>
  72. <div class="code-input-row col-12">
  73. <input
  74. v-for="(_, idx) in 4"
  75. :key="idx"
  76. :ref="el => setCodeRef(item.id, idx, el)"
  77. v-model="codeInputs[item.id][idx]"
  78. class="code-input-box font9 fontbold"
  79. inputmode="numeric"
  80. maxlength="1"
  81. type="tel"
  82. @input="onCodeInput(item, idx)"
  83. @keydown.delete="onCodeDelete(item.id, idx)"
  84. @paste.prevent="onCodePaste(item, $event)"
  85. />
  86. </div>
  87. <div v-if="codeError[item.id]" class="text-negative code-error-text font9 q-mt-xs text-center">
  88. {{ codeError[item.id] }}
  89. </div>
  90. </div>
  91. <div
  92. v-else-if="cardState(item) === 'in_progress'"
  93. class="col-12 text-center q-mb-xs"
  94. >
  95. <div class="column items-center">
  96. <span class="text-text font9 fontmedium q-my-xs">
  97. {{ $t('provider.dashboard.today_services.in_progress') }}
  98. </span>
  99. <div class="code-pill font12 fontbold bg-positive row">
  100. <q-icon
  101. class="q-mr-xs q-my-auto"
  102. name="mdi-check"
  103. size="12px"
  104. />
  105. {{ item.code || localVerified[item.id] }}
  106. </div>
  107. </div>
  108. </div>
  109. <div v-else class="">
  110. <div
  111. v-if="item.provider_reviewed"
  112. class="rate-btn reviewed-badge column fontsemibold"
  113. >
  114. <div class="text-center">
  115. <q-icon
  116. class="q-mr-xs"
  117. name="mdi-star"
  118. size="14px"
  119. />
  120. </div>
  121. {{ $t('provider.dashboard.today_services.reviewed_badge') }}
  122. </div>
  123. <div v-else class="">
  124. <q-btn
  125. class="rate-btn font10 fontmedium"
  126. no-caps
  127. unelevated
  128. @click.stop="emit('rate', item)"
  129. >
  130. <div class="column items-center">
  131. <q-icon
  132. class="q-mr-xs"
  133. name="mdi-star-outline"
  134. size="14px"
  135. />
  136. {{ $t('provider.dashboard.today_services.rate_btn') }}
  137. </div>
  138. </q-btn>
  139. </div>
  140. </div>
  141. </div>
  142. </div>
  143. <div class="progress-track q-mb-xs">
  144. <div
  145. class="progress-fill"
  146. :class="cardState(item) === 'finished' ? 'progress-fill--finished' : ''"
  147. :style="{ width: progressByState(item) + '%' }"
  148. />
  149. </div>
  150. <template v-if="cardState(item) === 'in_progress'">
  151. <div class="row items-center no-wrap">
  152. <q-icon
  153. class="q-mr-xs flex-shrink-0 gradient-diarista"
  154. name="mdi-clock-outline"
  155. size="13px"
  156. />
  157. <span class="text-grey-6 text-right text-no-wrap font9 fontmedium">
  158. {{ $t('provider.dashboard.today_services.end_time_label') }}
  159. <span class="gradient-diarista">
  160. {{ calculateTimeUntilEnd(item.end_time) }}
  161. </span>
  162. </span>
  163. </div>
  164. </template>
  165. <template v-else-if="cardState(item) === 'awaiting_code'">
  166. <div class="address-row">
  167. <q-icon
  168. class="flex-shrink-0"
  169. color="grey-6"
  170. name="mdi-map-marker-outline"
  171. size="13px"
  172. />
  173. <span class="text-grey-6 address-text text-right font9 fontmedium">
  174. {{ [item.address?.address, item.address?.number, item.address?.district].filter(Boolean).join(', ') || '—' }}
  175. </span>
  176. <q-icon
  177. class="flex-shrink-0 q-ml-sm"
  178. color="primary"
  179. name="mdi-content-copy"
  180. size="16px"
  181. @click="copyAddress(item.address)"
  182. />
  183. </div>
  184. </template>
  185. <div class="row items-center no-wrap">
  186. <q-btn
  187. class="flex-shrink-0"
  188. color="primary"
  189. flat no-caps dense
  190. size="sm"
  191. :label="$t('provider.dashboard.today_services.help')"
  192. @click.stop="openHelp"
  193. />
  194. </div>
  195. </q-card-section>
  196. </q-card>
  197. </div>
  198. </div>
  199. </div>
  200. </template>
  201. <script setup>
  202. import { avatarColors, formatAddress, getFirstName } from 'src/helpers/utils';
  203. import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
  204. import { useI18n } from 'vue-i18n'
  205. import { useQuasar } from 'quasar'
  206. import { verifyScheduleCode } from 'src/api/schedule'
  207. import ProfileHelpDialog from 'src/components/profile/ProfileHelpDialog.vue'
  208. const props = defineProps({ data: { type: Array, default: () => [] } })
  209. const emit = defineEmits(['rate', 'refresh'])
  210. const { t } = useI18n()
  211. const $q = useQuasar()
  212. const now = ref(Date.now())
  213. let timer = null
  214. onMounted(() => {
  215. timer = setInterval(() => { now.value = Date.now() }, 1000)
  216. })
  217. onUnmounted(() => {
  218. if (timer) clearInterval(timer)
  219. })
  220. const codeInputs = reactive({})
  221. const codeRefs = reactive({})
  222. const codeError = reactive({})
  223. const localVerified = reactive({})
  224. const ensureCode = (id) => {
  225. if (!codeInputs[id]) codeInputs[id] = ['', '', '', '']
  226. if (!codeRefs[id]) codeRefs[id] = [null, null, null, null]
  227. }
  228. props.data.forEach(item => ensureCode(item.id))
  229. const setCodeRef = (id, idx, el) => {
  230. ensureCode(id)
  231. codeRefs[id][idx] = el
  232. }
  233. //
  234. const onCodeInput = async (item, idx) => {
  235. const id = item.id
  236. const val = codeInputs[id][idx]
  237. if (!/^\d$/.test(val)) { codeInputs[id][idx] = ''; return }
  238. codeError[id] = null
  239. if (idx < 3) {
  240. codeRefs[id][idx + 1]?.focus()
  241. } else {
  242. await submitCode(item)
  243. }
  244. }
  245. const onCodeDelete = (id, idx) => {
  246. if (codeInputs[id][idx] === '' && idx > 0) {
  247. codeInputs[id][idx - 1] = ''
  248. codeRefs[id][idx - 1]?.focus()
  249. }
  250. }
  251. const onCodePaste = async (item, e) => {
  252. const text = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 4)
  253. if (text.length !== 4) return
  254. const id = item.id
  255. text.split('').forEach((ch, i) => { codeInputs[id][i] = ch })
  256. await submitCode(item)
  257. }
  258. const submitCode = async (item) => {
  259. const id = item.id
  260. const code = codeInputs[id].join('')
  261. if (code.length < 4) return
  262. try {
  263. const response = await verifyScheduleCode(id, code)
  264. if (response?.payload) {
  265. localVerified[id] = code
  266. emit('refresh')
  267. } else {
  268. const msg = response?.data?.message || response?.message || t('provider.dashboard.today_services.code_error')
  269. codeError[id] = msg
  270. codeInputs[id] = ['', '', '', '']
  271. codeRefs[id][0]?.focus()
  272. }
  273. } catch (err) {
  274. const msg = err?.response?.data?.message || t('provider.dashboard.today_services.code_error')
  275. codeError[id] = msg
  276. codeInputs[id] = ['', '', '', '']
  277. codeRefs[id][0]?.focus()
  278. }
  279. }
  280. //
  281. const calculateTimeUntilEnd = (endTimeStr) => {
  282. const [h, m] = (endTimeStr || '23:59').slice(0, 5).split(':').map(Number)
  283. const endTime = new Date()
  284. endTime.setHours(h, m, 0, 0)
  285. const diffMs = endTime.getTime() - now.value
  286. if (diffMs <= 0) return '00:00:00'
  287. const diffH = Math.floor(diffMs / (1000 * 60 * 60))
  288. const diffM = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
  289. const diffS = Math.floor((diffMs % (1000 * 60)) / 1000)
  290. return `${String(diffH).padStart(2, '0')}:${String(diffM).padStart(2, '0')}:${String(diffS).padStart(2, '0')}`
  291. }
  292. const cardState = (item) => {
  293. const verified = item.code_verified || !!localVerified[item.id]
  294. if (!verified) return 'awaiting_code'
  295. const [h, m] = (item.end_time || '23:59').slice(0, 5).split(':').map(Number)
  296. const endTime = new Date()
  297. endTime.setHours(h, m, 0, 0)
  298. return now.value >= endTime.getTime() ? 'finished' : 'in_progress'
  299. }
  300. const visibleItems = computed(() =>
  301. props.data.filter((item) => !item.provider_reviewed)
  302. )
  303. const copyAddress = (address) => {
  304. const formatted = formatAddress(address);
  305. if (formatted) {
  306. navigator.clipboard.writeText(formatted);
  307. }
  308. $q.notify({ message: t('provider.dashboard.next_schedules.address_copied'), color: 'positive' });
  309. };
  310. //
  311. const progressByState = (item) => {
  312. const state = cardState(item)
  313. if (state === 'awaiting_code') return 50
  314. if (state === 'finished') return 100
  315. const [sh, sm] = (item.start_time || '00:00').slice(0, 5).split(':').map(Number)
  316. const [eh, em] = (item.end_time || '23:59').slice(0, 5).split(':').map(Number)
  317. const startTime = new Date()
  318. startTime.setHours(sh, sm, 0, 0)
  319. const endTime = new Date()
  320. endTime.setHours(eh, em, 0, 0)
  321. const total = endTime.getTime() - startTime.getTime()
  322. if (total <= 0) return 80
  323. const elapsed = now.value - startTime.getTime()
  324. return Math.min(Math.max((elapsed / total) * 100, 10), 100)
  325. }
  326. //
  327. const openHelp = () => {
  328. $q.dialog({ component: ProfileHelpDialog })
  329. }
  330. </script>
  331. <style scoped lang="scss">
  332. .scroll-wrapper { overflow: hidden; }
  333. .scroll-track {
  334. display: flex;
  335. flex-direction: row;
  336. gap: 12px;
  337. overflow-x: auto;
  338. overscroll-behavior-x: contain;
  339. scroll-snap-type: x proximity;
  340. padding-bottom: 8px;
  341. &::-webkit-scrollbar { display: none; }
  342. &::after { content: ''; flex: 0 0 1px; }
  343. }
  344. .today-card {
  345. min-width: 82vw;
  346. scroll-snap-align: start;
  347. border-radius: 12px;
  348. }
  349. .today-card--single {
  350. min-width: 100%;
  351. width: 100%;
  352. }
  353. .hint-text {
  354. max-width: 100px;
  355. line-height: 1.3;
  356. }
  357. .code-input-row {
  358. display: flex;
  359. align-items: center;
  360. justify-content: center;
  361. gap: 4px;
  362. background: #d1d5db;
  363. border-radius: 999px;
  364. padding: 4px 8px;
  365. width: 110px;
  366. height: 28px;
  367. margin: 0;
  368. }
  369. .code-input-box {
  370. width: 12px;
  371. height: 18px;
  372. background: transparent;
  373. border: none;
  374. border-bottom: 1px solid white;
  375. border-radius: 0;
  376. text-align: center;
  377. color: #1a1a2e;
  378. outline: none;
  379. caret-color: transparent;
  380. &:focus {
  381. border-bottom: 2px solid #ffffff;
  382. box-shadow: none;
  383. }
  384. }
  385. .code-error-text {
  386. max-width: 110px;
  387. line-height: 1.2;
  388. }
  389. .address-row {
  390. display: grid;
  391. grid-template-columns: auto 1fr auto;
  392. align-items: center;
  393. gap: 4px;
  394. }
  395. .address-text {
  396. overflow: hidden;
  397. white-space: nowrap;
  398. text-overflow: ellipsis;
  399. min-width: 0;
  400. }
  401. .code-pill {
  402. color: white;
  403. letter-spacing: 2px;
  404. border-radius: 20px;
  405. padding: 1px 8px;
  406. }
  407. .rate-btn {
  408. background: #EC4899;
  409. color: white;
  410. white-space: nowrap;
  411. border-radius: 10px !important;
  412. min-width: 55px;
  413. min-height: 50px;
  414. padding: 3px 5px;
  415. gap: 4px;
  416. }
  417. .reviewed-badge {
  418. cursor: default;
  419. opacity: 0.85;
  420. }
  421. .progress-track {
  422. width: 100%;
  423. height: 8px;
  424. background: #E2E8F0;
  425. border-radius: 4px;
  426. overflow: hidden;
  427. }
  428. .progress-fill {
  429. height: 100%;
  430. border-radius: 4px;
  431. position: relative;
  432. overflow: hidden;
  433. background: linear-gradient(90deg, #8B5CF6, #EC4899);
  434. transition: width 0.4s ease;
  435. &--finished {
  436. background: #22c55e;
  437. }
  438. }
  439. .progress-fill::after {
  440. content: "";
  441. position: absolute;
  442. inset: 0;
  443. left: -40%;
  444. width: 40%;
  445. background: linear-gradient(
  446. 90deg,
  447. transparent,
  448. rgba(255,255,255,.35),
  449. transparent
  450. );
  451. transform: skewX(-20deg);
  452. animation: shine 2s linear infinite;
  453. }
  454. .progress-fill--finished::after {
  455. display: none;
  456. }
  457. @keyframes shine {
  458. from { left: -40%; }
  459. to { left: 140%; }
  460. }
  461. </style>