DefaultHeaderPage.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. <template>
  2. <div class="default-header-page">
  3. <q-breadcrumbs
  4. v-if="displayBreadcrumbs.length"
  5. class="q-mb-xs text-secondary"
  6. :class="$q.screen.lt.sm ? '' : 'q-pl-lg'"
  7. >
  8. <q-breadcrumbs-el
  9. v-for="crumb in displayBreadcrumbs"
  10. :key="crumb.name || crumb.label"
  11. :label="crumb.title"
  12. :to="crumb.name ? { name: crumb.name, params: crumb.params } : null"
  13. />
  14. </q-breadcrumbs>
  15. <div class="flex items-center justify-between">
  16. <div class="flex items-center" :class="$q.screen.lt.sm ? '' : 'q-pt-md'">
  17. <span
  18. v-if="displayTitle"
  19. class="text-h6 text-primary text-weight-regular"
  20. >
  21. {{ displayTitle }}
  22. </span>
  23. <div v-else style="width: 280px">
  24. <q-skeleton type="text" height="40px" />
  25. </div>
  26. <q-icon
  27. v-if="showFilterIcon"
  28. name="mdi-filter-outline"
  29. :color="filterOpen ? 'background' : 'primary'"
  30. size="sm"
  31. class="q-ml-sm cursor-pointer"
  32. :class="filterOpen ? 'bg-primary' : ''"
  33. :style="
  34. filterOpen
  35. ? 'border-radius: 8px; padding: 2px'
  36. : 'border-radius: 8px; padding: 2px; border: 1.5px solid var(--q-primary)'
  37. "
  38. @click="$emit('show-filter')"
  39. />
  40. </div>
  41. <div
  42. class="flex items-center q-pr-sm"
  43. :class="$q.screen.lt.sm ? '' : 'q-pt-md'"
  44. style="gap: 8px"
  45. >
  46. <slot name="after" />
  47. <div class="flex items-center no-wrap" style="gap: 12px">
  48. <!-- Seletor de unidade: aparece apenas quando o usuário tem mais de uma -->
  49. <template v-if="userUnits.length > 1">
  50. <DefaultSelect
  51. :model-value="store.selectedUnit"
  52. :options="userUnits"
  53. option-value="id"
  54. :option-label="getUnitLabel"
  55. label="Unidade"
  56. outlined
  57. style="width: 280px; flex-shrink: 0"
  58. @update:model-value="store.setSelectedUnit"
  59. />
  60. <q-spinner
  61. v-if="isUnitLoading"
  62. color="primary"
  63. size="18px"
  64. style="flex-shrink: 0"
  65. />
  66. </template>
  67. <template v-if="$q.screen.gt.sm && lastLoginFormatted">
  68. <div
  69. class="column"
  70. style="line-height: 1.2; white-space: nowrap; flex-shrink: 0"
  71. >
  72. <span class="text-caption text-grey-6 text-primary text-center"
  73. >Ultimo acesso</span
  74. >
  75. <span class="text-caption text-primary text-center">{{
  76. lastLoginFormatted
  77. }}</span>
  78. </div>
  79. </template>
  80. <div
  81. class="flex items-center no-wrap"
  82. style="gap: 2px; flex-shrink: 0"
  83. >
  84. <!-- Notificações -->
  85. <q-btn flat round dense icon="mdi-bell-badge" color="secondary">
  86. <q-badge
  87. v-if="unreadCount > 0"
  88. color="negative"
  89. floating
  90. rounded
  91. :label="unreadCount"
  92. />
  93. <q-menu
  94. anchor="bottom right"
  95. self="top right"
  96. :offset="[0, 8]"
  97. class="header-menu"
  98. @before-show="loadNotifications"
  99. >
  100. <div class="notifications-panel">
  101. <div class="row items-center justify-between q-px-md q-py-sm">
  102. <span class="text-subtitle2 text-primary text-weight-medium">
  103. Notificações
  104. </span>
  105. <q-btn
  106. flat
  107. dense
  108. no-caps
  109. size="sm"
  110. color="secondary"
  111. label="Marcar tudo como lido"
  112. :disable="unreadCount === 0"
  113. @click="markAllAsRead"
  114. />
  115. </div>
  116. <q-separator />
  117. <q-scroll-area
  118. v-if="notifications.length"
  119. style="height: 360px"
  120. :thumb-style="{ width: '5px', borderRadius: '4px' }"
  121. >
  122. <q-list separator>
  123. <q-item
  124. v-for="n in notifications"
  125. :key="n.id"
  126. v-close-popup
  127. clickable
  128. :class="!n.is_read ? 'bg-grey-2' : ''"
  129. @click="openNotification(n)"
  130. >
  131. <q-item-section avatar top>
  132. <q-icon
  133. :name="iconFor(n.notification_type)"
  134. :color="n.is_read ? 'grey-5' : 'secondary'"
  135. size="24px"
  136. />
  137. </q-item-section>
  138. <q-item-section>
  139. <q-item-label
  140. class="text-primary"
  141. :class="!n.is_read ? 'text-weight-medium' : ''"
  142. >
  143. {{ n.title }}
  144. </q-item-label>
  145. <q-item-label caption lines="2">
  146. {{ n.message }}
  147. </q-item-label>
  148. <q-item-label caption class="text-grey-6 q-mt-xs">
  149. {{ formatNotificationDate(n.created_at) }}
  150. </q-item-label>
  151. </q-item-section>
  152. <q-item-section v-if="!n.is_read" side top>
  153. <q-badge
  154. color="negative"
  155. rounded
  156. style="padding: 4px"
  157. />
  158. </q-item-section>
  159. </q-item>
  160. </q-list>
  161. </q-scroll-area>
  162. <div
  163. v-else
  164. class="column items-center justify-center q-pa-lg text-grey-6"
  165. >
  166. <q-icon name="mdi-bell-off-outline" size="32px" />
  167. <span class="text-caption q-mt-sm">Sem notificações</span>
  168. </div>
  169. </div>
  170. </q-menu>
  171. </q-btn>
  172. <!-- Perfil do usuário -->
  173. <q-btn flat round dense icon="mdi-account" color="secondary">
  174. <q-menu
  175. anchor="bottom right"
  176. self="top right"
  177. :offset="[0, 8]"
  178. class="header-menu"
  179. >
  180. <div class="profile-panel q-pa-md">
  181. <div class="row items-center no-wrap q-gutter-x-md">
  182. <q-img
  183. :src="user?.avatar_url || 'icons/user-icon.jpg'"
  184. class="avatar-circle"
  185. />
  186. <div class="column" style="min-width: 0">
  187. <span class="text-body2 text-primary text-weight-medium ellipsis">
  188. {{ user?.name || "Usuário" }}
  189. </span>
  190. <span class="text-caption text-grey-6 ellipsis">
  191. {{ user?.email }}
  192. </span>
  193. </div>
  194. </div>
  195. <q-separator class="q-my-md" />
  196. <q-btn
  197. v-close-popup
  198. flat
  199. no-caps
  200. align="left"
  201. class="full-width"
  202. color="primary"
  203. icon="mdi-account-edit-outline"
  204. label="Editar perfil"
  205. @click="goToEditProfile"
  206. />
  207. </div>
  208. </q-menu>
  209. </q-btn>
  210. <!-- Configurações -->
  211. <q-btn flat round dense icon="mdi-cog-outline" color="secondary">
  212. <q-menu
  213. anchor="bottom right"
  214. self="top right"
  215. :offset="[0, 8]"
  216. class="header-menu"
  217. @before-show="loadUnitDetails"
  218. >
  219. <div class="settings-panel q-pa-md">
  220. <div class="column q-gutter-y-sm">
  221. <div>
  222. <span class="text-caption text-grey-6">Nome fantasia</span>
  223. <div class="text-body2 text-primary text-weight-medium ellipsis">
  224. {{ unitInfo.fantasy_name || "Não informado" }}
  225. </div>
  226. </div>
  227. <div>
  228. <span class="text-caption text-grey-6">Razão social</span>
  229. <div class="text-body2 text-primary text-weight-medium ellipsis">
  230. {{ unitInfo.social_reason || "Não informado" }}
  231. </div>
  232. </div>
  233. <div>
  234. <span class="text-caption text-grey-6">Responsável pela unidade</span>
  235. <div class="text-body2 text-primary text-weight-medium ellipsis">
  236. {{ unitInfo.name_responsible || "Não informado" }}
  237. </div>
  238. </div>
  239. </div>
  240. <q-separator class="q-my-md" />
  241. <q-btn
  242. v-close-popup
  243. flat
  244. no-caps
  245. align="left"
  246. class="full-width"
  247. color="primary"
  248. icon="mdi-store-edit-outline"
  249. label="Editar unidade"
  250. @click="goToEditUnit"
  251. />
  252. <q-btn
  253. v-close-popup
  254. flat
  255. no-caps
  256. align="left"
  257. class="full-width q-mt-xs"
  258. color="negative"
  259. icon="mdi-logout"
  260. label="Sair"
  261. @click="logoutFn"
  262. />
  263. </div>
  264. </q-menu>
  265. </q-btn>
  266. </div>
  267. </div>
  268. </div>
  269. </div>
  270. <q-separator class="q-my-sm" />
  271. </div>
  272. </template>
  273. <script setup>
  274. import { computed, ref, watch, onMounted, onBeforeUnmount } from "vue";
  275. import { useRoute, useRouter } from "vue-router";
  276. import { useI18n } from "vue-i18n";
  277. import { userStore } from "src/stores/user";
  278. import { useAuth } from "src/composables/useAuth";
  279. import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
  280. import { getUnitMe } from "src/api/unit";
  281. import {
  282. getMyNotifications,
  283. getUnreadCount,
  284. markNotificationRead,
  285. markAllNotificationsRead,
  286. } from "src/api/notification";
  287. import { socket, joinRoom, leaveRoom } from "src/boot/socket.io";
  288. const { breadcrumbs, filterOpen, showFilterIcon, title } = defineProps({
  289. breadcrumbs: {
  290. type: Array,
  291. default: null,
  292. },
  293. filterOpen: {
  294. type: Boolean,
  295. default: false,
  296. },
  297. showFilterIcon: {
  298. type: Boolean,
  299. default: false,
  300. },
  301. title: {
  302. type: [String, Object],
  303. default: null,
  304. },
  305. });
  306. defineEmits(["show-filter"]);
  307. const route = useRoute();
  308. const router = useRouter();
  309. const { t } = useI18n();
  310. const store = userStore();
  311. const { logout } = useAuth();
  312. const userUnits = computed(() => store.user?.units ?? []);
  313. const user = computed(() => store.user);
  314. const unitDetails = ref(null);
  315. const unitInfo = computed(() => unitDetails.value || store.selectedUnit || {});
  316. const getUnitLabel = (unit) =>
  317. unit?.fantasy_name || unit?.social_reason || "Unidade sem nome";
  318. // ------------------- Notificações (sino) -------------------
  319. const notifications = ref([]);
  320. const unreadCount = ref(0);
  321. const NOTIFICATION_ICONS = {
  322. support_ticket_created: "mdi-lifebuoy",
  323. support_ticket_reply: "mdi-message-reply-text-outline",
  324. kanban_created: "mdi-clipboard-plus-outline",
  325. kanban_reply: "mdi-comment-text-outline",
  326. kanban_status_changed: "mdi-swap-horizontal",
  327. tbr_month_closed: "mdi-cash-multiple",
  328. account_payable_created: "mdi-file-document-plus-outline",
  329. payment_credited: "mdi-cash-check",
  330. };
  331. const iconFor = (type) => NOTIFICATION_ICONS[type] || "mdi-bell-outline";
  332. const loadUnreadCount = async () => {
  333. try {
  334. unreadCount.value = await getUnreadCount();
  335. } catch (e) {
  336. console.error("Falha ao carregar contador de notificações:", e);
  337. }
  338. };
  339. const loadNotifications = async () => {
  340. try {
  341. notifications.value = await getMyNotifications();
  342. } catch (e) {
  343. console.error("Falha ao carregar notificações:", e);
  344. }
  345. };
  346. const markAllAsRead = async () => {
  347. try {
  348. await markAllNotificationsRead();
  349. notifications.value.forEach((n) => (n.is_read = true));
  350. unreadCount.value = 0;
  351. } catch (e) {
  352. console.error("Falha ao marcar todas como lidas:", e);
  353. }
  354. };
  355. const openNotification = async (n) => {
  356. if (!n.is_read) {
  357. try {
  358. await markNotificationRead(n.id);
  359. n.is_read = true;
  360. unreadCount.value = Math.max(0, unreadCount.value - 1);
  361. } catch (e) {
  362. console.error("Falha ao marcar como lida:", e);
  363. }
  364. }
  365. if (n.url) router.push(n.url);
  366. };
  367. const formatNotificationDate = (raw) => {
  368. if (!raw) return "";
  369. const d = new Date(String(raw).replace(" ", "T"));
  370. return new Intl.DateTimeFormat("pt-BR", {
  371. day: "2-digit",
  372. month: "2-digit",
  373. year: "numeric",
  374. hour: "2-digit",
  375. minute: "2-digit",
  376. }).format(d);
  377. };
  378. const userRoom = computed(() =>
  379. store.user?.id ? `user.${store.user.id}` : null,
  380. );
  381. const onRealtimeNotification = () => {
  382. loadUnreadCount();
  383. loadNotifications();
  384. };
  385. onMounted(() => {
  386. loadUnreadCount();
  387. if (userRoom.value) {
  388. joinRoom(userRoom.value);
  389. socket.on("notification", onRealtimeNotification);
  390. }
  391. });
  392. onBeforeUnmount(() => {
  393. if (userRoom.value) {
  394. socket.off("notification", onRealtimeNotification);
  395. leaveRoom(userRoom.value);
  396. }
  397. });
  398. const isUnitLoading = ref(false);
  399. let loadingTimer = null;
  400. watch(
  401. () => store.selectedUnit?.id,
  402. (newId, oldId) => {
  403. if (newId && oldId !== undefined && newId !== oldId) {
  404. isUnitLoading.value = true;
  405. clearTimeout(loadingTimer);
  406. loadingTimer = setTimeout(() => {
  407. isUnitLoading.value = false;
  408. }, 1500);
  409. }
  410. },
  411. );
  412. const lastLoginFormatted = computed(() => {
  413. const raw = store.user?.last_login_at;
  414. if (!raw) return null;
  415. const d = new Date(raw.replace(" ", "T"));
  416. return new Intl.DateTimeFormat("pt-BR", {
  417. day: "2-digit",
  418. month: "2-digit",
  419. year: "numeric",
  420. hour: "2-digit",
  421. minute: "2-digit",
  422. timeZone: "America/Fortaleza",
  423. }).format(d);
  424. });
  425. const loadUnitDetails = async () => {
  426. try {
  427. unitDetails.value = await getUnitMe();
  428. } catch (e) {
  429. unitDetails.value = store.selectedUnit;
  430. console.error("Falha ao carregar dados da unidade:", e);
  431. }
  432. };
  433. const goToEditProfile = () => {
  434. if (!user.value?.id) return;
  435. router.push({ name: "UserEditPage", params: { id: user.value.id } });
  436. };
  437. const goToEditUnit = () => {
  438. router.push({ name: "UnitDataPage" });
  439. };
  440. const logoutFn = async () => {
  441. await logout();
  442. router.push({ name: "LoginPage" });
  443. };
  444. const displayTitle = computed(() => {
  445. if (title) {
  446. if (typeof title === "string") return title;
  447. if (title.translate) return t(title.value);
  448. return title.value;
  449. } else if (route.meta?.title) {
  450. const metaTitle = route.meta.title;
  451. if (typeof metaTitle === "string") return metaTitle;
  452. if (metaTitle.translate) return t(metaTitle.value);
  453. return metaTitle.value;
  454. }
  455. return null;
  456. });
  457. const displayBreadcrumbs = computed(() => {
  458. const items = breadcrumbs?.length ? breadcrumbs : route.meta?.breadcrumbs;
  459. return (items || []).map((b) => ({
  460. ...b,
  461. title: b.translate ? t(b.title) : b.title,
  462. }));
  463. });
  464. </script>
  465. <style scoped>
  466. .default-header-page {
  467. padding-left: 4px;
  468. }
  469. .default-header-page :deep(.q-breadcrumbs) {
  470. display: inline-flex;
  471. max-width: 100%;
  472. }
  473. .default-header-page :deep(.q-breadcrumbs__el),
  474. .default-header-page :deep(.q-breadcrumbs__separator) {
  475. flex: 0 0 auto;
  476. width: auto;
  477. }
  478. .default-header-page :deep(.q-breadcrumbs__el) {
  479. max-width: max-content;
  480. }
  481. .header-menu {
  482. border-radius: 10px;
  483. }
  484. .notifications-panel {
  485. width: 340px;
  486. max-width: 90vw;
  487. }
  488. .avatar-circle {
  489. width: 36px;
  490. height: 36px;
  491. border-radius: 50%;
  492. flex-shrink: 0;
  493. }
  494. .profile-panel,
  495. .settings-panel {
  496. width: 280px;
  497. max-width: 90vw;
  498. }
  499. </style>