Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras

This commit is contained in:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions
+182 -189
View File
@@ -16,295 +16,288 @@
-->
<!-- Banner global no topo position fixed acima da topbar, empurra layout via CSS var -->
<script setup>
import { watch, onBeforeUnmount, ref, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useNoticeStore } from '@/stores/noticeStore'
import { useTenantStore } from '@/stores/tenantStore'
import { watch, onBeforeUnmount, ref, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useNoticeStore } from '@/stores/noticeStore';
import { useTenantStore } from '@/stores/tenantStore';
const noticeStore = useNoticeStore()
const tenantStore = useTenantStore()
const router = useRouter()
const noticeStore = useNoticeStore();
const tenantStore = useTenantStore();
const router = useRouter();
const bannerEl = ref(null)
const visible = ref(false)
const bannerEl = ref(null);
const visible = ref(false);
// ── CSS variable: empurra topbar + layout ─────────────────────
function setHeight (px) {
document.documentElement.style.setProperty('--notice-banner-height', px + 'px')
function setHeight(px) {
document.documentElement.style.setProperty('--notice-banner-height', px + 'px');
}
function measureAndSet () {
nextTick(() => {
const h = bannerEl.value?.offsetHeight || 0
setHeight(h)
})
function measureAndSet() {
nextTick(() => {
const h = bannerEl.value?.offsetHeight || 0;
setHeight(h);
});
}
// ── Reactive: notice muda → mostra/esconde ───────────────────
watch(
() => noticeStore.activeNotice,
(notice) => {
if (notice) {
visible.value = true
measureAndSet()
noticeStore.onView(notice)
} else {
visible.value = false
setHeight(0)
}
},
{ immediate: true }
)
() => noticeStore.activeNotice,
(notice) => {
if (notice) {
visible.value = true;
measureAndSet();
noticeStore.onView(notice);
} else {
visible.value = false;
setHeight(0);
}
},
{ immediate: true }
);
// ── Ação CTA ─────────────────────────────────────────────────
async function handleCta (notice) {
await noticeStore.onCtaClick(notice)
async function handleCta(notice) {
await noticeStore.onCtaClick(notice);
if (notice.action_type === 'internal' && notice.action_route) {
router.push(notice.action_route)
} else if (notice.action_type === 'external' && notice.action_url) {
const target = notice.link_target || '_blank'
window.open(notice.action_url, target, 'noopener,noreferrer')
}
if (notice.action_type === 'internal' && notice.action_route) {
router.push(notice.action_route);
} else if (notice.action_type === 'external' && notice.action_url) {
const target = notice.link_target || '_blank';
window.open(notice.action_url, target, 'noopener,noreferrer');
}
}
// ── Dismiss ──────────────────────────────────────────────────
async function dismiss () {
await noticeStore.dismiss(noticeStore.activeNotice)
async function dismiss() {
await noticeStore.dismiss(noticeStore.activeNotice);
}
// ── Limpa a CSS var ao desmontar ─────────────────────────────
onBeforeUnmount(() => setHeight(0))
onBeforeUnmount(() => setHeight(0));
// ── Variantes → estilos ──────────────────────────────────────
const VARIANT_STYLES = {
info: { bg: 'var(--p-blue-600, #2563eb)', icon: 'pi-info-circle' },
success: { bg: 'var(--p-green-600, #16a34a)', icon: 'pi-check-circle' },
warning: { bg: 'var(--p-amber-500, #f59e0b)', icon: 'pi-exclamation-triangle' },
error: { bg: 'var(--p-red-600, #dc2626)', icon: 'pi-times-circle' },
}
info: { bg: 'var(--p-blue-600, #2563eb)', icon: 'pi-info-circle' },
success: { bg: 'var(--p-green-600, #16a34a)', icon: 'pi-check-circle' },
warning: { bg: 'var(--p-amber-500, #f59e0b)', icon: 'pi-exclamation-triangle' },
error: { bg: 'var(--p-red-600, #dc2626)', icon: 'pi-times-circle' }
};
function variantStyle (variant) {
return VARIANT_STYLES[variant] || VARIANT_STYLES.info
function variantStyle(variant) {
return VARIANT_STYLES[variant] || VARIANT_STYLES.info;
}
const ALIGN_CLASS = {
left: 'notice-inner--left',
center: 'notice-inner--center',
right: 'notice-inner--right',
justify: 'notice-inner--justify',
}
left: 'notice-inner--left',
center: 'notice-inner--center',
right: 'notice-inner--right',
justify: 'notice-inner--justify'
};
function alignClass (align) {
return ALIGN_CLASS[align] || ALIGN_CLASS.left
function alignClass(align) {
return ALIGN_CLASS[align] || ALIGN_CLASS.left;
}
</script>
<template>
<Transition name="notice-slide">
<div
v-if="visible && noticeStore.activeNotice"
ref="bannerEl"
class="global-notice-banner"
:style="{ background: variantStyle(noticeStore.activeNotice.variant).bg }"
role="alert"
:aria-label="noticeStore.activeNotice.title || 'Aviso'"
>
<!-- notice-inner controla o max-width/padding; o alinhamento fica no notice-group-wrap -->
<div class="notice-inner" :class="alignClass(noticeStore.activeNotice.content_align)">
<Transition name="notice-slide">
<div v-if="visible && noticeStore.activeNotice" ref="bannerEl" class="global-notice-banner" :style="{ background: variantStyle(noticeStore.activeNotice.variant).bg }" role="alert" :aria-label="noticeStore.activeNotice.title || 'Aviso'">
<!-- notice-inner controla o max-width/padding; o alinhamento fica no notice-group-wrap -->
<div class="notice-inner" :class="alignClass(noticeStore.activeNotice.content_align)">
<!-- Grupo alinhável: ícone + texto + cta -->
<div class="notice-group">
<i class="notice-icon pi" :class="variantStyle(noticeStore.activeNotice.variant).icon" aria-hidden="true" />
<!-- Grupo alinhável: ícone + texto + cta -->
<div class="notice-group">
<i
class="notice-icon pi"
:class="variantStyle(noticeStore.activeNotice.variant).icon"
aria-hidden="true"
/>
<div class="notice-body">
<span v-if="noticeStore.activeNotice.title" class="notice-title">
{{ noticeStore.activeNotice.title }}
</span>
<!-- eslint-disable vue/no-v-html -->
<span class="notice-message" v-html="noticeStore.activeNotice.message" />
</div>
<div class="notice-body">
<span v-if="noticeStore.activeNotice.title" class="notice-title">
{{ noticeStore.activeNotice.title }}
</span>
<!-- eslint-disable vue/no-v-html -->
<span class="notice-message" v-html="noticeStore.activeNotice.message" />
</div>
<button v-if="noticeStore.activeNotice.action_type !== 'none' && noticeStore.activeNotice.action_label" class="notice-cta" type="button" @click="handleCta(noticeStore.activeNotice)">
{{ noticeStore.activeNotice.action_label }}
<i v-if="noticeStore.activeNotice.action_type === 'external'" class="pi pi-external-link notice-cta__ext" />
</button>
</div>
<button
v-if="noticeStore.activeNotice.action_type !== 'none' && noticeStore.activeNotice.action_label"
class="notice-cta"
type="button"
@click="handleCta(noticeStore.activeNotice)"
>
{{ noticeStore.activeNotice.action_label }}
<i
v-if="noticeStore.activeNotice.action_type === 'external'"
class="pi pi-external-link notice-cta__ext"
/>
</button>
<!-- Fechar: sempre absoluto no canto direito -->
<button v-if="noticeStore.activeNotice.dismissible" class="notice-close" type="button" aria-label="Fechar aviso" @click="dismiss">
<i class="pi pi-times" />
</button>
</div>
</div>
<!-- Fechar: sempre absoluto no canto direito -->
<button
v-if="noticeStore.activeNotice.dismissible"
class="notice-close"
type="button"
aria-label="Fechar aviso"
@click="dismiss"
>
<i class="pi pi-times" />
</button>
</div>
</div>
</Transition>
</Transition>
</template>
<style scoped>
.global-notice-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 101; /* acima da topbar (z-index 100) */
min-height: 44px;
display: flex;
align-items: stretch;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 101; /* acima da topbar (z-index 100) */
min-height: 44px;
display: flex;
align-items: stretch;
}
.notice-inner {
position: relative; /* âncora do botão fechar absoluto */
width: 100%;
max-width: 1400px;
margin: 0 auto;
padding: 0 2.5rem 0 1rem; /* padding-right reserva espaço pro fechar */
display: flex;
align-items: center;
min-height: 44px;
position: relative; /* âncora do botão fechar absoluto */
width: 100%;
max-width: 1400px;
margin: 0 auto;
padding: 0 2.5rem 0 1rem; /* padding-right reserva espaço pro fechar */
display: flex;
align-items: center;
min-height: 44px;
}
/* ── Grupo alinhável (ícone + texto + cta) ───────────────── */
.notice-group {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 6px 0;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 6px 0;
}
/* ── Alinhamento: muda só o .notice-group dentro do .notice-inner ── */
.notice-inner--left { justify-content: flex-start; }
.notice-inner--center { justify-content: center; }
.notice-inner--right { justify-content: flex-end; }
.notice-inner--justify { justify-content: flex-start; }
.notice-inner--left {
justify-content: flex-start;
}
.notice-inner--center {
justify-content: center;
}
.notice-inner--right {
justify-content: flex-end;
}
.notice-inner--justify {
justify-content: flex-start;
}
/* justify: grupo expande para preencher toda a largura */
.notice-inner--justify .notice-group { flex: 1; }
.notice-inner--justify .notice-body { flex: 1; }
.notice-inner--justify .notice-group {
flex: 1;
}
.notice-inner--justify .notice-body {
flex: 1;
}
/* ── Ícone ───────────────────────────────────────────────── */
.notice-icon {
font-size: 1rem;
color: rgba(255, 255, 255, 0.92);
flex-shrink: 0;
font-size: 1rem;
color: rgba(255, 255, 255, 0.92);
flex-shrink: 0;
}
/* ── Corpo de texto ──────────────────────────────────────── */
.notice-body {
min-width: 0;
display: flex;
align-items: baseline;
gap: 0.45rem;
flex-wrap: wrap;
font-size: 0.85rem;
color: #fff;
line-height: 1.45;
min-width: 0;
display: flex;
align-items: baseline;
gap: 0.45rem;
flex-wrap: wrap;
font-size: 0.85rem;
color: #fff;
line-height: 1.45;
}
.notice-title {
font-weight: 700;
white-space: nowrap;
font-weight: 700;
white-space: nowrap;
}
.notice-message {
opacity: 0.92;
opacity: 0.92;
}
/* Links dentro do HTML do message */
.notice-message :deep(a) {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
/* ── CTA button ──────────────────────────────────────────── */
.notice-cta {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 14px;
border-radius: 6px;
border: 1.5px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 14px;
border-radius: 6px;
border: 1.5px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition:
background 0.15s,
border-color 0.15s;
flex-shrink: 0;
}
.notice-cta:hover {
background: rgba(255, 255, 255, 0.28);
border-color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.28);
border-color: rgba(255, 255, 255, 0.8);
}
.notice-cta__ext {
font-size: 0.65rem;
opacity: 0.8;
font-size: 0.65rem;
opacity: 0.8;
}
/* ── Fechar: sempre ancorado no canto direito ────────────── */
.notice-close {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.75);
display: grid;
place-items: center;
cursor: pointer;
font-size: 0.8rem;
transition: background 0.15s, color 0.15s;
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.75);
display: grid;
place-items: center;
cursor: pointer;
font-size: 0.8rem;
transition:
background 0.15s,
color 0.15s;
}
.notice-close:hover {
background: rgba(255, 255, 255, 0.2);
color: #fff;
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
/* ── Transição slide-down ────────────────────────────────── */
.notice-slide-enter-active {
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.22s ease;
transition:
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.22s ease;
}
.notice-slide-leave-active {
transition: transform 0.22s ease, opacity 0.2s ease;
transition:
transform 0.22s ease,
opacity 0.2s ease;
}
.notice-slide-enter-from,
.notice-slide-leave-to {
transform: translateY(-100%);
opacity: 0;
transform: translateY(-100%);
opacity: 0;
}
/* ── Dark mode: sem mudança — cores via variante ─────────── */
+53 -68
View File
@@ -16,7 +16,7 @@
*/
// Serviço central de acesso ao Supabase para Global Notices
import { supabase } from '@/lib/supabase/client'
import { supabase } from '@/lib/supabase/client';
// ── Leitura ────────────────────────────────────────────────────
@@ -25,107 +25,92 @@ import { supabase } from '@/lib/supabase/client'
* A filtragem por role/context é feita no cliente (noticeStore)
* para evitar lógica de array no PostgREST.
*/
export async function fetchActiveNotices () {
const now = new Date().toISOString()
export async function fetchActiveNotices() {
const now = new Date().toISOString();
const { data, error } = await supabase
.from('global_notices')
.select('*')
.eq('is_active', true)
.or(`starts_at.is.null,starts_at.lte.${now}`)
.or(`ends_at.is.null,ends_at.gte.${now}`)
.order('priority', { ascending: false })
const { data, error } = await supabase.from('global_notices').select('*').eq('is_active', true).or(`starts_at.is.null,starts_at.lte.${now}`).or(`ends_at.is.null,ends_at.gte.${now}`).order('priority', { ascending: false });
if (error) throw error
return data || []
if (error) throw error;
return data || [];
}
/**
* Busca todos os notices (sem filtro de ativo) — para o painel admin.
*/
export async function fetchAllNotices () {
const { data, error } = await supabase
.from('global_notices')
.select('*')
.order('priority', { ascending: false })
.order('created_at', { ascending: false })
export async function fetchAllNotices() {
const { data, error } = await supabase.from('global_notices').select('*').order('priority', { ascending: false }).order('created_at', { ascending: false });
if (error) throw error
return data || []
if (error) throw error;
return data || [];
}
// ── Tracking ───────────────────────────────────────────────────
export async function trackView (noticeId) {
try {
await supabase.rpc('notice_track_view', { p_notice_id: noticeId })
} catch { /* silencioso — tracking não deve quebrar o app */ }
export async function trackView(noticeId) {
try {
await supabase.rpc('notice_track_view', { p_notice_id: noticeId });
} catch {
/* silencioso — tracking não deve quebrar o app */
}
}
export async function trackClick (noticeId) {
try {
await supabase.rpc('notice_track_click', { p_notice_id: noticeId })
} catch {}
export async function trackClick(noticeId) {
try {
await supabase.rpc('notice_track_click', { p_notice_id: noticeId });
} catch {}
}
// ── Dismiss scope: 'user' (persiste no banco) ─────────────────
export async function saveDismissal (noticeId, version) {
const { data: { user } } = await supabase.auth.getUser()
if (!user?.id) return
export async function saveDismissal(noticeId, version) {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user?.id) return;
await supabase
.from('notice_dismissals')
.upsert({ notice_id: noticeId, user_id: user.id, version }, { onConflict: 'notice_id,user_id' })
await supabase.from('notice_dismissals').upsert({ notice_id: noticeId, user_id: user.id, version }, { onConflict: 'notice_id,user_id' });
}
export async function loadUserDismissals () {
const { data: { user } } = await supabase.auth.getUser()
if (!user?.id) return []
export async function loadUserDismissals() {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user?.id) return [];
const { data } = await supabase
.from('notice_dismissals')
.select('notice_id, version')
.eq('user_id', user.id)
const { data } = await supabase.from('notice_dismissals').select('notice_id, version').eq('user_id', user.id);
return data || []
return data || [];
}
// ── Admin CRUD ─────────────────────────────────────────────────
export async function createNotice (payload) {
const { data: { user } } = await supabase.auth.getUser()
const { data, error } = await supabase
.from('global_notices')
.insert({ ...payload, created_by: user?.id })
.select()
.single()
export async function createNotice(payload) {
const {
data: { user }
} = await supabase.auth.getUser();
const { data, error } = await supabase
.from('global_notices')
.insert({ ...payload, created_by: user?.id })
.select()
.single();
if (error) throw error
return data
if (error) throw error;
return data;
}
export async function updateNotice (id, payload) {
const { data, error } = await supabase
.from('global_notices')
.update(payload)
.eq('id', id)
.select()
.single()
export async function updateNotice(id, payload) {
const { data, error } = await supabase.from('global_notices').update(payload).eq('id', id).select().single();
if (error) throw error
return data
if (error) throw error;
return data;
}
export async function deleteNotice (id) {
const { error } = await supabase
.from('global_notices')
.delete()
.eq('id', id)
export async function deleteNotice(id) {
const { error } = await supabase.from('global_notices').delete().eq('id', id);
if (error) throw error
if (error) throw error;
}
export async function toggleNoticeActive (id, isActive) {
return updateNotice(id, { is_active: isActive })
export async function toggleNoticeActive(id, isActive) {
return updateNotice(id, { is_active: isActive });
}