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

feat: adiciona graficos de aniversariante e valores

ebagabee 1 месяц назад
Родитель
Сommit
33f9bff9c8

+ 57 - 0
src/components/charts/AniversariantesCard.vue

@@ -0,0 +1,57 @@
+<template>
+  <q-card flat class="dashboard-chart-card card-ring">
+    <div class="flex justify-between items-center no-wrap q-mb-sm">
+      <span class="text-subtitle2 text-weight-medium">Aniversariantes do Mês</span>
+      <q-btn flat round dense icon="mdi-book-open-outline" color="grey-6" size="sm" />
+    </div>
+
+    <div class="text-caption text-grey-6 q-mb-xs">Nome</div>
+    <q-separator />
+
+    <q-list>
+      <template v-for="(person, index) in people" :key="index">
+        <q-item class="q-px-none q-py-md">
+          <q-item-section avatar>
+            <div class="day-badge">{{ person.day }}</div>
+          </q-item-section>
+          <q-item-section>
+            <q-item-label>{{ person.name }}</q-item-label>
+          </q-item-section>
+        </q-item>
+        <q-separator v-if="index < people.length - 1" />
+      </template>
+    </q-list>
+  </q-card>
+</template>
+
+<script setup>
+defineProps({
+  people: {
+    type: Array,
+    default: () => [],
+  },
+});
+</script>
+
+<style scoped>
+.dashboard-chart-card {
+  border-radius: 8px;
+  padding: 16px;
+  display: flex;
+  flex-direction: column;
+}
+
+.day-badge {
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  background-color: #f97316;
+  color: #fff;
+  font-size: 13px;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+</style>

+ 42 - 0
src/components/charts/DashboardChartCard.vue

@@ -0,0 +1,42 @@
+<template>
+  <q-card flat class="dashboard-chart-card card-ring">
+    <div class="flex justify-between items-center no-wrap q-mb-sm">
+      <span class="text-subtitle2 text-weight-medium">{{ title }}</span>
+      <q-btn
+        flat
+        round
+        dense
+        icon="mdi-book-open-outline"
+        color="grey-6"
+        size="sm"
+        @click="$emit('export')"
+      />
+    </div>
+    <div class="chart-slot-wrapper">
+      <slot />
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+defineProps({
+  title: { type: String, required: true },
+});
+
+defineEmits(["export"]);
+</script>
+
+<style scoped>
+.dashboard-chart-card {
+  border-radius: 8px;
+  padding: 16px;
+  display: flex;
+  flex-direction: column;
+}
+
+.chart-slot-wrapper {
+  flex: 1;
+  min-height: 220px;
+  position: relative;
+}
+</style>

+ 189 - 0
src/components/charts/normal/GroupedBarChart.vue

@@ -0,0 +1,189 @@
+<template>
+  <div v-bind="$attrs" class="chart-wrapper full-width full-height">
+    <q-resize-observer @resize="onResize" />
+    <div v-if="hasData" class="chart-container">
+      <Bar
+        ref="chart_ref"
+        :options="chartOptions"
+        :data="chartData"
+        :plugins="[ChartDataLabels]"
+      />
+    </div>
+    <div v-else class="no-data-container">
+      <span class="text-grey-6">{{ $t("http.errors.no_records_found") }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { Bar } from "vue-chartjs";
+import {
+  Chart as ChartJS,
+  Title,
+  Tooltip,
+  Legend,
+  BarElement,
+  CategoryScale,
+  LinearScale,
+} from "chart.js";
+import ChartDataLabels from "chartjs-plugin-datalabels";
+import { base64ToJPEG } from "src/helpers/convertBase64Image";
+
+ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale);
+
+const chart_ref = ref(null);
+
+const props = defineProps({
+  /**
+   * Array de labels do eixo X
+   */
+  labels: {
+    type: Array,
+    default: () => [],
+  },
+  /**
+   * Array de datasets no formato:
+   * [{ label: String, data: Number[], color: String }]
+   */
+  datasets: {
+    type: Array,
+    default: () => [],
+  },
+  title: {
+    type: String,
+    default: "",
+  },
+  labelY: {
+    type: String,
+    default: "",
+  },
+  /** Raio das bordas das barras. Use valores altos (ex: 50) para efeito "pill". */
+  barRadius: {
+    type: Number,
+    default: 4,
+  },
+  /** Exibe datalabels em cima de cada barra */
+  showDatalabels: {
+    type: Boolean,
+    default: false,
+  },
+  tickFormatter: {
+    type: Function,
+    default: null,
+  },
+  tooltipFormatter: {
+    type: Function,
+    default: null,
+  },
+});
+
+const onResize = () => {
+  if (chart_ref.value?.chart) {
+    setTimeout(() => chart_ref.value.chart.resize(), 50);
+  }
+};
+
+const hasData = computed(
+  () => props.labels.length > 0 && props.datasets.length > 0,
+);
+
+const chartData = computed(() => ({
+  labels: props.labels,
+  datasets: props.datasets.map((ds) => ({
+    label: ds.label,
+    data: ds.data,
+    backgroundColor: ds.color,
+    borderColor: ds.color,
+    borderRadius: props.barRadius,
+    borderSkipped: false,
+    borderWidth: 0,
+  })),
+}));
+
+const labelColor = "#555555";
+const gridColor = "rgba(0,0,0,0.07)";
+
+const chartOptions = computed(() => ({
+  responsive: true,
+  maintainAspectRatio: false,
+  plugins: {
+    legend: {
+      display: false,
+    },
+    datalabels: {
+      display: props.showDatalabels,
+      anchor: "end",
+      align: "top",
+      offset: 4,
+      color: "#333",
+      font: { size: 12, weight: "bold" },
+      formatter: (v) => (v > 0 ? v : ""),
+    },
+    tooltip: {
+      backgroundColor: "rgba(255,255,255,0.95)",
+      titleColor: "#333",
+      bodyColor: "#555",
+      borderColor: "rgba(0,0,0,0.1)",
+      borderWidth: 1,
+      callbacks: props.tooltipFormatter
+        ? { label: props.tooltipFormatter }
+        : {},
+    },
+  },
+  scales: {
+    x: {
+      grid: { display: false },
+      ticks: { color: labelColor, font: { size: 11 } },
+    },
+    y: {
+      display: true,
+      title: {
+        display: !!props.labelY,
+        text: props.labelY,
+        color: labelColor,
+        font: { size: 12 },
+      },
+      grid: { color: gridColor },
+      ticks: {
+        color: labelColor,
+        font: { size: 11 },
+        callback: props.tickFormatter ?? ((v) => v),
+      },
+      suggestedMin: 0,
+    },
+  },
+}));
+
+const downloadImage = () => {
+  const image = chart_ref.value?.chart?.toBase64Image("image/jpeg", 1);
+  if (image) base64ToJPEG(image, props.title || "grouped-bar-chart");
+};
+
+defineExpose({ downloadImage, chart_ref });
+</script>
+
+<style scoped>
+.chart-wrapper {
+  position: relative;
+}
+
+.chart-container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  width: 100%;
+  height: 100%;
+}
+
+.no-data-container {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 1.5rem;
+}
+</style>

