瀏覽代碼

feat: adiciona holiday

ebagabee 2 周之前
父節點
當前提交
b029c7d44d
共有 3 個文件被更改,包括 503 次插入11 次删除
  1. 21 0
      src/api/holiday.js
  2. 66 11
      src/pages/dashboard/DashboardPage.vue
  3. 416 0
      src/pages/dashboard/components/FeriadosDialog.vue

+ 21 - 0
src/api/holiday.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getHolidays = async () => {
+  const { data } = await api.get("/holiday");
+  return data.payload;
+};
+
+export const createHoliday = async (holiday) => {
+  const { data } = await api.post("/holiday", holiday);
+  return data.payload;
+};
+
+export const updateHoliday = async (holiday, id) => {
+  const { data } = await api.put(`/holiday/${id}`, holiday);
+  return data.payload;
+};
+
+export const deleteHoliday = async (id) => {
+  const { data } = await api.delete(`/holiday/${id}`);
+  return data.payload;
+};

+ 66 - 11
src/pages/dashboard/DashboardPage.vue

@@ -135,7 +135,14 @@
               <span class="text-subtitle2 text-weight-medium"
                 >Feriados do mês</span
               >
-              <q-icon name="mdi-calendar-outline" color="grey-5" />
+              <q-btn
+                flat
+                round
+                dense
+                icon="mdi-calendar-star"
+                color="grey-5"
+                @click="openFeriadosDialog"
+              />
             </q-card-section>
             <q-separator />
             <q-card-section class="q-pt-md">
@@ -145,16 +152,23 @@
                 label="Nova data"
                 no-caps
                 class="full-width q-mb-md"
+                @click="openFeriadosDialog"
               />
-              <div class="row q-gutter-sm">
+              <div v-if="feriadosLoading" class="flex flex-center q-py-md">
+                <q-spinner color="primary" size="24px" />
+              </div>
+              <div v-else-if="feriadosMes.length === 0" class="text-caption text-grey-5 text-center">
+                Nenhum feriado neste mês.
+              </div>
+              <div v-else class="row q-gutter-sm">
                 <div
-                  v-for="(feriado, i) in feriados"
-                  :key="i"
+                  v-for="feriado in feriadosMes"
+                  :key="feriado.id"
                   class="column items-center"
                   style="min-width: 52px"
                 >
                   <q-badge
-                    :color="feriado.color"
+                    color="deep-orange"
                     class="text-subtitle1 text-bold q-pa-sm"
                     style="min-width: 40px; justify-content: center"
                   >
@@ -174,17 +188,22 @@
 </template>
 
 <script setup>
-import { ref } from "vue";
+import { ref, computed, onMounted } from "vue";
 import { Doughnut } from "vue-chartjs";
 import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
+import { useQuasar } from "quasar";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
 import DashboardChartCard from "src/components/charts/DashboardChartCard.vue";
 import GroupedBarChart from "src/components/charts/normal/GroupedBarChart.vue";
 import AniversariantesCard from "src/components/charts/AniversariantesCard.vue";
+import FeriadosDialog from "./components/FeriadosDialog.vue";
+import { getHolidays } from "src/api/holiday";
 
 ChartJS.register(ArcElement, Tooltip, Legend);
 
