DefaultHeaderPage.vue 14 KB

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