+ 2 - 1
src/components/layout/DefaultHeaderPage.vue

@@ -2,7 +2,7 @@
   <div>
     <q-breadcrumbs
       v-if="displayBreadcrumbs != null"
-      class="q-mb-xs text-secondary"
+      class="q-mb-xs text-secondary flex items-center"
       :class="$q.screen.lt.sm ? '' : 'q-pl-lg'"
     >
       <q-breadcrumbs-el
@@ -12,6 +12,7 @@
         :to="crumb.name ? { name: crumb.name, params: crumb.params } : null"
       />
     </q-breadcrumbs>
+
     <div
       v-else
       style="max-width: 180px"

+ 1 - 1
src/components/layout/LeftMenuLayout.vue

@@ -28,7 +28,7 @@
         <div
           v-if="!$q.screen.lt.md"
           class="toggle-button-wrapper absolute"
-          style="top: 2px; right: -32px; z-index: 1"
+          style="top: 10px; right: -32px; z-index: 1"
         >
           <div @click="miniState = !miniState">
             <q-icon

+ 100 - 0
src/pages/dashboard/DashboardPage.vue

@@ -187,6 +187,31 @@
           subtitle="0 pagamentos pendentes"
         />
       </div>
+
+      <div class="charts-row">
+        <DashboardChartCard title="Faturamento Serviço / Materiais">
+          <GroupedBarChart
+            :labels="faturamentoChart.labels"
+            :datasets="faturamentoChart.datasets"
+            label-y="R$"
+            :tick-formatter="formatCurrencyTick"
+            :tooltip-formatter="formatCurrencyTooltip"
+            class="full-width full-height"
+          />
+        </DashboardChartCard>
+
+        <DashboardChartCard title="Matrículas por Período">
+          <GroupedBarChart
+            :labels="matriculasChart.labels"
+            :datasets="matriculasChart.datasets"
+            :bar-radius="50"
+            :show-datalabels="true"
+            class="full-width full-height"
+          />
+        </DashboardChartCard>
+
+        <AniversariantesCard :people="aniversariantes" />
+      </div>
     </div>
 
     <div v-else class="flex flex-center full-width q-pa-xl">
