Преглед на файлове

feat: :sparkles: feat (agendamentos-adm) criada pagina de agendamentos

foi criada a pagina de agendamentos no layout adm, contendo as abas de criar agendamento, exibir agendamentos, e aba de agendamentos aprovados automaticamente + selecao de idiomas no perfil

fase:dev | origin:escopo
Gustavo Zanatta преди 3 седмици
родител
ревизия
cc2a1f8f97

+ 23 - 0
src/api/appointment.js

@@ -14,3 +14,26 @@ export const cancelAppointment = async (id) => {
   const { data } = await api.put(`/appointment/${id}`, { status: "cancelado" });
   return data.payload;
 };
+
+export const getAdminCounters = async () => {
+  const { data } = await api.get("/appointment/admin/counters");
+  return data.payload;
+};
+
+export const getAdminAppointmentsPaginated = async ({ page = 1, perPage = 10, filter, status } = {}) => {
+  const params = { page, per_page: perPage };
+  if (filter) params.search = filter;
+  if (status) params.status = status;
+  const { data } = await api.get("/appointment/admin/list", { params });
+  return { data: { result: data.payload } };
+};
+
+export const approveAppointment = async (id) => {
+  const { data } = await api.put(`/appointment/${id}/approve`);
+  return data.payload;
+};
+
+export const rejectAppointment = async (id) => {
+  const { data } = await api.put(`/appointment/${id}/reject`);
+  return data.payload;
+};

+ 49 - 0
src/components/layout/AppHeader.vue

@@ -21,18 +21,56 @@
     <q-avatar size="36px" class="app-header__avatar">
       <img v-if="store.user?.photo_url" :src="store.user.photo_url" />
       <span v-else class="app-header__avatar-initial">{{ userInitial }}</span>
+
+      <q-menu anchor="bottom right" self="top right" class="app-header__lang-menu">
+        <q-list dense style="min-width: 160px">
+          <q-item
+            v-for="lang in languages"
+            :key="lang.code"
+            v-close-popup
+            clickable
+            :active="currentLocaleBase === lang.code"
+            active-class="app-header__lang-item--active"
+            @click="changeLocale(lang.code)"
+          >
+            <q-item-section side class="q-pr-sm">
+              <span class="app-header__lang-flag">{{ lang.flag }}</span>
+            </q-item-section>
+            <q-item-section>{{ lang.label }}</q-item-section>
+          </q-item>
+        </q-list>
+      </q-menu>
     </q-avatar>
   </q-toolbar>
 </template>
 
 <script setup>
 import { computed } from "vue";
+import { useI18n } from "vue-i18n";
+import { Cookies } from "quasar";
+import { i18n } from "src/boot/i18n";
 import { userStore } from "src/stores/user";
 import { navigationStore } from "src/stores/navigation";
 import LogoSmall from "src/assets/logo_serprati_small.png";
 
 const store = userStore();
 const navStore = navigationStore();