+const $q = useQuasar();
+
 const faturamentoChart = {
   labels: [
     "17/02", "18/02", "19/02", "20/02", "21/02",
@@ -305,11 +324,47 @@ const aniversariantes = ref([
   { day: 34, name: "Sofia Martins" },
 ]);
 
-const feriados = ref([
-  { dia: 17, nome: "Carnaval", color: "amber" },
-  { dia: 17, nome: "Carnaval", color: "amber" },
-  { dia: 17, nome: "Carnaval", color: "amber" },
-]);
+// Feriados
+const allHolidays = ref([]);
+const feriadosLoading = ref(false);
+
+const feriadosMes = computed(() => {
+  const now = new Date();
+  const month = now.getMonth() + 1;
+  const year = now.getFullYear();
+  return allHolidays.value
+    .filter((h) => {
+      const d = new Date(h.holiday_date + "T00:00:00");
+      return d.getMonth() + 1 === month && d.getFullYear() === year;
+    })
+    .sort((a, b) => new Date(a.holiday_date) - new Date(b.holiday_date))
+    .map((h) => ({
+      id: h.id,
+      dia: new Date(h.holiday_date + "T00:00:00").getDate(),
+      nome: h.description,
+    }));
+});
+
+async function fetchHolidays() {
+  feriadosLoading.value = true;
+  try {
+    allHolidays.value = await getHolidays();
+  } catch {
+    $q.notify({ type: "negative", message: "Erro ao carregar feriados." });
+  } finally {
+    feriadosLoading.value = false;
+  }
+}
+
+function openFeriadosDialog() {
+  $q.dialog({ component: FeriadosDialog }).onOk(() => {
+    fetchHolidays();
+  });
+}
+
+onMounted(() => {
+  fetchHolidays();
+});
 </script>
 
 <style scoped>

+ 416 - 0
src/pages/dashboard/components/FeriadosDialog.vue

@@ -0,0 +1,416 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin feriados-dialog">
+      <DefaultDialogHeader title="Feriados" @close="onDialogCancel" />
+
+      <q-card-section class="row q-col-gutter-md q-pt-none">
+        <!-- Calendário -->
+        <div class="col-12 col-sm-7">
+          <!-- Navegação mês/ano -->
+          <div class="row items-center justify-between q-mb-sm">
+            <q-btn
+              flat
+              round
+              dense
+              icon="mdi-chevron-left"
+              color="grey-7"
+              @click="prevMonth"
+            />
+            <div class="row q-gutter-sm items-center">
+              <q-select
+                v-model="currentMonth"
+                :options="monthOptions"
+                emit-value
+                map-options
+                dense
+                outlined
+                style="min-width: 100px"
+              />
+              <q-select
+                v-model="currentYear"
+                :options="yearOptions"
+                emit-value
+                map-options
+                dense
+                outlined
+                style="min-width: 80px"
+              />
+            </div>
+            <q-btn
+              flat
+              round
+              dense
+              icon="mdi-chevron-right"
+              color="grey-7"
+              @click="nextMonth"
+            />
+          </div>
+
+          <!-- Cabeçalho dias da semana -->
+          <div class="calendar-grid q-mb-xs">
+            <div
+              v-for="day in weekDays"
+              :key="day"
+              class="calendar-header-cell text-caption text-grey-6 text-center"
+            >
+              {{ day }}
+            </div>
+          </div>
+
+          <!-- Dias do mês -->
+          <div class="calendar-grid">
+            <div
+              v-for="(cell, index) in calendarCells"
+              :key="index"
+              class="calendar-cell"
+              :class="{
+                'calendar-cell--empty': !cell.day,
+                'calendar-cell--holiday': cell.isHoliday,
+                'calendar-cell--selected': cell.day === selectedDay && cell.day !== null,
+              }"
+              @click="cell.day && onDayClick(cell)"
+            >
+              <span v-if="cell.day">{{ cell.day }}</span>
+            </div>
+          </div>
+
+          <!-- Input inline para adicionar feriado -->
+          <div v-if="selectedDay !== null && !selectedDayHasHoliday" class="q-mt-md">
+            <div class="text-caption text-grey-6 q-mb-xs">
+              Descrição para {{ selectedDay }}/{{ String(currentMonth).padStart(2, '0') }}/{{ currentYear }}
+            </div>
+            <div class="row q-gutter-sm">
+              <q-input
+                v-model="newDescription"
+                dense
+                outlined
+                placeholder="Ex: Independência do Brasil"
+                class="col"
+                autofocus
+                @keyup.enter="addHoliday"
+              />
+              <q-btn
+                unelevated
+                color="primary"
+                label="Adicionar"
+                no-caps
+                :disable="!newDescription.trim()"
+                @click="addHoliday"
+              />
+            </div>
+          </div>
+        </div>
+
+        <!-- Resumo -->
+        <div class="col-12 col-sm-5">
+          <div class="text-subtitle2 q-mb-sm">Resumo</div>
+          <q-separator class="q-mb-sm" />
+
+          <div v-if="monthHolidays.length === 0" class="text-caption text-grey-5 text-center q-mt-lg">
+            Clique em um dia no calendário para adicionar um registro.
+          </div>
+
+          <q-list v-else separator>
+            <q-item
+              v-for="holiday in monthHolidays"
+              :key="holiday._key"
+              class="q-pa-sm"
+            >
+              <q-item-section avatar>
+                <q-avatar
+                  size="36px"
+                  color="deep-orange"
+                  text-color="white"
+                  class="text-weight-bold"
+                >
+                  {{ holiday.day }}
+                </q-avatar>
+              </q-item-section>
+              <q-item-section>
+                <q-item-label class="text-body2">{{ holiday.description }}</q-item-label>
+                <q-item-label caption>Feriado</q-item-label>
+              </q-item-section>
+              <q-item-section side>
+                <q-btn
+                  flat
+                  round
+                  dense
+                  icon="mdi-trash-can-outline"
+                  color="grey-5"
+                  size="sm"
+                  @click="removeHoliday(holiday)"
+                />
+              </q-item-section>
+            </q-item>
+          </q-list>
+        </div>
+      </q-card-section>
+
+      <q-separator />
+
+      <q-card-actions align="right">
+        <q-btn outline color="primary" label="CANCELAR" no-caps @click="onDialogCancel" />
+        <q-btn
+          unelevated
+          color="primary"
+          label="SALVAR"
+          no-caps
+          :loading="saving"
+          @click="onSave"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import { getHolidays, createHoliday, deleteHoliday } from "src/api/holiday";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const $q = useQuasar();
+
+const now = new Date();
+const currentMonth = ref(now.getMonth() + 1); // 1-12
+const currentYear = ref(now.getFullYear());
+const selectedDay = ref(null);
+const newDescription = ref("");
+const saving = ref(false);
+
+// Holidays loaded from API
+const existingHolidays = ref([]); // { id, holiday_date, description }
+// Pending additions (not yet saved)
+const pendingAdditions = ref([]); // { _key, holiday_date, description, day }
+// IDs to delete on save
+const pendingDeletions = ref([]); // ids
+
+const weekDays = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
+
+const monthOptions = [
+  { label: "Jan", value: 1 },
+  { label: "Feb", value: 2 },
+  { label: "Mar", value: 3 },
+  { label: "Apr", value: 4 },
+  { label: "May", value: 5 },
+  { label: "Jun", value: 6 },
+  { label: "Jul", value: 7 },
+  { label: "Aug", value: 8 },
+  { label: "Sep", value: 9 },
+  { label: "Oct", value: 10 },
+  { label: "Nov", value: 11 },
+  { label: "Dec", value: 12 },
+];
+
+const yearOptions = computed(() => {
+  const base = now.getFullYear();
+  return Array.from({ length: 7 }, (_, i) => {
+    const y = base - 2 + i;
+    return { label: String(y), value: y };
+  });
+});
+
+// All holidays for current view (existing - deleted + pending)
+const allHolidays = computed(() => {
+  const active = existingHolidays.value
+    .filter((h) => !pendingDeletions.value.includes(h.id))
+    .map((h) => {
+      const d = new Date(h.holiday_date + "T00:00:00");
+      return {
+        _key: `existing-${h.id}`,
+        id: h.id,
+        isPending: false,
+        holiday_date: h.holiday_date,
+        day: d.getDate(),
+        month: d.getMonth() + 1,
+        year: d.getFullYear(),
+        description: h.description,
+      };
+    });
+  const additions = pendingAdditions.value.map((h) => ({ ...h, isPending: true }));
+  return [...active, ...additions];
+});
+
+// Holidays for selected month
+const monthHolidays = computed(() => {
+  return allHolidays.value
+    .filter((h) => h.month === currentMonth.value && h.year === currentYear.value)
+    .sort((a, b) => a.day - b.day);
+});
+
+// Compute calendar cells
+const calendarCells = computed(() => {
+  const year = currentYear.value;
+  const month = currentMonth.value;
+  const firstDay = new Date(year, month - 1, 1).getDay(); // 0=Sun
+  const daysInMonth = new Date(year, month, 0).getDate();
+
+  const holidayDays = new Set(monthHolidays.value.map((h) => h.day));
+
+  const cells = [];
+  for (let i = 0; i < firstDay; i++) {
+    cells.push({ day: null, isHoliday: false });
+  }
+  for (let d = 1; d <= daysInMonth; d++) {
+    cells.push({ day: d, isHoliday: holidayDays.has(d) });
+  }
+  return cells;
+});
+
+const selectedDayHasHoliday = computed(() => {
+  if (selectedDay.value === null) return false;
+  return monthHolidays.value.some((h) => h.day === selectedDay.value);
+});
+
+function prevMonth() {
+  if (currentMonth.value === 1) {
+    currentMonth.value = 12;
+    currentYear.value -= 1;
+  } else {
+    currentMonth.value -= 1;
+  }
+  selectedDay.value = null;
+  newDescription.value = "";
+}
+
+function nextMonth() {
+  if (currentMonth.value === 12) {
+    currentMonth.value = 1;
+    currentYear.value += 1;
+  } else {
+    currentMonth.value += 1;
+  }
+  selectedDay.value = null;
+  newDescription.value = "";
+}
+
+function onDayClick(cell) {
+  if (selectedDayHasHoliday.value && selectedDay.value === cell.day) {
+    // Toggle off
+    selectedDay.value = null;
+    newDescription.value = "";
+    return;
+  }
+  selectedDay.value = cell.day;
+  newDescription.value = "";
+}
+
+function addHoliday() {
+  const desc = newDescription.value.trim();
+  if (!desc || selectedDay.value === null) return;
+
+  const mm = String(currentMonth.value).padStart(2, "0");
+  const dd = String(selectedDay.value).padStart(2, "0");
+  const dateStr = `${currentYear.value}-${mm}-${dd}`;
+
+  pendingAdditions.value.push({
+    _key: `pending-${Date.now()}`,
+    id: null,
+    isPending: true,
+    holiday_date: dateStr,
+    day: selectedDay.value,
+    month: currentMonth.value,
+    year: currentYear.value,
+    description: desc,
+  });
+
+  selectedDay.value = null;
+  newDescription.value = "";
+}
+
+function removeHoliday(holiday) {
+  if (holiday.isPending) {
+    pendingAdditions.value = pendingAdditions.value.filter((h) => h._key !== holiday._key);
+  } else {
+    pendingDeletions.value.push(holiday.id);
+  }
+  if (selectedDay.value === holiday.day) {
+    selectedDay.value = null;
+    newDescription.value = "";
+  }
+}
+
+async function onSave() {
+  saving.value = true;
+  try {
+    // Delete first
+    for (const id of pendingDeletions.value) {
+      await deleteHoliday(id);
+    }
+    // Then create
+    for (const h of pendingAdditions.value) {
+      await createHoliday({ holiday_date: h.holiday_date, description: h.description });
+    }
+    onDialogOK(true);
+  } catch {
+    $q.notify({ type: "negative", message: "Erro ao salvar feriados. Tente novamente." });
+  } finally {
+    saving.value = false;
+  }
+}
+
+onMounted(async () => {
+  try {
+    existingHolidays.value = await getHolidays();
+  } catch {
+    $q.notify({ type: "negative", message: "Erro ao carregar feriados." });
+  }
+});
+</script>
+
+<style scoped>
+.feriados-dialog {
+  width: 700px;
+  max-width: 95vw;
+}
+
+.calendar-grid {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+  gap: 4px;
+}
+
+.calendar-header-cell {
+  padding: 4px 0;
+  font-weight: 600;
+}
+
+.calendar-cell {
+  aspect-ratio: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 8px;
+  cursor: pointer;
+  font-size: 14px;
+  transition: background 0.15s;
+}
+
+.calendar-cell:hover:not(.calendar-cell--empty) {
+  background: #f5f5f5;
+}
+
+.calendar-cell--empty {
+  cursor: default;
+  pointer-events: none;
+}
+
+.calendar-cell--holiday {
+  background: #e64a19;
+  color: #fff;
+  font-weight: 700;
+}
+
+.calendar-cell--holiday:hover {
+  background: #bf360c !important;
+}
+
+.calendar-cell--selected:not(.calendar-cell--holiday) {
+  background: #ffe0d6;
+  color: #e64a19;
+  font-weight: 700;
+}
+</style>