ProfileAvailabilityCalendar.vue 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. <template>
  2. <div class="avail-calendar">
  3. <div class="avail-cal__nav row items-center justify-between q-px-md q-pt-md q-pb-sm q-gutter-x-sm">
  4. <q-btn flat round dense icon="mdi-chevron-left" size="sm" color="text" @click="prevMonth" />
  5. <span class="avail-cal__nav-label month-label">{{ monthLabel }}</span>
  6. <q-btn flat round dense icon="mdi-chevron-right" size="sm" color="text" @click="nextMonth" />
  7. <q-btn flat round dense icon="mdi-chevron-left" size="xs" color="text" @click="prevYear" />
  8. <span class="avail-cal__nav-label year-label">{{ currentYear }}</span>
  9. <q-btn flat round dense icon="mdi-chevron-right" size="xs" color="text" @click="nextYear" />
  10. </div>
  11. <div class="avail-cal__weekdays row q-px-sm q-pb-xs">
  12. <div
  13. v-for="wd in weekdayLabels"
  14. :key="wd"
  15. class="avail-cal__weekday col text-center text-weight-bold"
  16. >
  17. {{ wd }}
  18. </div>
  19. </div>
  20. <div class="avail-cal__grid q-px-sm q-pb-md">
  21. <div
  22. v-for="n in firstDayOffset"
  23. :key="`empty-${n}`"
  24. class="avail-cal__cell"
  25. />
  26. <div
  27. v-for="day in daysInMonth"
  28. :key="day"
  29. class="avail-cal__cell column items-center justify-start"
  30. >
  31. <button
  32. class="avail-cal__day-btn"
  33. :class="{
  34. 'avail-cal__day--today': isToday(day),
  35. 'avail-cal__day--past': isPast(day),
  36. }"
  37. @click="onDayClick(day)"
  38. >
  39. {{ day }}
  40. </button>
  41. <div v-if="hasAnyBlock(day)" class="avail-cal__pill" @click="onDayClick(day)">
  42. <div
  43. class="avail-cal__pill-half avail-cal__pill-left"
  44. :class="morningState(day)"
  45. />
  46. <div
  47. class="avail-cal__pill-half avail-cal__pill-right"
  48. :class="afternoonState(day)"
  49. />
  50. </div>
  51. </div>
  52. </div>
  53. </div>
  54. </template>
  55. <script setup>
  56. import { ref, computed } from 'vue'
  57. import { useI18n } from 'vue-i18n'
  58. const props = defineProps({
  59. blockedDays: {
  60. type: Array,
  61. default: () => [],
  62. },
  63. })
  64. const emit = defineEmits(['date-click', 'navigation'])
  65. const { locale } = useI18n()
  66. const today = new Date()
  67. const currentYear = ref(today.getFullYear())
  68. const currentMonth = ref(today.getMonth() + 1) // 1-12
  69. const weekdayLabels = computed(() => {
  70. const base = new Date(2023, 0, 1)
  71. return Array.from({ length: 7 }, (_, i) => {
  72. const d = new Date(base)
  73. d.setDate(d.getDate() + i)
  74. return d.toLocaleDateString(locale.value || 'pt-BR', { weekday: 'narrow' })
  75. .replace('.', '')
  76. .slice(0, 3)
  77. .toUpperCase()
  78. })
  79. })
  80. const monthLabel = computed(() => {
  81. const d = new Date(currentYear.value, currentMonth.value - 1, 1)
  82. return d.toLocaleDateString(locale.value || 'pt-BR', { month: 'long' })
  83. })
  84. const daysInMonth = computed(() => {
  85. return new Date(currentYear.value, currentMonth.value, 0).getDate()
  86. })
  87. const firstDayOffset = computed(() => {
  88. return new Date(currentYear.value, currentMonth.value - 1, 1).getDay()
  89. })
  90. const prevMonth = () => {
  91. if (currentMonth.value === 1) {
  92. currentMonth.value = 12
  93. currentYear.value--
  94. } else {
  95. currentMonth.value--
  96. }
  97. emitNavigation()
  98. }
  99. const nextMonth = () => {
  100. if (currentMonth.value === 12) {
  101. currentMonth.value = 1
  102. currentYear.value++
  103. } else {
  104. currentMonth.value++
  105. }
  106. emitNavigation()
  107. }
  108. const prevYear = () => {
  109. currentYear.value--
  110. emitNavigation()
  111. }
  112. const nextYear = () => {
  113. currentYear.value++
  114. emitNavigation()
  115. }
  116. const emitNavigation = () => {
  117. emit('navigation', { year: currentYear.value, month: currentMonth.value })
  118. }
  119. const pad = (n) => String(n).padStart(2, '0')
  120. const dateStrForDay = (day) =>
  121. `${currentYear.value}-${pad(currentMonth.value)}-${pad(day)}`
  122. const isToday = (day) => {
  123. return (
  124. day === today.getDate() &&
  125. currentMonth.value === today.getMonth() + 1 &&
  126. currentYear.value === today.getFullYear()
  127. )
  128. }
  129. const isPast = (day) => {
  130. const d = new Date(currentYear.value, currentMonth.value - 1, day)
  131. const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate())
  132. return d < todayStart
  133. }
  134. const blockedPeriodsForDay = (day) => {
  135. const dateStr = dateStrForDay(day)
  136. return props.blockedDays
  137. .filter((bd) => bd.date === dateStr)
  138. .map((bd) => bd.period)
  139. }
  140. const hasAnyBlock = (day) => blockedPeriodsForDay(day).length > 0
  141. const isMorningBlocked = (day) => {
  142. const periods = blockedPeriodsForDay(day)
  143. return periods.includes('morning') || periods.includes('all')
  144. }
  145. const isAfternoonBlocked = (day) => {
  146. const periods = blockedPeriodsForDay(day)
  147. return periods.includes('afternoon') || periods.includes('all')
  148. }
  149. const morningState = (day) => ({
  150. 'pill-half--blocked': isMorningBlocked(day),
  151. 'pill-half--free': !isMorningBlocked(day),
  152. })
  153. const afternoonState = (day) => ({
  154. 'pill-half--blocked': isAfternoonBlocked(day),
  155. 'pill-half--free': !isAfternoonBlocked(day),
  156. })
  157. const onDayClick = (day) => {
  158. const dateStr = `${currentYear.value}/${pad(currentMonth.value)}/${pad(day)}`
  159. emit('date-click', dateStr)
  160. }
  161. </script>
  162. <style scoped lang="scss">
  163. $morning-color: #f916f9;
  164. $afternoon-color: #6366F1;
  165. $blocked-opacity: 1;
  166. $free-opacity: 0.05;
  167. .avail-calendar {
  168. background: #fff;
  169. border-radius: 20px;
  170. overflow: hidden;
  171. user-select: none;
  172. }
  173. /* ── Navegação ───────────────────────────────────────── */
  174. .avail-cal__nav {
  175. background: #fff;
  176. }
  177. .avail-cal__nav-label {
  178. font-weight: 700;
  179. color: #1E293B;
  180. &.month-label {
  181. color: #6366F1;
  182. font-size: 15px;
  183. text-transform: capitalize;
  184. }
  185. &.year-label {
  186. color: #6366F1;
  187. font-size: 15px;
  188. }
  189. }
  190. /* ── Cabeçalho weekdays ──────────────────────────────── */
  191. .avail-cal__weekdays {
  192. display: grid;
  193. grid-template-columns: repeat(7, 1fr);
  194. }
  195. .avail-cal__weekday {
  196. font-size: 11px;
  197. color: #6366F1;
  198. opacity: 0.8;
  199. padding: 4px 0;
  200. }
  201. /* ── Grid de dias ────────────────────────────────────── */
  202. .avail-cal__grid {
  203. display: grid;
  204. grid-template-columns: repeat(7, 1fr);
  205. gap: 0;
  206. }
  207. .avail-cal__cell {
  208. display: flex;
  209. flex-direction: column;
  210. align-items: center;
  211. padding: 2px 0 6px;
  212. }
  213. /* ── Botão do número ─────────────────────────────────── */
  214. .avail-cal__day-btn {
  215. width: 32px;
  216. height: 32px;
  217. border: none;
  218. background: transparent;
  219. border-radius: 50%;
  220. font-family: 'Inter', sans-serif;
  221. font-size: 13px;
  222. font-weight: 500;
  223. color: #1E293B;
  224. cursor: pointer;
  225. display: flex;
  226. align-items: center;
  227. justify-content: center;
  228. transition: background 0.15s;
  229. &:hover {
  230. background: #f1f5f9;
  231. }
  232. &.avail-cal__day--today {
  233. color: #7c4dff;
  234. background: rgba(124, 77, 255, 0.08);
  235. }
  236. &.avail-cal__day--past {
  237. color: #CBD5E1;
  238. cursor: default;
  239. pointer-events: none;
  240. }
  241. }
  242. /* ── Pílula ──────────────────────────────────────────── */
  243. .avail-cal__pill {
  244. display: flex;
  245. width: 28px;
  246. height: 9px;
  247. border-radius: 999px;
  248. overflow: hidden;
  249. cursor: pointer;
  250. margin-top: 2px;
  251. }
  252. .avail-cal__pill-half {
  253. flex: 1;
  254. transition: opacity 0.2s, background-color 0.2s;
  255. }
  256. .avail-cal__pill-left {
  257. background-color: $morning-color;
  258. border-radius: 999px 0 0 999px;
  259. &.pill-half--blocked {
  260. opacity: $blocked-opacity;
  261. }
  262. &.pill-half--free {
  263. opacity: $free-opacity;
  264. }
  265. }
  266. .avail-cal__pill-right {
  267. background-color: $afternoon-color;
  268. border-radius: 0 999px 999px 0;
  269. &.pill-half--blocked {
  270. opacity: $blocked-opacity;
  271. }
  272. &.pill-half--free {
  273. opacity: $free-opacity;
  274. }
  275. }
  276. </style>