+const { locale } = useI18n();
+
+const languages = [
+  { code: "pt", label: "Português", flag: "🇧🇷" },
+  { code: "en", label: "English",   flag: "🇺🇸" },
+  { code: "es", label: "Español",   flag: "🇪🇸" },
+];
+
+const currentLocaleBase = computed(() =>
+  locale.value?.split("-")[0].toLowerCase()
+);
+
+const changeLocale = (code) => {
+  i18n.global.locale.value = code;
+  Cookies.set("locale", code, { expires: 365, path: "/" });
+};
 
 const userTypeLabel = computed(() => {
   const t = store.userTipo;
@@ -95,4 +133,15 @@ const userInitial = computed(() => {
   font-size: 15px;
   font-weight: 600;
 }
+
+.app-header__lang-flag {
+  font-size: 16px;
+  line-height: 1;
+}
+
+.app-header__lang-item--active {
+  background: rgba(vars.$violet-normal, 0.1);
+  color: vars.$violet-normal;
+  font-weight: 600;
+}
 </style>

+ 72 - 0
src/components/selects/AssociadoSelect.vue

@@ -0,0 +1,72 @@
+<template>
+  <DefaultSelect
+    v-model="selected"
+    v-bind="$attrs"
+    use-input
+    hide-selected
+    fill-input
+    clearable
+    :options="options"
+    :label
+    :loading
+    :placeholder
+    @filter="filterFn"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{ $t("http.errors.no_records_found") }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </DefaultSelect>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { getAssociados } from "src/api/user";
+import { normalizeString } from "src/helpers/utils";
+import { useI18n } from "vue-i18n";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+const { label, placeholder } = defineProps({
+  label: {
+    type: String,
+    default: () => useI18n().t("associado.associado"),
+  },
+  placeholder: {
+    type: String,
+    default: () => useI18n().t("common.actions.search"),
+  },
+});
+
+const selected = defineModel({ type: Object });
+
+const loading = ref(true);
+const baseOptions = ref([]);
+const options = ref([]);
+
+const filterFn = (val, update) => {
+  const needle = normalizeString(val);
+  options.value = baseOptions.value.filter((v) =>
+    normalizeString(v.label).includes(needle),
+  );
+  update();
+};
+
+onMounted(async () => {
+  try {
+    const associados = await getAssociados();
+    baseOptions.value = associados.map((a) => ({
+      label: a.name,
+      value: a.id,
+      data: a,
+    }));
+    options.value = baseOptions.value;
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+});
+</script>

+ 2 - 2
src/css/quasar.variables.scss

@@ -4,7 +4,7 @@
 // ─── Standalone variables (usable directly in SCSS via $var-name) ────────────
 
 // Brand
-$primary:             #35a30a;
+$primary:             #661d75;
 $primary-4:           #cde8c2;
 
 // Violet (SerPrati brand)
@@ -53,7 +53,7 @@ $inactive: #FFE100; // Material Orange 800
 $colors: (
   // Brand Colors
   // $primary: #35a30a;
-  "primary": #35a30a,
+  "primary": #661d75,
   "primary-4": #cde8c2,
 
   "text": #161616,

+ 26 - 0
src/i18n/locales/en.json

@@ -55,6 +55,9 @@
     },
     "relatorios": {
       "description": "View management reports"
+    },
+    "agendamentos": {
+      "description": "Manage associate appointments"
     }
   },
   "common": {
@@ -128,6 +131,7 @@
       "week": "Week",
       "day": "Day",
       "hour": "Hour",
+      "hour2": "Time",
       "minute": "Minute",
       "second": "Second",
       "year": "Year",
@@ -646,5 +650,27 @@
       "exclusao": "Exclusion Date"
     },
     "exportar_excel": "Export Excel"
+  },
+  "agendamento": {
+    "nova_solicitacao": "New Request",
+    "visao_geral": "Overview",
+    "aprovados_automaticamente": "Auto Approved",
+    "associado": "Associate",
+    "solicitar": "Request",
+    "confirm_approve": "Are you sure you want to approve this appointment?",
+    "confirm_reject": "Are you sure you want to reject this appointment?",
+    "status": {
+      "pendente": "Waiting",
+      "confirmado": "Approved",
+      "recusado": "Rejected",
+      "cancelado": "Cancelled",
+      "concluido": "Completed"
+    },
+    "col": {
+      "pedido": "Order",
+      "parceiro": "Partner",
+      "servico": "Service",
+      "solicitacao": "Request Date"
+    }
   }
 }

+ 26 - 0
src/i18n/locales/es.json

@@ -55,6 +55,9 @@
     },
     "relatorios": {
       "description": "Visualice reportes gerenciales del sistema"
+    },
+    "agendamentos": {
+      "description": "Gestione las citas de los asociados"
     }
   },
   "common": {
@@ -128,6 +131,7 @@
       "week": "Semana",
       "day": "Día",
       "hour": "Hora",
+      "hour2": "Horário",
       "minute": "Minuto",
       "second": "Segundo",
       "year": "Año",
@@ -645,5 +649,27 @@
       "exclusao": "Exclusión"
     },
     "exportar_excel": "Exportar Excel"