@@ -200,6 +225,9 @@ import { onMounted, ref, watch } from "vue";
 import { useI18n } from "vue-i18n";
 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";
 
 const { t } = useI18n();
 
@@ -220,6 +248,56 @@ const periodOptions = [
   { label: "Personalizado", value: "custom" },
 ];
 
+// --- Aniversariantes do Mês (hardcoded) ---
+const aniversariantes = [
+  { day: 10, name: "Heloisa Faria" },
+  { day: 7, name: "Juliana Costa" },
+  { day: 24, name: "Fernando Almeida" },
+  { day: 28, name: "Patrícia Lima" },
+];
+// -------------------------------------------
+
+// --- Matrículas por Período (hardcoded) ---
+const matriculasChart = {
+  labels: ["JAN", "FEV", "MAR", "ABR", "MAI", "JUN"],
+  datasets: [
+    {
+      label: "Matrículas",
+      data: [120, 200, 150, 80, 70, 110],
+      color: ["#3B82F6", "#EF4444", "#A855F7", "#374151", "#EAB308", "#06B6D4"],
+    },
+  ],
+};
+// ---------------------------------------------------
+
+// --- Faturamento Serviço / Materiais (hardcoded) ---
+const faturamentoChart = {
+  labels: ["17/02", "20/02", "23/02", "26/02"],
+  datasets: [
+    {
+      label: "Serviço",
+      data: [18500, 22300, 15800, 27600],
+      color: "#7C3AED",
+    },
+    {
+      label: "Materiais",
+      data: [9200, 11400, 8700, 13100],
+      color: "#EC4899",
+    },
+  ],
+};
+
+const formatCurrencyTick = (value) => {
+  if (value >= 1000) return `R$ ${(value / 1000).toFixed(0)}k`;
+  return `R$ ${value}`;
+};
+
+const formatCurrencyTooltip = (context) => {
+  const value = context.parsed.y;
+  return ` ${context.dataset.label}: R$ ${value.toLocaleString("pt-BR", { minimumFractionDigits: 2 })}`;
+};
+// ---------------------------------------------------
+
 const ordersChart = ref({});
 const participantsChart = ref({});
 const paymentsChart = ref({});
@@ -410,6 +488,28 @@ onMounted(async () => {
   }
 }
 
+.charts-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16px;
+}
+
+.charts-row > * {
+  flex: 1 1 350px;
+  min-width: 280px;
+}
+
+.charts-row > *:last-child {
+  flex: 0 1 240px;
+  min-width: 200px;
+}
+
+@media (max-width: 599px) {
+  .charts-row > * {
+    flex: 1 1 100%;
+  }
+}
+
 .filter-row {
   display: flex;
   flex-wrap: wrap;