DefaultHeaderPage.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  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. >
  106. <div class="notifications-panel">
  107. <div class="row items-center justify-between q-px-md q-py-sm">
  108. <span class="text-subtitle2 text-primary text-weight-medium">
  109. Notificações
  110. </span>
  111. <q-btn
  112. flat
  113. dense
  114. no-caps
  115. size="sm"
  116. color="secondary"
  117. label="Marcar tudo como lido"
  118. :disable="unreadCount === 0"
  119. @click="markAllAsRead"
  120. />
  121. </div>
  122. <q-separator />
  123. <q-scroll-area
  124. v-if="notifications.length"
  125. style="height: 360px"
  126. :thumb-style="{ width: '5px', borderRadius: '4px' }"
  127. >
  128. <q-list separator>
  129. <q-item
  130. v-for="n in notifications"
  131. :key="n.id"
  132. clickable
  133. :class="!n.read ? 'bg-grey-2' : ''"
  134. @click="markAsRead(n.id)"
  135. >
  136. <q-item-section avatar top>
  137. <q-icon
  138. :name="n.icon"
  139. :color="n.read ? 'grey-5' : 'secondary'"
  140. size="24px"
  141. />
  142. </q-item-section>
  143. <q-item-section>
  144. <q-item-label
  145. class="text-primary"
  146. :class="!n.read ? 'text-weight-medium' : ''"
  147. >
  148. {{ n.title }}
  149. </q-item-label>
  150. <q-item-label caption lines="2">
  151. {{ n.message }}
  152. </q-item-label>
  153. <q-item-label caption class="text-grey-6 q-mt-xs">
  154. {{ formatNotificationDate(n.datetime) }}
  155. </q-item-label>
  156. </q-item-section>
  157. <q-item-section v-if="!n.read" side top>
  158. <q-badge color="negative" rounded style="padding: 4px" />
  159. </q-item-section>
  160. </q-item>
  161. </q-list>
  162. </q-scroll-area>
  163. <div
  164. v-else
  165. class="column items-center justify-center q-pa-lg text-grey-6"
  166. >
  167. <q-icon name="mdi-bell-off-outline" size="32px" />
  168. <span class="text-caption q-mt-sm">Sem notificações</span>
  169. </div>
  170. </div>
  171. </q-menu>
  172. </q-btn>
  173. <!-- Perfil do usuário -->
  174. <q-btn flat round dense icon="mdi-account" color="secondary">
  175. <q-menu
  176. anchor="bottom right"
  177. self="top right"
  178. :offset="[0, 8]"
  179. class="header-menu"
  180. >
  181. <div class="profile-panel q-pa-md">
  182. <div class="row items-center no-wrap q-gutter-x-md">
  183. <q-img
  184. :src="user?.avatar_url || 'icons/user-icon.jpg'"
  185. class="avatar-circle"
  186. />
  187. <div class="column" style="min-width: 0">
  188. <span class="text-body2 text-primary text-weight-medium ellipsis">
  189. {{ user?.name || "Usuário" }}
  190. </span>
  191. <span class="text-caption text-grey-6 ellipsis">
  192. {{ user?.email }}
  193. </span>
  194. </div>
  195. </div>
  196. <q-separator class="q-my-md" />
  197. <q-btn
  198. v-close-popup
  199. flat
  200. no-caps
  201. align="left"
  202. class="full-width"
  203. color="primary"
  204. icon="mdi-account-edit-outline"
  205. label="Editar"
  206. @click="goToEditProfile"
  207. />
  208. </div>
  209. </q-menu>
  210. </q-btn>
  211. <!-- Configurações -->
  212. <q-btn flat round dense icon="mdi-cog-outline" color="secondary">
  213. <q-menu
  214. anchor="bottom right"
  215. self="top right"
  216. :offset="[0, 8]"
  217. class="header-menu"
  218. >
  219. <div class="settings-panel q-pa-md">
  220. <div class="column q-mb-md">
  221. <span class="text-caption text-grey-6">Unidade</span>
  222. <span class="text-body2 text-primary text-weight-medium">
  223. {{ unitLabel }}
  224. </span>
  225. </div>
  226. <q-separator class="q-mb-md" />
  227. <q-btn
  228. v-close-popup
  229. flat
  230. no-caps
  231. align="left"
  232. class="full-width"
  233. color="negative"
  234. icon="mdi-logout"
  235. label="Sair"
  236. @click="logoutFn"
  237. />
  238. </div>
  239. </q-menu>
  240. </q-btn>
  241. </div>
  242. </div>
  243. <slot name="after" />
  244. </div>
  245. </div>
  246. <q-separator class="q-my-sm" />
  247. </div>
  248. </template>
  249. <script setup>
  250. import { computed, ref } from "vue";
  251. import { useRoute, useRouter } from "vue-router";
  252. import { useI18n } from "vue-i18n";
  253. import { userStore } from "src/stores/user";
  254. import { useAuth } from "src/composables/useAuth";
  255. const { title, breadcrumbs, filterOpen, showFilterIcon } = defineProps({
  256. title: {
  257. type: String,
  258. default: null,
  259. },
  260. breadcrumbs: {
  261. type: Object,
  262. default: null,
  263. },
  264. filterOpen: {
  265. type: Boolean,
  266. default: false,
  267. },
  268. showFilterIcon: {
  269. type: Boolean,
  270. default: true,
  271. },
  272. });
  273. defineEmits(["show-filter"]);
  274. const store = userStore();
  275. const router = useRouter();
  276. const { logout } = useAuth();
  277. const user = computed(() => store.user);
  278. // Unidade exibida em Configurações. Sem unidade vinculada = matriz.
  279. const unitLabel = computed(() => {
  280. const units = store.user?.units ?? [];
  281. if (!units.length) return "Franqueadora / Matriz";
  282. return units.map((u) => u.fantasy_name).join(", ");
  283. });
  284. // TODO: substituir por dados reais da API de notificações (mock por enquanto).
  285. const notifications = ref([
  286. {
  287. id: 1,
  288. icon: "mdi-cash-multiple",
  289. title: "Novo faturamento gerado",
  290. message: "As parcelas do mês foram geradas para as unidades ativas.",
  291. datetime: "2026-07-02T09:15:00",
  292. read: false,
  293. },
  294. {
  295. id: 2,
  296. icon: "mdi-account-plus-outline",
  297. title: "Novo usuário cadastrado",
  298. message: "Um novo usuário foi adicionado à unidade Centro.",
  299. datetime: "2026-07-01T16:42:00",
  300. read: false,
  301. },
  302. {
  303. id: 3,
  304. icon: "mdi-file-document-outline",
  305. title: "Contrato atualizado",
  306. message: "O contrato da franquia foi atualizado com sucesso.",
  307. datetime: "2026-06-30T11:05:00",
  308. read: false,
  309. },
  310. {
  311. id: 4,
  312. icon: "mdi-alert-circle-outline",
  313. title: "Pagamento em atraso",
  314. message: "A unidade Zona Sul possui uma parcela vencida há 3 dias.",
  315. datetime: "2026-06-29T08:20:00",
  316. read: false,
  317. },
  318. {
  319. id: 5,
  320. icon: "mdi-check-decagram-outline",
  321. title: "Pagamento confirmado",
  322. message: "O boleto da unidade Barra foi compensado com sucesso.",
  323. datetime: "2026-06-28T14:10:00",
  324. read: false,
  325. },
  326. {
  327. id: 6,
  328. icon: "mdi-lifebuoy",
  329. title: "Novo chamado de suporte",
  330. message: "A unidade Norte abriu um chamado sobre acesso ao sistema.",
  331. datetime: "2026-06-27T10:55:00",
  332. read: true,
  333. },
  334. {
  335. id: 7,
  336. icon: "mdi-package-variant-closed",
  337. title: "Pacote de aulas publicado",
  338. message: "Um novo pacote foi disponibilizado para todas as unidades.",
  339. datetime: "2026-06-26T17:30:00",
  340. read: true,
  341. },
  342. {
  343. id: 8,
  344. icon: "mdi-cake-variant-outline",
  345. title: "Aniversariantes do dia",
  346. message: "5 alunos fazem aniversário hoje na sua rede.",
  347. datetime: "2026-06-25T07:00:00",
  348. read: true,
  349. },
  350. {
  351. id: 9,
  352. icon: "mdi-store-plus-outline",
  353. title: "Nova unidade cadastrada",
  354. message: "A unidade Jardins foi adicionada à franquia.",
  355. datetime: "2026-06-24T13:45:00",
  356. read: true,
  357. },
  358. {
  359. id: 10,
  360. icon: "mdi-shield-check-outline",
  361. title: "Atualização de segurança",
  362. message: "Recomendamos revisar as permissões de acesso dos usuários.",
  363. datetime: "2026-06-23T09:05:00",
  364. read: true,
  365. },
  366. ]);
  367. const unreadCount = computed(
  368. () => notifications.value.filter((n) => !n.read).length,
  369. );
  370. const markAllAsRead = () => {
  371. notifications.value.forEach((n) => (n.read = true));
  372. };
  373. const markAsRead = (id) => {
  374. const item = notifications.value.find((n) => n.id === id);
  375. if (item) item.read = true;
  376. };
  377. const formatNotificationDate = (raw) => {
  378. if (!raw) return "";
  379. const d = new Date(raw);
  380. return new Intl.DateTimeFormat("pt-BR", {
  381. day: "2-digit",
  382. month: "2-digit",
  383. year: "numeric",
  384. hour: "2-digit",
  385. minute: "2-digit",
  386. }).format(d);
  387. };
  388. const goToEditProfile = () => {
  389. if (!user.value?.id) return;
  390. router.push({ name: "UserEditPage", params: { id: user.value.id } });
  391. };
  392. const logoutFn = async () => {
  393. await logout();
  394. router.push({ name: "LoginPage" });
  395. };
  396. const lastLoginFormatted = computed(() => {
  397. const raw = store.user?.last_login_at;
  398. if (!raw) return null;
  399. const d = new Date(raw.replace(" ", "T") + "Z");
  400. return new Intl.DateTimeFormat("pt-BR", {
  401. day: "2-digit",
  402. month: "2-digit",
  403. year: "numeric",
  404. hour: "2-digit",
  405. minute: "2-digit",
  406. timeZone: "America/Sao_Paulo",
  407. }).format(d);
  408. });
  409. const route = useRoute();
  410. const { t } = useI18n();
  411. const displayBreadcrumbs = computed(() => {
  412. if (!breadcrumbs && breadcrumbs?.length <= 0) {
  413. return null;
  414. } else if (breadcrumbs && breadcrumbs?.length > 0) {
  415. return (breadcrumbs || []).map((b) => {
  416. if (b.translate) {
  417. return t(b.title);
  418. } else {
  419. return b.title;
  420. }
  421. });
  422. }
  423. if (!route.meta?.breadcrumbs && route.meta?.breadcrumbs?.length <= 0) {
  424. return null;
  425. } else if (route.meta?.breadcrumbs && route.meta?.breadcrumbs?.length > 0) {
  426. return (route.meta?.breadcrumbs || []).map((b) => {
  427. if (b.translate) {
  428. return {
  429. ...b,
  430. title: t(b.title),
  431. };
  432. } else {
  433. return {
  434. ...b,
  435. title: b.title,
  436. };
  437. }
  438. });
  439. }
  440. return null;
  441. });
  442. </script>
  443. <style scoped>
  444. .avatar-circle {
  445. width: 36px;
  446. height: 36px;
  447. border-radius: 50%;
  448. flex-shrink: 0;
  449. }
  450. .header-menu {
  451. border-radius: 10px;
  452. }
  453. .notifications-panel {
  454. width: 340px;
  455. max-width: 90vw;
  456. }
  457. .profile-panel,
  458. .settings-panel {
  459. width: 260px;
  460. max-width: 90vw;
  461. }
  462. </style>