+  },
+  "agendamento": {
+    "nova_solicitacao": "Nueva Solicitud",
+    "visao_geral": "Visión General",
+    "aprovados_automaticamente": "Aprobados Automáticamente",
+    "associado": "Asociado",
+    "solicitar": "Solicitar",
+    "confirm_approve": "¿Estás seguro de que deseas aprobar esta cita?",
+    "confirm_reject": "¿Estás seguro de que deseas rechazar esta cita?",
+    "status": {
+      "pendente": "Esperando",
+      "confirmado": "Aprobado",
+      "recusado": "Rechazado",
+      "cancelado": "Cancelado",
+      "concluido": "Completado"
+    },
+    "col": {
+      "pedido": "Pedido",
+      "parceiro": "Socio",
+      "servico": "Servicio",
+      "solicitacao": "Solicitud"
+    }
   }
 }

+ 26 - 0
src/i18n/locales/pt.json

@@ -55,6 +55,9 @@
     },
     "relatorios": {
       "description": "Visualize relatórios gerenciais do sistema"
+    },
+    "agendamentos": {
+      "description": "Gerencie os agendamentos dos associados"
     }
   },
   "common": {
@@ -128,6 +131,7 @@
       "week": "Semana",
       "day": "Dia",
       "hour": "Hora",
+      "hour2": "Horário",
       "minute": "Minuto",
       "second": "Segundo",
       "year": "Ano",
@@ -646,5 +650,27 @@
       "exclusao": "Exclusão"
     },
     "exportar_excel": "Exportar Excel"
+  },
+  "agendamento": {
+    "nova_solicitacao": "Nova Solicitação",
+    "visao_geral": "Visão Geral de Agendamentos",
+    "aprovados_automaticamente": "Aprovados Automaticamente",
+    "associado": "Associado",
+    "solicitar": "Solicitar",
+    "confirm_approve": "Tem certeza que deseja aprovar este agendamento?",
+    "confirm_reject": "Tem certeza que deseja recusar este agendamento?",
+    "status": {
+      "pendente": "Aguardando",
+      "confirmado": "Aprovado",
+      "recusado": "Recusado",
+      "cancelado": "Cancelado",
+      "concluido": "Concluído"
+    },
+    "col": {
+      "pedido": "Pedido",
+      "parceiro": "Parceiro",
+      "servico": "Serviço",
+      "solicitacao": "Solicitação"
+    }
   }
 }

+ 447 - 0
src/pages/agendamentos/AppointmentsAdminPage.vue

