| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544 |
- <template>
- <div class="default-header-page">
- <q-breadcrumbs
- v-if="displayBreadcrumbs.length"
- class="q-mb-xs text-secondary"
- :class="$q.screen.lt.sm ? '' : 'q-pl-lg'"
- >
- <q-breadcrumbs-el
- v-for="crumb in displayBreadcrumbs"
- :key="crumb.name || crumb.label"
- :label="crumb.title"
- :to="crumb.name ? { name: crumb.name, params: crumb.params } : null"
- />
- </q-breadcrumbs>
- <div class="flex items-center justify-between">
- <div class="flex items-center" :class="$q.screen.lt.sm ? '' : 'q-pt-md'">
- <span
- v-if="displayTitle"
- class="text-h6 text-primary text-weight-regular"
- >
- {{ displayTitle }}
- </span>
- <div v-else style="width: 280px">
- <q-skeleton type="text" height="40px" />
- </div>
- <q-icon
- v-if="showFilterIcon"
- name="mdi-filter-outline"
- :color="filterOpen ? 'background' : 'primary'"
- size="sm"
- class="q-ml-sm cursor-pointer"
- :class="filterOpen ? 'bg-primary' : ''"
- :style="
- filterOpen
- ? 'border-radius: 8px; padding: 2px'
- : 'border-radius: 8px; padding: 2px; border: 1.5px solid var(--q-primary)'
- "
- @click="$emit('show-filter')"
- />
- </div>
- <div
- class="flex items-center q-pr-sm"
- :class="$q.screen.lt.sm ? '' : 'q-pt-md'"
- style="gap: 8px"
- >
- <slot name="after" />
- <div class="flex items-center no-wrap" style="gap: 12px">
- <!-- Seletor de unidade: aparece apenas quando o usuário tem mais de uma -->
- <template v-if="userUnits.length > 1">
- <DefaultSelect
- :model-value="store.selectedUnit"
- :options="userUnits"
- option-value="id"
- :option-label="getUnitLabel"
- label="Unidade"
- outlined
- style="width: 280px; flex-shrink: 0"
- @update:model-value="store.setSelectedUnit"
- />
- <q-spinner
- v-if="isUnitLoading"
- color="primary"
- size="18px"
- style="flex-shrink: 0"
- />
- </template>
- <template v-if="$q.screen.gt.sm && lastLoginFormatted">
- <div
- class="column"
- style="line-height: 1.2; white-space: nowrap; flex-shrink: 0"
- >
- <span class="text-caption text-grey-6 text-primary text-center"
- >Ultimo acesso</span
- >
- <span class="text-caption text-primary text-center">{{
- lastLoginFormatted
- }}</span>
- </div>
- </template>
- <div
- class="flex items-center no-wrap"
- style="gap: 2px; flex-shrink: 0"
- >
- <!-- Notificações -->
- <q-btn flat round dense icon="mdi-bell-badge" color="secondary">
- <q-badge
- v-if="unreadCount > 0"
- color="negative"
- floating
- rounded
- :label="unreadCount"
- />
- <q-menu
- anchor="bottom right"
- self="top right"
- :offset="[0, 8]"
- class="header-menu"
- @before-show="loadNotifications"
- >
- <div class="notifications-panel">
- <div class="row items-center justify-between q-px-md q-py-sm">
- <span class="text-subtitle2 text-primary text-weight-medium">
- Notificações
- </span>
- <q-btn
- flat
- dense
- no-caps
- size="sm"
- color="secondary"
- label="Marcar tudo como lido"
- :disable="unreadCount === 0"
- @click="markAllAsRead"
- />
- </div>
- <q-separator />
- <q-scroll-area
- v-if="notifications.length"
- style="height: 360px"
- :thumb-style="{ width: '5px', borderRadius: '4px' }"
- >
- <q-list separator>
- <q-item
- v-for="n in notifications"
- :key="n.id"
- v-close-popup
- clickable
- :class="!n.is_read ? 'bg-grey-2' : ''"
- @click="openNotification(n)"
- >
- <q-item-section avatar top>
- <q-icon
- :name="iconFor(n.notification_type)"
- :color="n.is_read ? 'grey-5' : 'secondary'"
- size="24px"
- />
- </q-item-section>
- <q-item-section>
- <q-item-label
- class="text-primary"
- :class="!n.is_read ? 'text-weight-medium' : ''"
- >
- {{ n.title }}
- </q-item-label>
- <q-item-label caption lines="2">
- {{ n.message }}
- </q-item-label>
- <q-item-label caption class="text-grey-6 q-mt-xs">
- {{ formatNotificationDate(n.created_at) }}
- </q-item-label>
- </q-item-section>
- <q-item-section v-if="!n.is_read" side top>
- <q-badge
- color="negative"
- rounded
- style="padding: 4px"
- />
- </q-item-section>
- </q-item>
- </q-list>
- </q-scroll-area>
- <div
- v-else
- class="column items-center justify-center q-pa-lg text-grey-6"
- >
- <q-icon name="mdi-bell-off-outline" size="32px" />
- <span class="text-caption q-mt-sm">Sem notificações</span>
- </div>
- </div>
- </q-menu>
- </q-btn>
- <!-- Perfil do usuário -->
- <q-btn flat round dense icon="mdi-account" color="secondary">
- <q-menu
- anchor="bottom right"
- self="top right"
- :offset="[0, 8]"
- class="header-menu"
- >
- <div class="profile-panel q-pa-md">
- <div class="row items-center no-wrap q-gutter-x-md">
- <q-img
- :src="user?.avatar_url || 'icons/user-icon.jpg'"
- class="avatar-circle"
- />
- <div class="column" style="min-width: 0">
- <span class="text-body2 text-primary text-weight-medium ellipsis">
- {{ user?.name || "Usuário" }}
- </span>
- <span class="text-caption text-grey-6 ellipsis">
- {{ user?.email }}
- </span>
- </div>
- </div>
- <q-separator class="q-my-md" />
- <q-btn
- v-close-popup
- flat
- no-caps
- align="left"
- class="full-width"
- color="primary"
- icon="mdi-account-edit-outline"
- label="Editar perfil"
- @click="goToEditProfile"
- />
- </div>
- </q-menu>
- </q-btn>
- <!-- Configurações -->
- <q-btn flat round dense icon="mdi-cog-outline" color="secondary">
- <q-menu
- anchor="bottom right"
- self="top right"
- :offset="[0, 8]"
- class="header-menu"
- @before-show="loadUnitDetails"
- >
- <div class="settings-panel q-pa-md">
- <div class="column q-gutter-y-sm">
- <div>
- <span class="text-caption text-grey-6">Nome fantasia</span>
- <div class="text-body2 text-primary text-weight-medium ellipsis">
- {{ unitInfo.fantasy_name || "Não informado" }}
- </div>
- </div>
- <div>
- <span class="text-caption text-grey-6">Razão social</span>
- <div class="text-body2 text-primary text-weight-medium ellipsis">
- {{ unitInfo.social_reason || "Não informado" }}
- </div>
- </div>
- <div>
- <span class="text-caption text-grey-6">Responsável pela unidade</span>
- <div class="text-body2 text-primary text-weight-medium ellipsis">
- {{ unitInfo.name_responsible || "Não informado" }}
- </div>
- </div>
- </div>
- <q-separator class="q-my-md" />
- <q-btn
- v-close-popup
- flat
- no-caps
- align="left"
- class="full-width"
- color="primary"
- icon="mdi-store-edit-outline"
- label="Editar unidade"
- @click="goToEditUnit"
- />
- <q-btn
- v-close-popup
- flat
- no-caps
- align="left"
- class="full-width q-mt-xs"
- color="negative"
- icon="mdi-logout"
- label="Sair"
- @click="logoutFn"
- />
- </div>
- </q-menu>
- </q-btn>
- </div>
- </div>
- </div>
- </div>
- <q-separator class="q-my-sm" />
- </div>
- </template>
- <script setup>
- import { computed, ref, watch, onMounted, onBeforeUnmount } from "vue";
- import { useRoute, useRouter } from "vue-router";
- import { useI18n } from "vue-i18n";
- import { userStore } from "src/stores/user";
- import { useAuth } from "src/composables/useAuth";
- import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
- import { getUnitMe } from "src/api/unit";
- import {
- getMyNotifications,
- getUnreadCount,
- markNotificationRead,
- markAllNotificationsRead,
- } from "src/api/notification";
- import { socket, joinRoom, leaveRoom } from "src/boot/socket.io";
- const { breadcrumbs, filterOpen, showFilterIcon, title } = defineProps({
- breadcrumbs: {
- type: Array,
- default: null,
- },
- filterOpen: {
- type: Boolean,
- default: false,
- },
- showFilterIcon: {
- type: Boolean,
- default: false,
- },
- title: {
- type: [String, Object],
- default: null,
- },
- });
- defineEmits(["show-filter"]);
- const route = useRoute();
- const router = useRouter();
- const { t } = useI18n();
- const store = userStore();
- const { logout } = useAuth();
- const userUnits = computed(() => store.user?.units ?? []);
- const user = computed(() => store.user);
- const unitDetails = ref(null);
- const unitInfo = computed(() => unitDetails.value || store.selectedUnit || {});
- const getUnitLabel = (unit) =>
- unit?.fantasy_name || unit?.social_reason || "Unidade sem nome";
- // ------------------- Notificações (sino) -------------------
- const notifications = ref([]);
- const unreadCount = ref(0);
- const NOTIFICATION_ICONS = {
- support_ticket_created: "mdi-lifebuoy",
- support_ticket_reply: "mdi-message-reply-text-outline",
- kanban_created: "mdi-clipboard-plus-outline",
- kanban_reply: "mdi-comment-text-outline",
- kanban_status_changed: "mdi-swap-horizontal",
- tbr_month_closed: "mdi-cash-multiple",
- account_payable_created: "mdi-file-document-plus-outline",
- payment_credited: "mdi-cash-check",
- };
- const iconFor = (type) => NOTIFICATION_ICONS[type] || "mdi-bell-outline";
- const loadUnreadCount = async () => {
- try {
- unreadCount.value = await getUnreadCount();
- } catch (e) {
- console.error("Falha ao carregar contador de notificações:", e);
- }
- };
- const loadNotifications = async () => {
- try {
- notifications.value = await getMyNotifications();
- } catch (e) {
- console.error("Falha ao carregar notificações:", e);
- }
- };
- const markAllAsRead = async () => {
- try {
- await markAllNotificationsRead();
- notifications.value.forEach((n) => (n.is_read = true));
- unreadCount.value = 0;
- } catch (e) {
- console.error("Falha ao marcar todas como lidas:", e);
- }
- };
- const openNotification = async (n) => {
- if (!n.is_read) {
- try {
- await markNotificationRead(n.id);
- n.is_read = true;
- unreadCount.value = Math.max(0, unreadCount.value - 1);
- } catch (e) {
- console.error("Falha ao marcar como lida:", e);
- }
- }
- if (n.url) router.push(n.url);
- };
- const formatNotificationDate = (raw) => {
- if (!raw) return "";
- const d = new Date(String(raw).replace(" ", "T"));
- return new Intl.DateTimeFormat("pt-BR", {
- day: "2-digit",
- month: "2-digit",
- year: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- }).format(d);
- };
- const userRoom = computed(() =>
- store.user?.id ? `user.${store.user.id}` : null,
- );
- const onRealtimeNotification = () => {
- loadUnreadCount();
- loadNotifications();
- };
- onMounted(() => {
- loadUnreadCount();
- if (userRoom.value) {
- joinRoom(userRoom.value);
- socket.on("notification", onRealtimeNotification);
- }
- });
- onBeforeUnmount(() => {
- if (userRoom.value) {
- socket.off("notification", onRealtimeNotification);
- leaveRoom(userRoom.value);
- }
- });
- const isUnitLoading = ref(false);
- let loadingTimer = null;
- watch(
- () => store.selectedUnit?.id,
- (newId, oldId) => {
- if (newId && oldId !== undefined && newId !== oldId) {
- isUnitLoading.value = true;
- clearTimeout(loadingTimer);
- loadingTimer = setTimeout(() => {
- isUnitLoading.value = false;
- }, 1500);
- }
- },
- );
- const lastLoginFormatted = computed(() => {
- const raw = store.user?.last_login_at;
- if (!raw) return null;
- const d = new Date(raw.replace(" ", "T"));
- return new Intl.DateTimeFormat("pt-BR", {
- day: "2-digit",
- month: "2-digit",
- year: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- timeZone: "America/Fortaleza",
- }).format(d);
- });
- const loadUnitDetails = async () => {
- try {
- unitDetails.value = await getUnitMe();
- } catch (e) {
- unitDetails.value = store.selectedUnit;
- console.error("Falha ao carregar dados da unidade:", e);
- }
- };
- const goToEditProfile = () => {
- if (!user.value?.id) return;
- router.push({ name: "UserEditPage", params: { id: user.value.id } });
- };
- const goToEditUnit = () => {
- router.push({ name: "UnitDataPage" });
- };
- const logoutFn = async () => {
- await logout();
- router.push({ name: "LoginPage" });
- };
- const displayTitle = computed(() => {
- if (title) {
- if (typeof title === "string") return title;
- if (title.translate) return t(title.value);
- return title.value;
- } else if (route.meta?.title) {
- const metaTitle = route.meta.title;
- if (typeof metaTitle === "string") return metaTitle;
- if (metaTitle.translate) return t(metaTitle.value);
- return metaTitle.value;
- }
- return null;
- });
- const displayBreadcrumbs = computed(() => {
- const items = breadcrumbs?.length ? breadcrumbs : route.meta?.breadcrumbs;
- return (items || []).map((b) => ({
- ...b,
- title: b.translate ? t(b.title) : b.title,
- }));
- });
- </script>
- <style scoped>
- .default-header-page {
- padding-left: 4px;
- }
- .default-header-page :deep(.q-breadcrumbs) {
- display: inline-flex;
- max-width: 100%;
- }
- .default-header-page :deep(.q-breadcrumbs__el),
- .default-header-page :deep(.q-breadcrumbs__separator) {
- flex: 0 0 auto;
- width: auto;
- }
- .default-header-page :deep(.q-breadcrumbs__el) {
- max-width: max-content;
- }
- .header-menu {
- border-radius: 10px;
- }
- .notifications-panel {
- width: 340px;
- max-width: 90vw;
- }
- .avatar-circle {
- width: 36px;
- height: 36px;
- border-radius: 50%;
- flex-shrink: 0;
- }
- .profile-panel,
- .settings-panel {
- width: 280px;
- max-width: 90vw;
- }
- </style>
|