|
|
@@ -0,0 +1,328 @@
|
|
|
+<template>
|
|
|
+ <div class="avail-calendar">
|
|
|
+
|
|
|
+ <div class="avail-cal__nav row items-center justify-between q-px-md q-pt-md q-pb-sm q-gutter-x-sm">
|
|
|
+ <q-btn flat round dense icon="mdi-chevron-left" size="sm" color="text" @click="prevMonth" />
|
|
|
+ <span class="avail-cal__nav-label month-label">{{ monthLabel }}</span>
|
|
|
+ <q-btn flat round dense icon="mdi-chevron-right" size="sm" color="text" @click="nextMonth" />
|
|
|
+
|
|
|
+ <q-btn flat round dense icon="mdi-chevron-left" size="xs" color="text" @click="prevYear" />
|
|
|
+ <span class="avail-cal__nav-label year-label">{{ currentYear }}</span>
|
|
|
+ <q-btn flat round dense icon="mdi-chevron-right" size="xs" color="text" @click="nextYear" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="avail-cal__weekdays row q-px-sm q-pb-xs">
|
|
|
+ <div
|
|
|
+ v-for="wd in weekdayLabels"
|
|
|
+ :key="wd"
|
|
|
+ class="avail-cal__weekday col text-center text-weight-bold"
|
|
|
+ >
|
|
|
+ {{ wd }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="avail-cal__grid q-px-sm q-pb-md">
|
|
|
+ <div
|
|
|
+ v-for="n in firstDayOffset"
|
|
|
+ :key="`empty-${n}`"
|
|
|
+ class="avail-cal__cell"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-for="day in daysInMonth"
|
|
|
+ :key="day"
|
|
|
+ class="avail-cal__cell column items-center justify-start"
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ class="avail-cal__day-btn"
|
|
|
+ :class="{
|
|
|
+ 'avail-cal__day--today': isToday(day),
|
|
|
+ 'avail-cal__day--past': isPast(day),
|
|
|
+ }"
|
|
|
+ @click="onDayClick(day)"
|
|
|
+ >
|
|
|
+ {{ day }}
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <div v-if="hasAnyBlock(day)" class="avail-cal__pill" @click="onDayClick(day)">
|
|
|
+ <div
|
|
|
+ class="avail-cal__pill-half avail-cal__pill-left"
|
|
|
+ :class="morningState(day)"
|
|
|
+ />
|
|
|
+ <div
|
|
|
+ class="avail-cal__pill-half avail-cal__pill-right"
|
|
|
+ :class="afternoonState(day)"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, computed } from 'vue'
|
|
|
+import { useI18n } from 'vue-i18n'
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ blockedDays: {
|
|
|
+ type: Array,
|
|
|
+ default: () => [],
|
|
|
+ },
|
|
|
+})
|
|
|
+
|
|
|
+const emit = defineEmits(['date-click', 'navigation'])
|
|
|
+
|
|
|
+const { locale } = useI18n()
|
|
|
+
|
|
|
+const today = new Date()
|
|
|
+const currentYear = ref(today.getFullYear())
|
|
|
+const currentMonth = ref(today.getMonth() + 1) // 1-12
|
|
|
+
|
|
|
+const weekdayLabels = computed(() => {
|
|
|
+ const base = new Date(2023, 0, 1)
|
|
|
+ return Array.from({ length: 7 }, (_, i) => {
|
|
|
+ const d = new Date(base)
|
|
|
+ d.setDate(d.getDate() + i)
|
|
|
+ return d.toLocaleDateString(locale.value || 'pt-BR', { weekday: 'narrow' })
|
|
|
+ .replace('.', '')
|
|
|
+ .slice(0, 3)
|
|
|
+ .toUpperCase()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+const monthLabel = computed(() => {
|
|
|
+ const d = new Date(currentYear.value, currentMonth.value - 1, 1)
|
|
|
+ return d.toLocaleDateString(locale.value || 'pt-BR', { month: 'long' })
|
|
|
+})
|
|
|
+
|
|
|
+const daysInMonth = computed(() => {
|
|
|
+ return new Date(currentYear.value, currentMonth.value, 0).getDate()
|
|
|
+})
|
|
|
+
|
|
|
+const firstDayOffset = computed(() => {
|
|
|
+ return new Date(currentYear.value, currentMonth.value - 1, 1).getDay()
|
|
|
+})
|
|
|
+
|
|
|
+const prevMonth = () => {
|
|
|
+ if (currentMonth.value === 1) {
|
|
|
+ currentMonth.value = 12
|
|
|
+ currentYear.value--
|
|
|
+ } else {
|
|
|
+ currentMonth.value--
|
|
|
+ }
|
|
|
+ emitNavigation()
|
|
|
+}
|
|
|
+
|
|
|
+const nextMonth = () => {
|
|
|
+ if (currentMonth.value === 12) {
|
|
|
+ currentMonth.value = 1
|
|
|
+ currentYear.value++
|
|
|
+ } else {
|
|
|
+ currentMonth.value++
|
|
|
+ }
|
|
|
+ emitNavigation()
|
|
|
+}
|
|
|
+
|
|
|
+const prevYear = () => {
|
|
|
+ currentYear.value--
|
|
|
+ emitNavigation()
|
|
|
+}
|
|
|
+
|
|
|
+const nextYear = () => {
|
|
|
+ currentYear.value++
|
|
|
+ emitNavigation()
|
|
|
+}
|
|
|
+
|
|
|
+const emitNavigation = () => {
|
|
|
+ emit('navigation', { year: currentYear.value, month: currentMonth.value })
|
|
|
+}
|
|
|
+
|
|
|
+const pad = (n) => String(n).padStart(2, '0')
|
|
|
+
|
|
|
+const dateStrForDay = (day) =>
|
|
|
+ `${currentYear.value}-${pad(currentMonth.value)}-${pad(day)}`
|
|
|
+
|
|
|
+const isToday = (day) => {
|
|
|
+ return (
|
|
|
+ day === today.getDate() &&
|
|
|
+ currentMonth.value === today.getMonth() + 1 &&
|
|
|
+ currentYear.value === today.getFullYear()
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+const isPast = (day) => {
|
|
|
+ const d = new Date(currentYear.value, currentMonth.value - 1, day)
|
|
|
+ const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
|
|
+ return d < todayStart
|
|
|
+}
|
|
|
+
|
|
|
+const blockedPeriodsForDay = (day) => {
|
|
|
+ const dateStr = dateStrForDay(day)
|
|
|
+ return props.blockedDays
|
|
|
+ .filter((bd) => bd.date === dateStr)
|
|
|
+ .map((bd) => bd.period)
|
|
|
+}
|
|
|
+
|
|
|
+const hasAnyBlock = (day) => blockedPeriodsForDay(day).length > 0
|
|
|
+
|
|
|
+const isMorningBlocked = (day) => {
|
|
|
+ const periods = blockedPeriodsForDay(day)
|
|
|
+ return periods.includes('morning') || periods.includes('all')
|
|
|
+}
|
|
|
+
|
|
|
+const isAfternoonBlocked = (day) => {
|
|
|
+ const periods = blockedPeriodsForDay(day)
|
|
|
+ return periods.includes('afternoon') || periods.includes('all')
|
|
|
+}
|
|
|
+
|
|
|
+const morningState = (day) => ({
|
|
|
+ 'pill-half--blocked': isMorningBlocked(day),
|
|
|
+ 'pill-half--free': !isMorningBlocked(day),
|
|
|
+})
|
|
|
+
|
|
|
+const afternoonState = (day) => ({
|
|
|
+ 'pill-half--blocked': isAfternoonBlocked(day),
|
|
|
+ 'pill-half--free': !isAfternoonBlocked(day),
|
|
|
+})
|
|
|
+
|
|
|
+const onDayClick = (day) => {
|
|
|
+ const dateStr = `${currentYear.value}/${pad(currentMonth.value)}/${pad(day)}`
|
|
|
+ emit('date-click', dateStr)
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+$morning-color: #f916f9;
|
|
|
+$afternoon-color: #6366F1;
|
|
|
+$blocked-opacity: 1;
|
|
|
+$free-opacity: 0.05;
|
|
|
+
|
|
|
+.avail-calendar {
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 20px;
|
|
|
+ overflow: hidden;
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+
|
|
|
+/* ── Navegação ───────────────────────────────────────── */
|
|
|
+.avail-cal__nav {
|
|
|
+ background: #fff;
|
|
|
+}
|
|
|
+
|
|
|
+.avail-cal__nav-label {
|
|
|
+ font-weight: 700;
|
|
|
+ color: #1E293B;
|
|
|
+
|
|
|
+ &.month-label {
|
|
|
+ color: #6366F1;
|
|
|
+ font-size: 15px;
|
|
|
+ text-transform: capitalize;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.year-label {
|
|
|
+ color: #6366F1;
|
|
|
+ font-size: 15px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* ── Cabeçalho weekdays ──────────────────────────────── */
|
|
|
+.avail-cal__weekdays {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(7, 1fr);
|
|
|
+}
|
|
|
+
|
|
|
+.avail-cal__weekday {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #6366F1;
|
|
|
+ opacity: 0.8;
|
|
|
+ padding: 4px 0;
|
|
|
+}
|
|
|
+
|
|
|
+/* ── Grid de dias ────────────────────────────────────── */
|
|
|
+.avail-cal__grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(7, 1fr);
|
|
|
+ gap: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.avail-cal__cell {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ padding: 2px 0 6px;
|
|
|
+}
|
|
|
+
|
|
|
+/* ── Botão do número ─────────────────────────────────── */
|
|
|
+.avail-cal__day-btn {
|
|
|
+ width: 32px;
|
|
|
+ height: 32px;
|
|
|
+ border: none;
|
|
|
+ background: transparent;
|
|
|
+ border-radius: 50%;
|
|
|
+ font-family: 'Inter', sans-serif;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #1E293B;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ transition: background 0.15s;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: #f1f5f9;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.avail-cal__day--today {
|
|
|
+ color: #7c4dff;
|
|
|
+ background: rgba(124, 77, 255, 0.08);
|
|
|
+ }
|
|
|
+
|
|
|
+ &.avail-cal__day--past {
|
|
|
+ color: #CBD5E1;
|
|
|
+ cursor: default;
|
|
|
+ pointer-events: none;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* ── Pílula ──────────────────────────────────────────── */
|
|
|
+.avail-cal__pill {
|
|
|
+ display: flex;
|
|
|
+ width: 28px;
|
|
|
+ height: 9px;
|
|
|
+ border-radius: 999px;
|
|
|
+ overflow: hidden;
|
|
|
+ cursor: pointer;
|
|
|
+ margin-top: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.avail-cal__pill-half {
|
|
|
+ flex: 1;
|
|
|
+ transition: opacity 0.2s, background-color 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.avail-cal__pill-left {
|
|
|
+ background-color: $morning-color;
|
|
|
+ border-radius: 999px 0 0 999px;
|
|
|
+
|
|
|
+ &.pill-half--blocked {
|
|
|
+ opacity: $blocked-opacity;
|
|
|
+ }
|
|
|
+ &.pill-half--free {
|
|
|
+ opacity: $free-opacity;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.avail-cal__pill-right {
|
|
|
+ background-color: $afternoon-color;
|
|
|
+ border-radius: 0 999px 999px 0;
|
|
|
+
|
|
|
+ &.pill-half--blocked {
|
|
|
+ opacity: $blocked-opacity;
|
|
|
+ }
|
|
|
+ &.pill-half--free {
|
|
|
+ opacity: $free-opacity;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|