@@ -0,0 +1,447 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+
+    <div class="row q-gutter-xs q-mb-md flex-wrap">
+      <div
+        v-for="tab in tabs"
+        :key="tab.name"
+        :class="['cat-chip', activeTab === tab.name ? 'cat-chip--selected' : 'cat-chip--default']"
+        @click="selectTab(tab.name)"
+      >
+        <span v-if="tab.counterKey !== null" class="cat-chip-count q-mr-xs">
+          {{ counters[tab.counterKey] !== undefined ? counters[tab.counterKey] : '—' }}
+        </span>
+        {{ tab.label }}
+      </div>
+    </div>
+
+    <div v-if="activeTab === 'nova-solicitacao'" class="q-mt-md">
+      <q-card flat bordered>
+        <q-card-section>
+          <q-form ref="formRef" @submit="submitAppointment">
+            <div class="row q-col-gutter-sm">
+              <AssociadoSelect
+                v-model="form.associado"
+                :label="$t('agendamento.associado')"
+                :rules="[inputRules.required]"
+                class="col-12 input-violet"
+              />
+              <PartnerAgreementSelect
+                v-model="form.partner"
+                :label="$t('ui.navigation.convenios')"
+                :rules="[inputRules.required]"
+                class="col-12 input-violet"
+              />
+              <PartnerAgreementServiceSelect
+                v-model="form.service"
+                :partner-agreement-id="form.partner?.value"
+                :label="$t('associado.service')"
+                :rules="[inputRules.required]"
+                class="col-12 input-violet"
+              />
+              <DefaultInputDatePicker
+                v-model:untreated-date="form.date"
+                :label="$t('common.terms.date')"
+                :rules="[inputRules.required]"
+                placeholder="dd/mm/aaaa"
+                lazy-rules
+                class="col-12 col-md-6"
+              />
+              <DefaultInput
+                v-model="form.time"
+                :label="$t('common.terms.hour2')"
+                :rules="[inputRules.required]"
+                mask="##:##"
+                placeholder="HH:MM"
+                class="col-12 col-md-6"
+              />
+              <DefaultInput
+                v-model="form.observations"
+                :label="$t('associado.notes')"
+                type="textarea"
+                autogrow
+                class="col-12"
+              />
+            </div>
+            <div class="q-mt-md flex justify-end">
+              <q-btn
+                color="primary"
+                type="submit"
+                :label="$t('agendamento.solicitar')"
+                :loading="submitting"
+              />
+            </div>
+          </q-form>
+        </q-card-section>
+      </q-card>
+    </div>
+
+    <div v-else-if="activeTab === 'visao-geral'" class="q-mt-md">
+      <div class="counters-row q-mb-md">
+        <div class="counter-card text-primary">
+          <span class="counter-value">
+            <q-icon
+              name="mdi-clock"
+              color="primary"
+            />
+            {{ counters.pendentes !== undefined ? counters.pendentes : '—' }}
+          </span>
+          <span class="counter-label">{{ $t("agendamento.status.pendente") }}</span>
+        </div>
+        <div class="counter-card text-primary">
+          <span class="counter-value">
+            <q-icon
+              name="mdi-check"
+              color="primary"
+            />
+            {{ counters.aprovados !== undefined ? counters.aprovados : '—' }}  
+          </span>
+          <span class="counter-label">{{ $t("agendamento.status.confirmado") }}</span>
+        </div>
+        <div class="counter-card text-primary">
+          <span class="counter-value">
+            <q-icon
+              name="mdi-close"
+              color="primary"
+            />
+            {{ counters.recusados !== undefined ? counters.recusados : '—' }}
+          </span>
+          <span class="counter-label">{{ $t("agendamento.status.recusado") }}</span>
+        </div>
+      </div>
+
+      <DefaultTableServerSide
+        :key="tableKey"
+        :columns="columnsVisaoGeral"
+        :api-call="apiFetchAll"
+        :add-item="false"
+        :show-search-field="true"
+      >
+        <template #body-cell-status="{ row }">
+          <q-td class="text-center">
+            <q-chip
+              outline
+              :color="statusColor(row.status)"
+              :label="$t(`agendamento.status.${row.status}`)"
+              size="sm"
+            />
+          </q-td>
+        </template>
+        <template #body-cell-acoes="{ row }">
+          <q-td auto-width>
+            <div class="row no-wrap items-center" style="gap: 4px">
+              <q-btn
+                dense
+                round
+                icon="mdi-check"
+                color="positive"
+                size="sm"
+                :unelevated="row.status === 'confirmado'"
+                :outline="row.status !== 'confirmado'"
+                :loading="actionId === row.id && actionType === 'approve'"
+                @click.prevent.stop="onApprove(row)"
+              />
+              <q-btn
+                dense
+                round
+                icon="mdi-close"
+                color="negative"
+                size="sm"
+                :unelevated="row.status === 'recusado' || row.status === 'cancelado'"
+                :outline="row.status !== 'recusado' && row.status !== 'cancelado'"
+                :loading="actionId === row.id && actionType === 'reject'"
+                @click.prevent.stop="onReject(row)"
+              />
+            </div>
+          </q-td>
+        </template>
+      </DefaultTableServerSide>
+    </div>
+
+    <div v-else-if="activeTab === 'aprovados'" class="q-mt-md">
+      <DefaultTableServerSide
+        :key="tableKey"
+        :columns="columnsAprovados"
+        :api-call="apiFetchAprovados"
+        :add-item="false"
+        :show-search-field="true"
+      >
+        <template #body-cell-status>
+          <q-td class="text-center">
+            <q-chip
+              outline
+              color="positive"
+              :label="$t('agendamento.status.confirmado')"
+              size="sm"
+            />
+          </q-td>
+        </template>
+      </DefaultTableServerSide>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { useInputRules } from "src/composables/useInputRules";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTableServerSide from "src/components/defaults/DefaultTableServerSide.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import AssociadoSelect from "src/components/selects/AssociadoSelect.vue";
+import PartnerAgreementSelect from "src/components/selects/PartnerAgreementSelect.vue";
+import PartnerAgreementServiceSelect from "src/components/selects/PartnerAgreementServiceSelect.vue";
+
+import {
+  getAdminCounters,
+  getAdminAppointmentsPaginated,
+  createAppointment,
+  approveAppointment,
+  rejectAppointment,
+} from "src/api/appointment";
+
+const $q = useQuasar();
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+const formRef = useTemplateRef("formRef");
+
+const activeTab = ref("nova-solicitacao");
+const tableKey = ref(0);
+const submitting = ref(false);
+const actionId = ref(null);
+const actionType = ref(null);
+
+const counters = ref({
+  pendentes: undefined,
+  aprovados: undefined,
+  recusados: undefined,
+});
+
+const form = ref({
+  associado: null,
+  partner: null,
+  service: null,
+  date: "",
+  time: "",
+  observations: "",
+});
+
+const tabs = computed(() => [
+  {
+    name: "nova-solicitacao",
+    label: t("agendamento.nova_solicitacao"),
+    icon: "mdi-calendar-plus-outline",
+    counterKey: null,
+  },
+  {
+    name: "visao-geral",
+    label: t("agendamento.visao_geral"),
+    icon: "mdi-calendar-check-outline",
+    counterKey: "pendentes",
+  },
+  {
+    name: "aprovados",
+    label: t("agendamento.aprovados_automaticamente"),
+    icon: "mdi-calendar-star-outline",
+    counterKey: "aprovados",
+  },
+]);
+
+const columnsVisaoGeral = computed(() => [
+  { name: "order_number", label: t("agendamento.col.pedido"), field: "order_number", align: "left" },
+  { name: "cracha", label: t("associado.cracha"), field: "registration", align: "left" },
+  { name: "user_name", label: t("common.terms.name"), field: "user_name", align: "left" },
+  { name: "partner_name", label: t("agendamento.col.parceiro"), field: "partner_name", align: "left" },
+  { name: "service_name", label: t("agendamento.col.servico"), field: "service_name", align: "left" },
+  { name: "requested_at", label: t("agendamento.col.solicitacao"), field: "requested_at", align: "left" },
+  { name: "acoes", label: t("common.terms.actions"), field: "acoes", align: "center" },
+  { name: "status", label: t("common.terms.status"), field: "status", align: "center" },
+]);
+
+const columnsAprovados = computed(() => [
+  { name: "order_number", label: t("agendamento.col.pedido"), field: "order_number", align: "left" },
+  { name: "user_name", label: t("common.terms.name"), field: "user_name", align: "left" },
+  { name: "partner_name", label: t("agendamento.col.parceiro"), field: "partner_name", align: "left" },
+  { name: "service_name", label: t("agendamento.col.servico"), field: "service_name", align: "left" },
+  { name: "requested_at", label: t("agendamento.col.solicitacao"), field: "requested_at", align: "left" },
+  { name: "status", label: t("common.terms.status"), field: "status", align: "center" },
+]);
+
+const statusColor = (status) => {
+  const map = {
+    pendente: "warning",
+    confirmado: "positive",
+    recusado: "negative",
+    cancelado: "grey-6",
+    concluido: "info",
+  };
+  return map[status] ?? "grey-6";
+};
+
+const apiFetchAll = (params) => getAdminAppointmentsPaginated(params);
+const apiFetchAprovados = (params) =>
+  getAdminAppointmentsPaginated({ ...params, status: "confirmado" });
+
+const selectTab = (name) => {
+  activeTab.value = name;
+  tableKey.value++;
+};
+
+const loadCounters = async () => {
+  try {
+    counters.value = await getAdminCounters();
+  } catch {
+    // silent — counters show '—'
+  }
+};
+
+const resetForm = () => {
+  form.value = {
+    associado: null,
+    partner: null,
+    service: null,
+    date: "",
+    time: "",
+    observations: "",
+  };
+  formRef.value?.resetValidation();
+};
+
+const submitAppointment = async () => {
+  const valid = await formRef.value?.validate();
+  if (!valid) return;
+  submitting.value = true;
+  try {
+    await createAppointment({
+      user_id: form.value.associado.value,
+      partner_agreement_id: form.value.partner.value,
+      partner_agreement_service_id: form.value.service.value,
+      time: form.value.time,
+      date: form.value.date,
+      observations: form.value.observations || null,
+    });
+    $q.notify({ type: "positive", message: t("http.success") });
+    resetForm();
+    await loadCounters();
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    submitting.value = false;
+  }
+};
+
+const onApprove = (row) => {
+  if (row.status !== "pendente") return;
+  $q.dialog({
+    title: t("common.ui.messages.confirm_action"),
+    message: t("agendamento.confirm_approve"),
+    cancel: true,
+    persistent: true,
+  }).onOk(async () => {
+    actionId.value = row.id;
+    actionType.value = "approve";
+    try {
+      await approveAppointment(row.id);
+      tableKey.value++;
+      await loadCounters();
+      $q.notify({ type: "positive", message: t("http.success") });
+    } catch {
+      $q.notify({ type: "negative", message: t("http.errors.failed") });
+    } finally {
+      actionId.value = null;
+      actionType.value = null;
+    }
+  });
+};
+
+const onReject = (row) => {
+  if (row.status !== "pendente") return;
+  $q.dialog({
+    title: t("common.ui.messages.confirm_action"),
+    message: t("agendamento.confirm_reject"),
+    cancel: true,
+    persistent: true,
+  }).onOk(async () => {
+    actionId.value = row.id;
+    actionType.value = "reject";
+    try {
+      await rejectAppointment(row.id);
+      tableKey.value++;
+      await loadCounters();
+      $q.notify({ type: "positive", message: t("http.success") });
+    } catch {
+      $q.notify({ type: "negative", message: t("http.errors.failed") });
+    } finally {
+      actionId.value = null;
+      actionType.value = null;
+    }
+  });
+};
+
+onMounted(() => {
+  loadCounters();
+});
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss" as vars;
+
+.cat-chip {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 28px;
+  padding: 0 12px;
+  border-radius: 5px;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  user-select: none;
+  transition: background 0.15s, color 0.15s;
+
+  &--default {
+    background: #c9a3dc;
+    color: #fff;
+  }
+
+  &--selected {
+    background: #4d1658;
+    color: #fff;
+  }
+}
+
+.cat-chip-count {
+  font-weight: 700;
+}
+
+.counters-row {
+  display: flex;
+  gap: 12px;
+}
+
+.counter-card {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 16px;
+  border-radius: 8px;
+  border: 1.5px solid vars.$color-border;
+  background: vars.$surface;
+}
+
+.counter-value {
+  font-size: 1.75rem;
+  font-weight: 700;
+  line-height: 1;
+  margin-bottom: 4px;
+}
+
+.counter-label {
+  font-size: 0.85rem;
+}
+</style>

