|
|
@@ -0,0 +1,211 @@
|
|
|
+<template>
|
|
|
+ <div class="day-timeline">
|
|
|
+ <div ref="scrollRef" class="day-timeline__scroll">
|
|
|
+ <div class="day-timeline__grid">
|
|
|
+ <!-- Hour rows (labels + lines) -->
|
|
|
+ <div
|
|
|
+ v-for="hour in hours"
|
|
|
+ :key="hour"
|
|
|
+ class="day-timeline__hour-row"
|
|
|
+ :style="{ height: hourHeight + 'px' }"
|
|
|
+ >
|
|
|
+ <div class="day-timeline__hour-label">{{ formatHour(hour) }}</div>
|
|
|
+ <div class="day-timeline__hour-line"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Positioned events -->
|
|
|
+ <div class="day-timeline__events">
|
|
|
+ <div
|
|
|
+ v-for="item in positionedClasses"
|
|
|
+ :key="item.id"
|
|
|
+ class="day-timeline__event"
|
|
|
+ :style="item.style"
|
|
|
+ @click="$emit('class-click', item.data)"
|
|
|
+ >
|
|
|
+ <div class="day-timeline__event-title">{{ item.data.title }}</div>
|
|
|
+ <div class="day-timeline__event-meta">
|
|
|
+ <q-icon name="mdi-clock-outline" size="13px" />
|
|
|
+ <span>{{ item.timeRange }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="day-timeline__event-meta">
|
|
|
+ <q-icon name="mdi-map-marker-outline" size="13px" />
|
|
|
+ <span>{{ item.data.room }}</span>
|
|
|
+ <span class="day-timeline__event-sep">·</span>
|
|
|
+ <q-icon name="mdi-account-outline" size="13px" />
|
|
|
+ <span>{{ item.data.instructor }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="!positionedClasses.length" class="day-timeline__empty">
|
|
|
+ Nenhuma aula agendada para este dia.
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { computed, ref, onMounted, watch, nextTick } from "vue";
|
|
|
+import { format, parseISO, isSameDay, differenceInMinutes } from "date-fns";
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ date: { type: Date, required: true },
|
|
|
+ classes: { type: Array, default: () => [] },
|
|
|
+});
|
|
|
+
|
|
|
+defineEmits(["class-click"]);
|
|
|
+
|
|
|
+const START_HOUR = 0;
|
|
|
+const END_HOUR = 23;
|
|
|
+const DEFAULT_SCROLL_HOUR = 7; // abre no início do horário comercial
|
|
|
+const hourHeight = 64;
|
|
|
+
|
|
|
+const scrollRef = ref(null);
|
|
|
+
|
|
|
+const hours = computed(() => {
|
|
|
+ const list = [];
|
|
|
+ for (let h = START_HOUR; h <= END_HOUR; h++) list.push(h);
|
|
|
+ return list;
|
|
|
+});
|
|
|
+
|
|
|
+const formatHour = (hour) => `${String(hour).padStart(2, "0")}:00`;
|
|
|
+
|
|
|
+const dayClasses = computed(() =>
|
|
|
+ props.classes.filter((c) => isSameDay(parseISO(c.date_time_start), props.date)),
|
|
|
+);
|
|
|
+
|
|
|
+const positionedClasses = computed(() =>
|
|
|
+ dayClasses.value.map((item) => {
|
|
|
+ const start = parseISO(item.date_time_start);
|
|
|
+ const end = parseISO(item.date_time_end);
|
|
|
+ const minutesFromTop =
|
|
|
+ (start.getHours() - START_HOUR) * 60 + start.getMinutes();
|
|
|
+ const durationMinutes = Math.max(differenceInMinutes(end, start), 30);
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: item.id,
|
|
|
+ data: item,
|
|
|
+ timeRange: `${format(start, "HH:mm")} - ${format(end, "HH:mm")}`,
|
|
|
+ style: {
|
|
|
+ top: (minutesFromTop / 60) * hourHeight + "px",
|
|
|
+ height: (durationMinutes / 60) * hourHeight + "px",
|
|
|
+ },
|
|
|
+ };
|
|
|
+ }),
|
|
|
+);
|
|
|
+
|
|
|
+// Posiciona o scroll na primeira aula do dia (ou no horário comercial)
|
|
|
+const scrollToRelevantHour = async () => {
|
|
|
+ await nextTick();
|
|
|
+ if (!scrollRef.value) return;
|
|
|
+
|
|
|
+ const firstStart = dayClasses.value
|
|
|
+ .map((c) => parseISO(c.date_time_start))
|
|
|
+ .sort((a, b) => a - b)[0];
|
|
|
+
|
|
|
+ const targetHour = firstStart
|
|
|
+ ? Math.max(firstStart.getHours() - 1, START_HOUR)
|
|
|
+ : DEFAULT_SCROLL_HOUR;
|
|
|
+
|
|
|
+ scrollRef.value.scrollTop = (targetHour - START_HOUR) * hourHeight;
|
|
|
+};
|
|
|
+
|
|
|
+onMounted(scrollToRelevantHour);
|
|
|
+watch(() => props.date, scrollToRelevantHour);
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.day-timeline {
|
|
|
+ border: 1px solid #ff8340;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: #ffffff;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.day-timeline__scroll {
|
|
|
+ max-height: calc(100vh - 220px);
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.day-timeline__grid {
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+.day-timeline__hour-row {
|
|
|
+ position: relative;
|
|
|
+ display: flex;
|
|
|
+}
|
|
|
+
|
|
|
+.day-timeline__hour-label {
|
|
|
+ width: 64px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ text-align: right;
|
|
|
+ padding: 2px 10px 0 0;
|
|
|
+ font-size: 11px;
|
|
|
+ color: #909090;
|
|
|
+ transform: translateY(-7px);
|
|
|
+}
|
|
|
+
|
|
|
+.day-timeline__hour-line {
|
|
|
+ flex: 1;
|
|
|
+ border-top: 1px solid #f0e0d6;
|
|
|
+ border-left: 1px solid #ffd9c4;
|
|
|
+}
|
|
|
+
|
|
|
+.day-timeline__events {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 64px;
|
|
|
+ right: 8px;
|
|
|
+ bottom: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.day-timeline__event {
|
|
|
+ position: absolute;
|
|
|
+ left: 6px;
|
|
|
+ right: 6px;
|
|
|
+ background: #ffe0d0;
|
|
|
+ border-left: 3px solid #ff8340;
|
|
|
+ border-radius: 6px;
|
|
|
+ padding: 4px 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: filter 0.15s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.day-timeline__event:hover {
|
|
|
+ filter: brightness(0.97);
|
|
|
+}
|
|
|
+
|
|
|
+.day-timeline__event-title {
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #003e29;
|
|
|
+ line-height: 1.2;
|
|
|
+}
|
|
|
+
|
|
|
+.day-timeline__event-meta {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ font-size: 11px;
|
|
|
+ color: #6a4a37;
|
|
|
+ margin-top: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.day-timeline__event-meta .q-icon {
|
|
|
+ color: #ff8340;
|
|
|
+}
|
|
|
+
|
|
|
+.day-timeline__event-sep {
|
|
|
+ margin: 0 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.day-timeline__empty {
|
|
|
+ padding: 24px;
|
|
|
+ text-align: center;
|
|
|
+ color: #909090;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+</style>
|