DashboardPendingSchedules.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. <template>
  2. <div class="q-mx-md q-mb-md">
  3. <div class="scroll-wrapper">
  4. <div
  5. ref="trackRef"
  6. class="scroll-track"
  7. @scroll="onTrackScroll"
  8. >
  9. <q-card
  10. v-for="item in data"
  11. :key="item.id"
  12. class="pending-card card-border shadow-card bg-surface"
  13. :flat="false"
  14. @click="seeDetails(item)"
  15. >
  16. <q-card-section class="card-body q-pa-md">
  17. <div class="row no-wrap items-start q-mb-sm">
  18. <q-avatar
  19. class="q-mr-sm flex-shrink-0"
  20. size="40px"
  21. :style="avatarColors[item.id % avatarColors.length]"
  22. >
  23. <img
  24. v-if="providerPhoto(item)"
  25. style="object-fit: cover; border-radius: 50%"
  26. :src="providerPhoto(item)"
  27. />
  28. <span v-else>
  29. {{
  30. providerName(item).slice(0, 2).toUpperCase() || "??"
  31. }}
  32. </span>
  33. </q-avatar>
  34. <div class="col column no-wrap overflow-hidden">
  35. <span class="font12 fontmedium text-text">
  36. <span v-if="type === 'servicePackage'">
  37. {{
  38. $t("dashboard_client.pending_schedules.pay_to_provider")
  39. }}
  40. </span>
  41. <span v-else-if="item.status == 'pending'">
  42. {{
  43. $t("dashboard_client.pending_schedules.requesting_with")
  44. }}
  45. </span>
  46. <span v-else-if="item.status == 'accepted'">
  47. {{
  48. $t("dashboard_client.pending_schedules.pay_to_provider")
  49. }}
  50. </span>
  51. <span class="font12 fontbold">
  52. {{ " " + (getFirstName(providerName(item)) || "—") }}</span
  53. >
  54. </span>
  55. <div class="row items-center q-mt-xs">
  56. <q-icon
  57. class="q-mr-xs"
  58. color="grey-5"
  59. name="mdi-clock-outline"
  60. size="13px"
  61. />
  62. <span class="font9 fontmedium text-grey-5 meta-text ellipsis">
  63. {{ requestStatusText(item) }}
  64. </span>
  65. </div>
  66. </div>
  67. <div
  68. class="clock-badge-col q-ml-sm flex-shrink-0 column items-center"
  69. >
  70. <div class="clock-badge">
  71. <q-icon
  72. color="white"
  73. name="mdi-clock-outline"
  74. size="14px"
  75. />
  76. </div>
  77. <span
  78. class="font10 fontmedium text-primary q-mt-xs badge-status-text"
  79. >
  80. {{
  81. type === 'servicePackage'
  82. ? $t("dashboard_client.pending_schedules.status.accepted")
  83. : $t(
  84. `dashboard_client.pending_schedules.status.${item.status ?? "pending"}`,
  85. )
  86. }}
  87. </span>
  88. </div>
  89. </div>
  90. <div class="progress-track q-mb-md">
  91. <div
  92. class="progress-fill"
  93. :class="progressClass"
  94. />
  95. </div>
  96. <div class="schedule-info">
  97. <div class="schedule-info-item">
  98. <q-icon
  99. name="mdi-calendar-outline"
  100. size="14px"
  101. />
  102. <span>{{ formatNumericDate(scheduleFor(item)?.date) || "—" }}</span>
  103. </div>
  104. <div class="schedule-info-item">
  105. <q-icon
  106. name="mdi-clock-outline"
  107. size="14px"
  108. />
  109. <span>
  110. {{
  111. formatTimeRange(
  112. scheduleFor(item)?.start_time,
  113. scheduleFor(item)?.end_time,
  114. ) || "—"
  115. }}
  116. </span>
  117. </div>
  118. </div>
  119. <div class="card-footer row items-center no-wrap">
  120. <q-btn
  121. v-if="type !== 'servicePackage'"
  122. class="q-mr-sm flex-shrink-0"
  123. color="primary"
  124. dense
  125. flat
  126. no-caps
  127. size="xs"
  128. :label="$t('dashboard_client.pending_schedules.cancel_btn')"
  129. @click.stop="$emit('cancel', item)"
  130. />
  131. <q-space />
  132. <span
  133. class="font9 fontregular text-grey-6 col ellipsis text-right"
  134. >
  135. <q-icon
  136. class="q-mr-xs flex-shrink-0"
  137. color="grey-6"
  138. name="mdi-map-marker-outline"
  139. size="13px"
  140. />
  141. {{
  142. formatAddress(addressFor(item)) || "—"
  143. }}
  144. </span>
  145. </div>
  146. </q-card-section>
  147. </q-card>
  148. </div>
  149. </div>
  150. <DefaultCarouselIndicator
  151. :active="activeIndex"
  152. :count="pageCount"
  153. />
  154. </div>
  155. </template>
  156. <script setup>
  157. import { avatarColors } from "src/helpers/avatarColors";
  158. import { computed } from "vue";
  159. import { formatAddress, getFirstName } from "src/helpers/utils";
  160. import { formatNumericDate, formatTimeRange } from "src/helpers/scheduleDate";
  161. import { useCarouselIndicator } from "src/composables/useCarouselIndicator";
  162. import { useI18n } from "vue-i18n";
  163. import DefaultCarouselIndicator from "src/components/defaults/DefaultCarouselIndicator.vue";
  164. const emit = defineEmits(["view-details", "cancel"]);
  165. const props = defineProps({
  166. data: { type: Array, default: () => [] },
  167. type: { type: String, default: 'schedule' },
  168. progressClass: { type: String, default: '' },
  169. });
  170. const { t } = useI18n();
  171. const { activeIndex, onTrackScroll, pageCount, trackRef } =
  172. useCarouselIndicator(() => props.data);
  173. const isServicePackage = computed(() => props.type === 'servicePackage');
  174. const addressFor = (item) => {
  175. const address = isServicePackage.value
  176. ? item.schedules?.[0]?.address
  177. : item.address;
  178. if (!address) return null;
  179. return {
  180. address: address.address,
  181. district: address.district,
  182. number: address.number,
  183. };
  184. };
  185. const providerName = (item) => {
  186. if (isServicePackage.value) {
  187. return item.provider?.user?.name ?? item.provider_name ?? '';
  188. }
  189. return item.provider_name ?? '';
  190. };
  191. const providerPhoto = (item) => {
  192. if (isServicePackage.value) {
  193. return item.provider?.profile_media?.url ?? item.provider_photo ?? null;
  194. }
  195. return item.provider_photo;
  196. };
  197. const scheduleCountLabel = (count) =>
  198. t("dashboard_client.pending_schedules.schedule_count", { count });
  199. const requestStatusText = (item) => {
  200. if (isServicePackage.value) {
  201. return item.schedules?.length ? scheduleCountLabel(item.schedules.length) : "—";
  202. }
  203. if (!item.time_since_request) return "—";
  204. const timeAgo = t("dashboard_client.pending_schedules.time_ago", {
  205. time: item.time_since_request,
  206. });
  207. const packageCount = Number(item.service_package_items_count ?? 0);
  208. return packageCount > 1
  209. ? `${timeAgo} · ${scheduleCountLabel(packageCount)}`
  210. : timeAgo;
  211. };
  212. const scheduleFor = (item) =>
  213. isServicePackage.value ? item.schedules?.[0] : item;
  214. const seeDetails = (item) => {
  215. emit("view-details", item);
  216. };
  217. </script>
  218. <style scoped lang="scss">
  219. .scroll-wrapper {
  220. overflow: hidden;
  221. }
  222. .scroll-track {
  223. display: flex;
  224. flex-direction: row;
  225. gap: 12px;
  226. overflow-x: auto;
  227. overscroll-behavior-x: contain;
  228. scroll-snap-type: x mandatory;
  229. padding-bottom: 8px;
  230. &::-webkit-scrollbar {
  231. display: none;
  232. }
  233. }
  234. .pending-card {
  235. flex: 0 0 100%;
  236. width: 100%;
  237. min-height: 146px;
  238. display: flex;
  239. scroll-snap-align: center;
  240. }
  241. .card-body {
  242. flex: 1;
  243. display: flex;
  244. flex-direction: column;
  245. width: 100%;
  246. }
  247. .clock-badge-col {
  248. flex: 0 0 68px;
  249. width: 68px;
  250. align-items: center;
  251. }
  252. .meta-text {
  253. white-space: nowrap;
  254. }
  255. .card-footer {
  256. min-height: 24px;
  257. }
  258. .clock-badge {
  259. width: 24px;
  260. height: 24px;
  261. border-radius: 50%;
  262. background: linear-gradient(135deg, #8b5cf6, #ec4899);
  263. display: flex;
  264. align-items: center;
  265. justify-content: center;
  266. }
  267. .badge-status-text {
  268. white-space: nowrap;
  269. }
  270. .progress-track {
  271. width: 100%;
  272. height: 6px;
  273. margin-top: auto;
  274. background: #e2e8f0;
  275. border-radius: 999px;
  276. overflow: hidden;
  277. }
  278. .schedule-info {
  279. display: flex;
  280. flex-direction: column;
  281. align-items: flex-start;
  282. gap: 4px;
  283. width: 100%;
  284. margin: 5px 0 4px;
  285. padding: 0 2px;
  286. color: #475569;
  287. font-size: 10px;
  288. font-weight: 500;
  289. }
  290. .schedule-info-item {
  291. display: flex;
  292. align-items: center;
  293. gap: 5px;
  294. white-space: nowrap;
  295. .q-icon {
  296. color: #64748b;
  297. }
  298. }
  299. .progress-fill {
  300. position: relative;
  301. height: 100%;
  302. width: 0;
  303. border-radius: inherit;
  304. background: linear-gradient(
  305. 90deg,
  306. #3b5cff,
  307. #7a4dff,
  308. #d94cff
  309. );
  310. overflow: hidden;
  311. }
  312. .progress-fill::after {
  313. content: "";
  314. position: absolute;
  315. top: 0;
  316. bottom: 0;
  317. left: -35%;
  318. width: 35%;
  319. background: linear-gradient(
  320. 90deg,
  321. transparent,
  322. rgba(255,255,255,.45),
  323. transparent
  324. );
  325. transform: skewX(-20deg);
  326. animation: progressShine 2s linear infinite;
  327. }
  328. @keyframes progressShine {
  329. from {
  330. left: -35%;
  331. }
  332. to {
  333. left: 135%;
  334. }
  335. }
  336. .progress-fill--loop {
  337. background: #ec4899;
  338. animation: progressLoopFill 3s ease-in-out infinite;
  339. }
  340. .progress-fill--loop::after {
  341. display: none;
  342. }
  343. @keyframes progressLoopFill {
  344. 0% {
  345. width: 0;
  346. }
  347. 80% {
  348. width: 100%;
  349. }
  350. 100% {
  351. width: 100%;
  352. }
  353. }
  354. .progress-fill--urgent {
  355. width: 100%;
  356. animation: progressUrgentPulse 1.2s ease-in-out infinite;
  357. }
  358. @keyframes progressUrgentPulse {
  359. 0%,
  360. 100% {
  361. opacity: 1;
  362. }
  363. 50% {
  364. opacity: 0.2;
  365. }
  366. }
  367. @media (prefers-reduced-motion: reduce) {
  368. .progress-fill,
  369. .progress-fill::after {
  370. animation: none;
  371. }
  372. .progress-fill--loop,
  373. .progress-fill--urgent {
  374. width: 100%;
  375. opacity: 1;
  376. }
  377. }
  378. </style>