Просмотр исходного кода

feat(class): visões de calendário (mês e timeline do dia)

Grade mensal estilo Google Calendar com card de aula nas células e
timeline de horas (00h-23h) rolável ao clicar no dia.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ebagabee 1 месяц назад
Родитель
Сommit
fc03c703b2

+ 108 - 0
src/pages/classes/components/ClassEventCard.vue

@@ -0,0 +1,108 @@
+<template>
+  <div
+    class="class-event-card"
+    :class="{ 'class-event-card--compact': compact }"
+    @click.stop="$emit('click', classItem)"
+  >
+    <div class="class-event-card__title">{{ classItem.title }}</div>
+
+    <div class="class-event-card__row">
+      <q-icon name="mdi-map-marker-outline" size="14px" />
+      <span>{{ classItem.room }}</span>
+    </div>
+
+    <div class="class-event-card__row">
+      <q-icon name="mdi-account-outline" size="14px" />
+      <span>{{ classItem.instructor }}</span>
+    </div>
+
+    <div class="class-event-card__row">
+      <q-icon name="mdi-clock-outline" size="14px" />
+      <span>{{ timeRange }}</span>
+    </div>
+
+    <div class="class-event-card__row">
+      <q-icon name="mdi-account-group-outline" size="14px" />
+      <span>{{ enrollment }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { format, parseISO } from "date-fns";
+
+const props = defineProps({
+  classItem: { type: Object, required: true },
+  compact: { type: Boolean, default: false },
+});
+
+defineEmits(["click"]);
+
+const timeRange = computed(() => {
+  const start = format(parseISO(props.classItem.date_time_start), "HH:mm");
+  const end = format(parseISO(props.classItem.date_time_end), "HH:mm");
+  return `${start} - ${end}`;
+});
+
+const enrollment = computed(() => {
+  const pad = (n) => String(n ?? 0).padStart(2, "0");
+  // presentes / total de alunos com contrato ativo no pacote
+  return `${pad(props.classItem.present_count)}/${pad(props.classItem.students_count)}`;
+});
+</script>
+
+<style scoped>
+.class-event-card {
+  background: #ffffff;
+  border-radius: 8px;
+  padding: 8px 10px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
+  cursor: pointer;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 3px;
+  transition: box-shadow 0.18s ease, transform 0.18s ease;
+}
+
+.class-event-card:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
+  transform: translateY(-1px);
+}
+
+.class-event-card__title {
+  font-size: 12px;
+  font-weight: 600;
+  color: #003e29;
+  text-align: center;
+  line-height: 1.15;
+  margin-bottom: 2px;
+}
+
+.class-event-card__row {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 11px;
+  color: #505050;
+  align-self: flex-start;
+}
+
+.class-event-card__row .q-icon {
+  color: #ff8340;
+  flex-shrink: 0;
+}
+
+.class-event-card--compact {
+  padding: 6px 8px;
+}
+
+.class-event-card--compact .class-event-card__title {
+  font-size: 11px;
+}
+
+.class-event-card--compact .class-event-card__row {
+  font-size: 10px;
+}
+</style>

+ 211 - 0
src/pages/classes/components/DayTimeline.vue

@@ -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>

+ 167 - 0
src/pages/classes/components/MonthGrid.vue

@@ -0,0 +1,167 @@
+<template>
+  <div class="month-grid">
+    <!-- Weekday header -->
+    <div class="month-grid__weekdays">
+      <div v-for="label in weekdayLabels" :key="label" class="month-grid__weekday">
+        {{ label }}
+      </div>
+    </div>
+
+    <!-- Day cells -->
+    <div class="month-grid__body">
+      <div
+        v-for="day in days"
+        :key="day.key"
+        class="month-grid__cell"
+        :class="{
+          'month-grid__cell--outside': !day.inCurrentMonth,
+          'month-grid__cell--today': day.isToday,
+        }"
+        @click="$emit('day-click', day.date)"
+      >
+        <div class="month-grid__cell-number">{{ day.dayNumber }}</div>
+
+        <div class="month-grid__cell-events">
+          <ClassEventCard
+            v-for="item in day.classes"
+            :key="item.id"
+            :class-item="item"
+            compact
+            @click="$emit('class-click', item)"
+          />
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import {
+  startOfMonth,
+  endOfMonth,
+  startOfWeek,
+  endOfWeek,
+  eachDayOfInterval,
+  isSameMonth,
+  isSameDay,
+  parseISO,
+  format,
+} from "date-fns";
+import { ptBR } from "date-fns/locale";
+import ClassEventCard from "./ClassEventCard.vue";
+
+const props = defineProps({
+  currentDate: { type: Date, required: true },
+  classes: { type: Array, default: () => [] },
+});
+
+defineEmits(["day-click", "class-click"]);
+
+const weekdayLabels = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
+
+const days = computed(() => {
+  const monthStart = startOfMonth(props.currentDate);
+  const monthEnd = endOfMonth(props.currentDate);
+  const gridStart = startOfWeek(monthStart, { weekStartsOn: 0 });
+  const gridEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });
+  const today = new Date();
+
+  return eachDayOfInterval({ start: gridStart, end: gridEnd }).map((date) => ({
+    date,
+    key: format(date, "yyyy-MM-dd"),
+    dayNumber: format(date, "d", { locale: ptBR }),
+    inCurrentMonth: isSameMonth(date, props.currentDate),
+    isToday: isSameDay(date, today),
+    classes: props.classes.filter((c) =>
+      isSameDay(parseISO(c.date_time_start), date),
+    ),
+  }));
+});
+</script>
+
+<style scoped>
+.month-grid {
+  display: flex;
+  flex-direction: column;
+  border: 1px solid #ff8340;
+  border-radius: 10px;
+  overflow: hidden;
+  background: #eaf8f8;
+}
+
+.month-grid__weekdays {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+  background: #003e29;
+}
+
+.month-grid__weekday {
+  text-align: center;
+  padding: 8px 4px;
+  font-size: 12px;
+  font-weight: 600;
+  color: #ffffff;
+  text-transform: uppercase;
+  letter-spacing: 0.4px;
+}
+
+.month-grid__body {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+}
+
+.month-grid__cell {
+  position: relative;
+  min-height: 116px;
+  border-right: 1px solid #ff8340;
+  border-bottom: 1px solid #ff8340;
+  padding: 6px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  cursor: pointer;
+  transition: background 0.15s ease;
+}
+
+/* remove the right border on the last column of each row */
+.month-grid__cell:nth-child(7n) {
+  border-right: none;
+}
+
+.month-grid__cell:hover {
+  background: rgba(255, 131, 64, 0.08);
+}
+
+.month-grid__cell--outside {
+  background: rgba(0, 0, 0, 0.02);
+}
+
+.month-grid__cell--outside .month-grid__cell-number {
+  color: #b0b0b0;
+}
+
+.month-grid__cell--today .month-grid__cell-number {
+  background: #ff8340;
+  color: #ffffff;
+}
+
+.month-grid__cell-number {
+  align-self: flex-start;
+  font-size: 12px;
+  font-weight: 600;
+  color: #505050;
+  width: 22px;
+  height: 22px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+}
+
+.month-grid__cell-events {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+</style>