+ 3 - 3
src/pages/city/CityPage.vue

@@ -43,7 +43,7 @@
   </div>
 </template>
 <script setup>
-import { defineAsyncComponent, useTemplateRef } from "vue";
+import { computed, defineAsyncComponent, useTemplateRef } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { permissionStore } from "src/stores/permission";
@@ -61,7 +61,7 @@ const $q = useQuasar();
 const tableRef = useTemplateRef("tableRef");
 const { t } = useI18n();
 
-const columns = [
+const columns = computed(() => [
   {
     name: "id",
     label: "ID",
@@ -107,7 +107,7 @@ const columns = [
     align: "left",
     required: true,
   },
-];
+]);
 
 const onRowClick = (row) => {
   if (permission_store.getAccess("config.city", "edit") === false) {

+ 3 - 3
src/pages/country/CountryPage.vue

@@ -42,7 +42,7 @@
   </div>
 </template>
 <script setup>
-import { defineAsyncComponent, useTemplateRef } from "vue";
+import { computed, defineAsyncComponent, useTemplateRef } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { permissionStore } from "src/stores/permission";
@@ -60,7 +60,7 @@ const $q = useQuasar();
 const tableRef = useTemplateRef("tableRef");
 const { t } = useI18n();
 
-const columns = [
+const columns = computed(() => [
   {
     name: "id",
     label: "ID",
@@ -99,7 +99,7 @@ const columns = [
     align: "left",
     required: true,
   },
-];
+]);
 
 const onRowClick = (row) => {
   if (permission_store.getAccess("config.country", "edit") === false) {

+ 3 - 3
src/pages/gestao-associados/GestaoAssociadosPage.vue

@@ -60,7 +60,7 @@
 </template>
 
 <script setup>
-import { ref, defineAsyncComponent } from "vue";
+import { ref, computed, defineAsyncComponent } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { permissionStore } from "src/stores/permission";
@@ -78,7 +78,7 @@ const $q = useQuasar();
 const { t } = useI18n();
 const tableRef = ref(null);
 
-const columns = [
+const columns = computed(() => [
   {
     name: "registration",
     label: t("associado.cracha"),
@@ -134,7 +134,7 @@ const columns = [
     sortable: true,
     width: "5%",
   },
-];
+]);
 
 const onAddItem = async () => {
   if (!permission_store.getAccess("associado", "add")) {

+ 25 - 10
src/pages/notificacoes/NotificationsAdminPage.vue

@@ -2,18 +2,15 @@
   <div class="notifications-page">
     <DefaultHeaderPage />
 
-    <div class="flex q-gutter-xs q-mb-md">
-      <q-chip
+    <div class="row q-gutter-xs q-mb-md flex-wrap">
+      <div
         v-for="tab in tabs"
         :key="tab.name"
-        clickable
-        :color="activeTab === tab.name ? 'violet-normal' : 'violet-light'"
-        :text-color="activeTab === tab.name ? 'white' : 'violet-normal'"
-        class="notifications-page__tab-chip"
+        :class="['cat-chip', activeTab === tab.name ? 'cat-chip--selected' : 'cat-chip--default']"
         @click="activeTab = tab.name"
       >
         {{ tab.label }}
-      </q-chip>
+      </div>
     </div>
 
     <q-tab-panels v-model="activeTab" animated>
@@ -52,9 +49,27 @@ const onNotificationSent = async () => {
 </script>
 
 <style scoped lang="scss">
-.notifications-page__tab-chip {
-  font-size: 13px;
-  border-radius: 20px;
+.cat-chip {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 28px;
+  padding: 0 12px;
+  border-radius: 5px;
+  font-size: 12px;
+  font-weight: 500;
   cursor: pointer;
+  user-select: none;
+  transition: background 0.15s, color 0.15s;
+
+  &--default {
+    background: #c9a3dc;
+    color: #fff;
+  }
+
+  &--selected {
+    background: #4d1658;
+    color: #fff;
+  }
 }
 </style>

+ 29 - 17
src/pages/parceiros-convenios/ParceirosConveniosPage.vue

@@ -31,27 +31,21 @@
       </q-input>
     </div>
 
-    <div class="parceiros-page__chips q-pb-md">
-      <q-chip
-        clickable
-        :outline="activeCategory !== 'all'"
-        color="violet-normal"
-        text-color="white"
+    <div class="row q-gutter-xs q-mb-md flex-wrap">
+      <div
+        :class="['cat-chip', activeCategory === 'all' ? 'cat-chip--selected' : 'cat-chip--default']"
         @click="activeCategory = 'all'"
       >
         {{ $t('common.terms.all') }}
-      </q-chip>
-      <q-chip
+      </div>
+      <div
         v-for="cat in categories"
         :key="cat.id"
-        clickable
-        :outline="activeCategory !== String(cat.id)"
-        color="violet-normal"
-        text-color="white"
+        :class="['cat-chip', activeCategory === String(cat.id) ? 'cat-chip--selected' : 'cat-chip--default']"
         @click="activeCategory = String(cat.id)"
       >
         {{ cat.name }}
-      </q-chip>
+      </div>
     </div>
 
     <div v-if="loading" class="flex flex-center q-pa-xl">
@@ -166,10 +160,28 @@ onMounted(async () => {
   width: 100%;
 }
 
-.parceiros-page__chips {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 4px;
+.cat-chip {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 28px;
+  padding: 0 12px;
+  border-radius: 5px;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  user-select: none;
+  transition: background 0.15s, color 0.15s;
+}
+
+.cat-chip--default {
+  background: #c9a3dc;
+  color: #fff;
+}
+
+.cat-chip--selected {
+  background: #4d1658;
+  color: #fff;
 }
 
 .btn-gradient {

+ 9 - 9
src/pages/relatorios/RelatoriosPage.vue

@@ -65,7 +65,7 @@
 </template>
 
 <script setup>
-import { ref, onMounted } from "vue";
+import { ref, computed, onMounted } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
@@ -90,7 +90,7 @@ const counters = ref({
   exclusoes_mes: undefined,
 });
 
-const tabs = [
+const tabs = computed(() => [
   {
     name: "novos-associados",
     label: t("relatorio.novos_associados"),
@@ -109,9 +109,9 @@ const tabs = [
     icon: "mdi-account-remove-outline",
     counterKey: "exclusoes_mes",
   },
-];
+]);
 
-const columnsNovosAssociados = [
+const columnsNovosAssociados = computed(() => [
   {
     name: "name",
     label: t("common.terms.name"),
@@ -133,9 +133,9 @@ const columnsNovosAssociados = [
     align: "left",
     sortable: false,
   },
-];
+]);
 
-const columnsContatos = [
+const columnsContatos = computed(() => [
   {
     name: "name",
     label: t("common.terms.name"),
@@ -157,9 +157,9 @@ const columnsContatos = [
     align: "left",
     sortable: false,
   },
-];
+]);
 
-const columnsExclusoes = [
+const columnsExclusoes = computed(() => [
   {
     name: "name",
     label: t("common.terms.name"),
@@ -181,7 +181,7 @@ const columnsExclusoes = [
     align: "left",
     sortable: false,
   },
-];
+]);
 
 const selectTab = (name) => {
   activeTab.value = name;

+ 3 - 3
src/pages/state/StatePage.vue

@@ -42,7 +42,7 @@
   </div>
 </template>
 <script setup>
-import { defineAsyncComponent, useTemplateRef } from "vue";
+import { computed, defineAsyncComponent, useTemplateRef } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { permissionStore } from "src/stores/permission";
@@ -60,7 +60,7 @@ const $q = useQuasar();
 const tableRef = useTemplateRef("tableRef");
 const { t } = useI18n();
 
-const columns = [
+const columns = computed(() => [
   {
     name: "id",
     label: "ID",
@@ -99,7 +99,7 @@ const columns = [
     align: "left",
     required: true,
   },
-];
+]);
 
 const onRowClick = (row) => {
   if (permission_store.getAccess("config.state", "edit") === false) {

+ 3 - 3
src/pages/users/UsersPage.vue

@@ -43,7 +43,7 @@
 </template>
 
 <script setup>
-import { defineAsyncComponent, useTemplateRef } from "vue";
+import { computed, defineAsyncComponent, useTemplateRef } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { permissionStore } from "src/stores/permission";
@@ -61,7 +61,7 @@ const $q = useQuasar();
 const { t } = useI18n();
 const tableRef = useTemplateRef("tableRef");
 
-const columns = [
+const columns = computed(() => [
   {
     name: "name",
     label: t("common.terms.name"),
@@ -83,7 +83,7 @@ const columns = [
     align: "left",
     required: true,
   },
-];
+]);
 
 const onRowClick = (row) => {
   if (permission_store.getAccess("config.user", "view") === false) {

+ 20 - 0
src/router/routes/agendamento-admin.route.js

@@ -0,0 +1,20 @@
+export default [
+  {
+    path: "/agendamentos",
+    name: "AppointmentsPage",
+    component: () => import("pages/agendamentos/AppointmentsAdminPage.vue"),
+    meta: {
+      title: { value: "ui.navigation.appointments", translate: true },
+      description: { value: "page.agendamentos.description", translate: true },
+      requireAuth: true,
+      requiredPermission: "agendamento",
+      breadcrumbs: [
+        {
+          name: "AppointmentsPage",
+          title: "ui.navigation.appointments",
+          translate: true,
+        },
+      ],
+    },
+  },
+];