Documentos Pacientes, Template Documentos Pacientes Saas, Documentos prontuários, Documentos Externos, Visualização Externa, Permissão de Visualização, Render Otimização

This commit is contained in:
Leonardo
2026-03-30 14:08:19 -03:00
parent 0658e2e9bf
commit d088a89fb7
112 changed files with 115867 additions and 5266 deletions
+14
View File
@@ -50,6 +50,7 @@ const presetModel = computed({
applyThemeEngine(layoutConfig);
queuePatch?.({ preset: val });
saveThemeToStorage();
}
});
@@ -68,11 +69,23 @@ const menuModeModel = computed({
}
});
function saveThemeToStorage() {
try {
localStorage.setItem('ui_theme_config', JSON.stringify({
preset: layoutConfig.preset,
primary: layoutConfig.primary,
surface: layoutConfig.surface,
menuMode: layoutConfig.menuMode
}));
} catch {}
}
function updateColors(type, item) {
if (type === 'primary') {
layoutConfig.primary = item.name;
applyThemeEngine(layoutConfig);
queuePatch?.({ primary_color: item.name });
saveThemeToStorage();
return;
}
@@ -80,6 +93,7 @@ function updateColors(type, item) {
layoutConfig.surface = item.name;
applyThemeEngine(layoutConfig);
queuePatch?.({ surface_color: item.name });
saveThemeToStorage();
}
}
</script>
+1 -1
View File
@@ -18,7 +18,7 @@
<template>
<div class="layout-footer">
SAKAI by
Agência PSI
<a href="https://primevue.org" target="_blank" rel="noopener noreferrer" class="text-primary font-bold hover:underline">PrimeVue</a>
</div>
</template>
+1 -1
View File
@@ -176,7 +176,7 @@ onBeforeUnmount(() => {
<style>
/* ──────────────────────────────────────────────
LAYOUT CLÁSSICO — ajustes globais (não scoped)
para sobrescrever o tema PrimeVue/Sakai
para sobrescrever o tema PrimeVue
────────────────────────────────────────────── */
/* ── Global Notice Banner: variável de altura ─────────────
+15 -2
View File
@@ -46,11 +46,24 @@ function isDarkNow() {
return document.documentElement.classList.contains('app-dark');
}
async function waitForDarkFlip(before, timeoutMs = 900) {
const start = performance.now();
while (performance.now() - start < timeoutMs) {
await nextTick();
await new Promise((r) => requestAnimationFrame(r));
const now = isDarkNow();
if (now !== before) return now;
}
return isDarkNow();
}
async function toggleDarkAndPersist() {
try {
const before = isDarkNow();
toggleDarkMode();
await nextTick();
const theme_mode = isDarkNow() ? 'dark' : 'light';
const after = await waitForDarkFlip(before);
const theme_mode = after ? 'dark' : 'light';
try { localStorage.setItem('ui_theme_mode', theme_mode); } catch {}
await queuePatch({ theme_mode }, { flushNow: true });
} catch (e) {
console.error('[FooterPanel][theme] falhou:', e?.message || e);
+1
View File
@@ -46,6 +46,7 @@ const emit = defineEmits(['quick-create']);
const props = defineProps({
item: { type: Object, default: () => ({}) },
index: { type: Number, default: 0 },
root: { type: Boolean, default: false },
parentPath: { type: String, default: null }
});
+14 -8
View File
@@ -95,6 +95,7 @@ const userName = computed(() => sessionUser.value?.user_metadata?.full_name || s
// ── Início (fixo) ────────────────────────────────────────────
function selectHome() {
if (layoutConfig.railOpenMode === 'hover') return;
if (layoutState.railSectionKey === '__home__' && layoutState.railPanelOpen) {
layoutState.railPanelOpen = false;
} else {
@@ -107,6 +108,7 @@ const isHomeActive = computed(() => layoutState.railSectionKey === '__home__' &&
// ── Seleção de seção ─────────────────────────────────────────
function selectSection(section) {
if (layoutConfig.railOpenMode === 'hover') return;
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) {
layoutState.railPanelOpen = false;
} else {
@@ -115,13 +117,21 @@ function selectSection(section) {
}
}
// Verifica recursivamente se alguma rota do grupo está ativa
function _matchesActive(items, active) {
return items.some((i) => {
const p = typeof i.to === 'string' ? i.to : '';
if (p && active.startsWith(p)) return true;
if (Array.isArray(i.items) && i.items.length) return _matchesActive(i.items, active);
return false;
});
}
function isActiveSectionOrChild(section) {
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) return true;
const active = String(layoutState.activePath || '');
return section.items.some((i) => {
const p = typeof i.to === 'string' ? i.to : '';
return p && active.startsWith(p);
});
if (!active) return false;
return _matchesActive(section.items, active);
}
// ── Menu do usuário (rodapé) ─────────────────────────────────
@@ -144,7 +154,6 @@ function toggleUserMenu(e) {
<button
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
:class="isHomeActive ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
v-tooltip.right="{ value: 'Início', showDelay: 0 }"
aria-label="Início"
@click="selectHome"
@mouseenter="onHomeHover"
@@ -157,7 +166,6 @@ function toggleUserMenu(e) {
:key="section.key"
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
:class="isActiveSectionOrChild(section) ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
v-tooltip.right="{ value: section.label, showDelay: 0 }"
:aria-label="section.label"
@click="selectSection(section)"
@mouseenter="onSectionHover(section)"
@@ -170,7 +178,6 @@ function toggleUserMenu(e) {
<div class="w-full flex flex-col items-center gap-1.5 py-2 pb-3 border-t border-[var(--surface-border)]">
<button
class="w-9 h-9 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-[0.875rem] shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
v-tooltip.right="{ value: 'Configurações', showDelay: 0 }"
aria-label="Configurações"
@click="$router.push('/configuracoes')"
>
@@ -180,7 +187,6 @@ function toggleUserMenu(e) {
<!-- Avatar trigger do menu de usuário -->
<button
class="w-9 h-9 rounded-[10px] border-none cursor-pointer overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center transition-[transform,box-shadow] duration-150 hover:scale-105 hover:shadow-[0_0_0_2px_var(--primary-color)]"
v-tooltip.right="{ value: userName, showDelay: 0 }"
:aria-label="userName"
@click="toggleUserMenu"
>
+3 -1
View File
@@ -114,12 +114,14 @@ function onPanelMouseEnter() {
}
function onPanelMouseLeave() {
if (layoutConfig.railOpenMode !== 'hover') return;
if (popoverOpen.value) return; // popover flutuante aberto — não fechar
scheduleRailHoverClose(200);
}
// ── QuickCreate (Pacientes) ───────────────────────────────
const createPopover = ref(null);
const quickDialog = ref(false);
const popoverOpen = ref(false);
function openQuickCreate(event, item) {
createPopover.value?.toggle(event);
@@ -482,7 +484,7 @@ async function goToResult(r) {
</nav>
<!-- PatientCreatePopover (shared) -->
<PatientCreatePopover ref="createPopover" @quick-create="onQuickCreate" />
<PatientCreatePopover ref="createPopover" @quick-create="onQuickCreate" @show="popoverOpen = true" @hide="popoverOpen = false" />
<!-- Cadastro Rápido Dialog -->
<ComponentCadastroRapido
+14
View File
@@ -57,6 +57,7 @@ const presetModel = computed({
layoutConfig.preset = v;
applyThemeEngine(layoutConfig);
queuePatch?.({ preset: v });
saveThemeToStorage();
}
});
@@ -68,16 +69,29 @@ const menuModeModel = computed({
}
});
function saveThemeToStorage() {
try {
localStorage.setItem('ui_theme_config', JSON.stringify({
preset: layoutConfig.preset,
primary: layoutConfig.primary,
surface: layoutConfig.surface,
menuMode: layoutConfig.menuMode
}));
} catch {}
}
function updateColors(type, item) {
if (type === 'primary') {
layoutConfig.primary = item.name;
applyThemeEngine(layoutConfig);
queuePatch?.({ primary_color: item.name });
saveThemeToStorage();
}
if (type === 'surface') {
layoutConfig.surface = item.name;
applyThemeEngine(layoutConfig);
queuePatch?.({ surface_color: item.name });
saveThemeToStorage();
}
}
+12 -1
View File
@@ -140,6 +140,16 @@ async function loadAndApplyUserSettings() {
// 3) aplica engine UMA vez
applyThemeEngine(layoutConfig);
// 4) persiste no localStorage para carregamento instantâneo no próximo boot
try {
localStorage.setItem('ui_theme_config', JSON.stringify({
preset: layoutConfig.preset,
primary: layoutConfig.primary,
surface: layoutConfig.surface,
menuMode: layoutConfig.menuMode
}));
} catch {}
// ✅ IMPORTANTE:
// changeMenuMode NÃO é só "setar menuMode".
// Ele reseta estados do sidebar/overlay/mobile e previne drift.
@@ -165,6 +175,7 @@ async function toggleDarkAndPersistSilently() {
toggleDarkMode();
const after = await waitForDarkFlip(before);
const theme_mode = after ? 'dark' : 'light';
try { localStorage.setItem('ui_theme_mode', theme_mode); } catch {}
await queuePatch({ theme_mode }, { flushNow: true });
} catch (e) {
console.error('[Topbar][theme] falhou:', e?.message || e);
@@ -632,7 +643,7 @@ onMounted(async () => {
}
/* Hamburguer: visível apenas em ≤ xl (1280px)
!important necessário para sobrescrever CSS do tema Sakai (.layout-menu-button) */
!important necessário para sobrescrever CSS do tema (.layout-menu-button) */
.rail-topbar__hamburger {
display: none !important;
}
+15 -4
View File
@@ -36,12 +36,23 @@ function _loadRailOpenMode() {
return 'hover';
}
// ── resolve tema (preset/primary/surface) salvo no localStorage ─
function _loadSavedTheme() {
try {
const raw = localStorage.getItem('ui_theme_config');
if (raw) return JSON.parse(raw);
} catch {}
return {};
}
const _savedTheme = _loadSavedTheme();
const layoutConfig = reactive({
preset: 'Aura',
primary: 'emerald',
surface: null,
preset: _savedTheme.preset || 'Aura',
primary: _savedTheme.primary || 'emerald',
surface: _savedTheme.surface || null,
darkTheme: false,
menuMode: 'static',
menuMode: _savedTheme.menuMode || 'static',
variant: _loadVariant(), // 'classic' | 'rail'
railOpenMode: _loadRailOpenMode() // 'click' | 'hover'
});
@@ -15,18 +15,18 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch, onMounted, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import DatePicker from 'primevue/datepicker';
import { useToast } from 'primevue/usetoast';
import PausasChipsEditor from '@/components/agenda/PausasChipsEditor.vue';
import FullCalendar from '@fullcalendar/vue3';
import timeGridPlugin from '@fullcalendar/timegrid';
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
import timeGridPlugin from '@fullcalendar/timegrid';
import FullCalendar from '@fullcalendar/vue3';
const toast = useToast();
const tenantStore = useTenantStore();
@@ -1385,7 +1385,7 @@ const jornadaEndDate = computed({
<div class="anim-child [--delay:120ms] xl:w-[42%] xl:top-4 xl:self-start">
<div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm agenda-altura">
<!-- Header do preview -->
<div class="sticky top-0 z-10 bg-white">
<div class="sticky top-0 z-10">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
<div class="font-semibold text-sm">Preview da agenda</div>
<div class="flex gap-1">
@@ -19,7 +19,7 @@ import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import Editor from 'primevue/editor';
import JoditEmailEditor from '@/components/ui/JoditEmailEditor.vue';
import { supabase } from '@/lib/supabase/client';
import { renderEmail, renderTemplate, generateLayoutSection } from '@/lib/email/emailTemplateService';
import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants';
@@ -135,19 +135,6 @@ const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success'
// ── Dialog layout (header/footer global) ──────────────────────
const layoutDlg = ref({ open: false, saving: false });
const layoutForm = ref({ header: defaultSection(), footer: defaultSection() });
const headerEditorRef = ref(null);
const footerEditorRef = ref(null);
const LAYOUT_OPTIONS = [
{ value: 'logo-left', label: 'Logo à esquerda' },
{ value: 'logo-right', label: 'Logo à direita' },
{ value: 'logo-center', label: 'Logo centralizada' }
];
const TEXT_OPTIONS = [
{ value: 'text-left', label: 'Texto à esquerda' },
{ value: 'text-center', label: 'Texto centralizado' },
{ value: 'text-right', label: 'Texto à direita' }
];
function openLayoutDlg() {
layoutForm.value = {
@@ -157,10 +144,6 @@ function openLayoutDlg() {
layoutDlg.value = { open: true, saving: false };
}
function selectLayout(which, type) {
layoutForm.value[which].layout = type;
}
const headerLayoutPreview = computed(() => generateLayoutSection(layoutForm.value.header, profileLogoUrl.value, true));
const footerLayoutPreview = computed(() => generateLayoutSection(layoutForm.value.footer, profileLogoUrl.value, false));
@@ -206,7 +189,6 @@ function openEdit(row) {
subject: ov?.subject ?? row.subject,
body_html: ov?.body_html ?? row.body_html,
body_text: ov?.body_text ?? '',
enabled: ov?.enabled ?? true,
synced_version: row.version,
variables: row.variables || {}
};
@@ -225,15 +207,11 @@ const formVariables = computed(() => {
function insertVar(varName) {
const snippet = `{{${varName}}}`;
const quill = editorRef.value?.quill;
if (!quill) {
if (editorRef.value?.insertHTML) {
editorRef.value.insertHTML(snippet);
} else {
form.value.body_html = (form.value.body_html || '') + snippet;
return;
}
const range = quill.getSelection(true);
const index = range ? range.index : quill.getLength() - 1;
quill.insertText(index, snippet, 'user');
quill.setSelection(index + snippet.length, 0);
}
async function save() {
@@ -251,7 +229,7 @@ async function save() {
subject: form.value.use_custom_subject ? form.value.subject : null,
body_html: form.value.use_custom_body ? form.value.body_html : null,
body_text: form.value.use_custom_body && form.value.body_text ? form.value.body_text : null,
enabled: form.value.enabled,
enabled: true,
synced_version: form.value.synced_version
};
if (dlg.value.mode === 'create') {
@@ -323,9 +301,19 @@ onMounted(async () => {
<template>
<div class="flex flex-col gap-4">
<!-- Filtro -->
<div class="flex gap-2 flex-wrap">
<!-- Filtro + Layout global -->
<div class="flex items-center gap-2 flex-wrap">
<Button v-for="opt in DOMAIN_OPTIONS" :key="String(opt.value)" :label="opt.label" size="small" :severity="filterDomain === opt.value ? 'primary' : 'secondary'" :outlined="filterDomain !== opt.value" @click="filterDomain = opt.value" />
<div class="ml-auto">
<Button
label="Layout global"
icon="pi pi-palette"
size="small"
:severity="layoutActive ? 'success' : 'secondary'"
:outlined="!layoutActive"
@click="openLayoutDlg"
/>
</div>
</div>
<!-- SKELETON -->
@@ -394,88 +382,21 @@ onMounted(async () => {
</div>
<div v-if="layoutForm.header.enabled" class="px-4 py-4 flex flex-col gap-4">
<!-- Cards de layout -->
<div class="flex flex-col gap-2">
<p class="text-xs font-semibold">Com logo</p>
<div class="flex gap-2">
<button v-for="opt in LAYOUT_OPTIONS" :key="opt.value" class="layout-card" :class="{ 'layout-card--active': layoutForm.header.layout === opt.value }" @click="selectLayout('header', opt.value)">
<div class="layout-card__thumb">
<template v-if="opt.value === 'logo-left'">
<div class="lc-logo" />
<div class="lc-spacer" />
<div class="lc-lines">
<div class="lc-line" />
<div class="lc-line short" />
</div>
</template>
<template v-else-if="opt.value === 'logo-right'">
<div class="lc-lines">
<div class="lc-line" />
<div class="lc-line short" />
</div>
<div class="lc-spacer" />
<div class="lc-logo" />
</template>
<template v-else>
<div class="lc-center">
<div class="lc-logo" />
<div class="lc-line" style="width: 70%; margin-top: 5px" />
</div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
<p class="text-xs font-semibold mt-1"> texto</p>
<div class="flex gap-2">
<button v-for="opt in TEXT_OPTIONS" :key="opt.value" class="layout-card" :class="{ 'layout-card--active': layoutForm.header.layout === opt.value }" @click="selectLayout('header', opt.value)">
<div class="layout-card__thumb">
<template v-if="opt.value === 'text-left'">
<div class="lc-lines">
<div class="lc-line" />
<div class="lc-line short" />
</div>
</template>
<template v-else-if="opt.value === 'text-center'">
<div class="lc-lines lc-lines--center">
<div class="lc-line" style="width: 85%" />
<div class="lc-line short" style="width: 55%; align-self: center" />
</div>
</template>
<template v-else>
<div class="lc-lines lc-lines--right">
<div class="lc-line" />
<div class="lc-line short" style="align-self: flex-end" />
</div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
</div>
<!-- Editor de texto -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Texto</label>
<Editor ref="headerEditorRef" v-model="layoutForm.header.content" editor-style="min-height:100px;font-size:0.85rem;">
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" type="button" />
<button class="ql-italic" type="button" />
<button class="ql-underline" type="button" />
</span>
<span class="ql-formats">
<select class="ql-color" />
</span>
</template>
</Editor>
</div>
<!-- Editor Jodit use os botões " Logo Esq./Dir./Centro" na toolbar para inserir layouts prontos -->
<JoditEmailEditor
v-model="layoutForm.header.content"
:min-height="160"
:layout-buttons="true"
:logo-url="profileLogoUrl"
/>
<!-- Preview -->
<div v-if="headerLayoutPreview" class="rounded border border-[var(--surface-border)] bg-white p-3 text-sm text-gray-800">
<span class="text-[0.65rem] text-gray-400 uppercase tracking-widest block mb-2">Preview</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="headerLayoutPreview" />
<div v-if="headerLayoutPreview" class="rounded-lg border border-(--surface-border) bg-(--surface-ground) p-3">
<span class="text-[0.65rem] text-(--text-color-secondary) uppercase tracking-widest block mb-2">Preview</span>
<div class="rounded border border-(--surface-border) bg-white p-3 text-sm text-gray-800 shadow-sm">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="headerLayoutPreview" />
</div>
</div>
</div>
</div>
@@ -494,88 +415,21 @@ onMounted(async () => {
</div>
<div v-if="layoutForm.footer.enabled" class="px-4 py-4 flex flex-col gap-4">
<!-- Cards de layout -->
<div class="flex flex-col gap-2">
<p class="text-xs font-semibold">Com logo</p>
<div class="flex gap-2">
<button v-for="opt in LAYOUT_OPTIONS" :key="opt.value" class="layout-card" :class="{ 'layout-card--active': layoutForm.footer.layout === opt.value }" @click="selectLayout('footer', opt.value)">
<div class="layout-card__thumb">
<template v-if="opt.value === 'logo-left'">
<div class="lc-logo" />
<div class="lc-spacer" />
<div class="lc-lines">
<div class="lc-line" />
<div class="lc-line short" />
</div>
</template>
<template v-else-if="opt.value === 'logo-right'">
<div class="lc-lines">
<div class="lc-line" />
<div class="lc-line short" />
</div>
<div class="lc-spacer" />
<div class="lc-logo" />
</template>
<template v-else>
<div class="lc-center">
<div class="lc-logo" />
<div class="lc-line" style="width: 70%; margin-top: 5px" />
</div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
<p class="text-xs font-semibold mt-1"> texto</p>
<div class="flex gap-2">
<button v-for="opt in TEXT_OPTIONS" :key="opt.value" class="layout-card" :class="{ 'layout-card--active': layoutForm.footer.layout === opt.value }" @click="selectLayout('footer', opt.value)">
<div class="layout-card__thumb">
<template v-if="opt.value === 'text-left'">
<div class="lc-lines">
<div class="lc-line" />
<div class="lc-line short" />
</div>
</template>
<template v-else-if="opt.value === 'text-center'">
<div class="lc-lines lc-lines--center">
<div class="lc-line" style="width: 85%" />
<div class="lc-line short" style="width: 55%; align-self: center" />
</div>
</template>
<template v-else>
<div class="lc-lines lc-lines--right">
<div class="lc-line" />
<div class="lc-line short" style="align-self: flex-end" />
</div>
</template>
</div>
<span class="layout-card__label">{{ opt.label }}</span>
</button>
</div>
</div>
<!-- Editor de texto -->
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Texto</label>
<Editor ref="footerEditorRef" v-model="layoutForm.footer.content" editor-style="min-height:100px;font-size:0.85rem;">
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" type="button" />
<button class="ql-italic" type="button" />
<button class="ql-underline" type="button" />
</span>
<span class="ql-formats">
<select class="ql-color" />
</span>
</template>
</Editor>
</div>
<!-- Editor Jodit use os botões " Logo Esq./Dir./Centro" na toolbar para inserir layouts prontos -->
<JoditEmailEditor
v-model="layoutForm.footer.content"
:min-height="160"
:layout-buttons="true"
:logo-url="profileLogoUrl"
/>
<!-- Preview -->
<div v-if="footerLayoutPreview" class="rounded border border-[var(--surface-border)] bg-white p-3 text-sm text-gray-800">
<span class="text-[0.65rem] text-gray-400 uppercase tracking-widest block mb-2">Preview</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="footerLayoutPreview" />
<div v-if="footerLayoutPreview" class="rounded-lg border border-(--surface-border) bg-(--surface-ground) p-3">
<span class="text-[0.65rem] text-(--text-color-secondary) uppercase tracking-widest block mb-2">Preview</span>
<div class="rounded border border-(--surface-border) bg-white p-3 text-sm text-gray-800 shadow-sm">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="footerLayoutPreview" />
</div>
</div>
</div>
</div>
@@ -616,35 +470,7 @@ onMounted(async () => {
</div>
<div class="px-4 py-3 flex flex-col gap-3">
<template v-if="form.use_custom_body">
<Editor ref="editorRef" v-model="form.body_html" editor-style="min-height:200px;font-size:0.85rem;">
<template #toolbar>
<span class="ql-formats">
<select class="ql-header">
<option value="1">Título</option>
<option value="2">Subtítulo</option>
<option selected>Normal</option>
</select>
</span>
<span class="ql-formats">
<button class="ql-bold" type="button" />
<button class="ql-italic" type="button" />
<button class="ql-underline" type="button" />
</span>
<span class="ql-formats">
<select class="ql-align" />
<select class="ql-color" />
<select class="ql-background" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" type="button" />
<button class="ql-list" value="bullet" type="button" />
</span>
<span class="ql-formats">
<button class="ql-link" type="button" />
<button class="ql-clean" type="button" />
</span>
</template>
</Editor>
<JoditEmailEditor ref="editorRef" v-model="form.body_html" :min-height="220" />
<div class="flex flex-col gap-1.5">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
@@ -660,11 +486,6 @@ onMounted(async () => {
</div>
</div>
<!-- Override ativo -->
<div class="flex items-center gap-2">
<ToggleSwitch v-model="form.enabled" inputId="sw-enabled" />
<label for="sw-enabled" class="text-sm cursor-pointer select-none">Override ativo</label>
</div>
</div>
<template #footer>
@@ -699,104 +520,4 @@ onMounted(async () => {
</template>
<style scoped>
/* ── Layout cards ───────────────────────────────────────── */
.layout-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 8px;
border: 1.5px solid var(--surface-border);
border-radius: 8px;
background: var(--surface-card);
cursor: pointer;
transition:
border-color 0.15s,
box-shadow 0.15s,
background 0.15s;
}
.layout-card:hover {
border-color: color-mix(in srgb, var(--primary-color, #6366f1) 50%, transparent);
background: color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card));
}
.layout-card--active {
border-color: var(--primary-color, #6366f1);
background: color-mix(in srgb, var(--primary-color, #6366f1) 8%, var(--surface-card));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
}
.layout-card__thumb {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
height: 38px;
border: 1px solid #e5e7eb;
border-radius: 5px;
padding: 6px;
background: #f9fafb;
}
.layout-card__label {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-color-secondary);
text-align: center;
line-height: 1.3;
}
.layout-card--active .layout-card__label {
color: var(--primary-color, #6366f1);
}
/* Elementos internos dos cards */
.lc-logo {
width: 22px;
height: 22px;
border-radius: 3px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 35%, #e5e7eb);
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
}
.lc-spacer {
flex: 1;
min-width: 4px;
}
.lc-lines {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.lc-lines--center {
align-items: center;
}
.lc-lines--right {
align-items: flex-end;
}
.lc-line {
height: 3px;
background: #d1d5db;
border-radius: 2px;
}
.lc-line.short {
width: 60%;
}
.lc-center {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
gap: 2px;
}
.lc-center .lc-logo {
width: 18px;
height: 18px;
}
.lc-center .lc-line {
width: 100%;
}
/* Esconde botão de imagem do Quill em todos os editores desta página */
:deep(.ql-image) {
display: none !important;
}
</style>
@@ -378,6 +378,42 @@ function confirmRestoreTemplate(tpl) {
});
}
// ══════════════════════════════════════════════════════════════
// ABA 2 — Emojis rápidos para o guia de formatação
// ══════════════════════════════════════════════════════════════
const QUICK_EMOJIS = [
{ char: '📅', label: 'Calendário' },
{ char: '⏰', label: 'Relógio / Lembrete' },
{ char: '✅', label: 'Confirmado' },
{ char: '❌', label: 'Cancelado' },
{ char: '🔔', label: 'Notificação' },
{ char: '💬', label: 'Mensagem' },
{ char: '💙', label: 'Cuidado / Saúde' },
{ char: '🌿', label: 'Bem-estar' },
{ char: '🤝', label: 'Parceria / Encontro' },
{ char: '📋', label: 'Formulário / Triagem' },
{ char: '💰', label: 'Financeiro' },
{ char: '🔗', label: 'Link' },
{ char: '📍', label: 'Local' },
{ char: '📞', label: 'Telefone' },
{ char: '🏥', label: 'Clínica' },
{ char: '🧠', label: 'Psicologia' },
{ char: '👤', label: 'Paciente' },
{ char: '🌟', label: 'Destaque' },
{ char: '⚠️', label: 'Atenção' },
{ char: '➡️', label: 'Seta / Próximo passo' },
{ char: '🗓️', label: 'Agenda' },
{ char: '🕐', label: 'Hora' },
{ char: '📩', label: 'Recebido' },
{ char: '🔄', label: 'Reagendamento' }
];
function copyEmoji(char) {
navigator.clipboard?.writeText(char).catch(() => {});
toast.add({ severity: 'info', summary: `${char} copiado!`, life: 1500 });
}
// ══════════════════════════════════════════════════════════════
// ABA 3 — Logs de envio
// ══════════════════════════════════════════════════════════════
@@ -533,47 +569,152 @@ onBeforeUnmount(() => {
<!-- ABA 2 Templates -->
<TabPanel :value="1">
<div class="flex flex-col gap-3 pt-3">
<!-- Skeleton loading -->
<template v-if="templatesLoading">
<div v-for="n in 4" :key="n" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2 mb-3">
<Skeleton width="5rem" height="1.4rem" border-radius="999px" />
<Skeleton width="10rem" height="1rem" />
<div class="flex gap-4 pt-3 items-start">
<!-- Coluna esquerda: cards de templates (65%) -->
<div class="flex flex-col gap-3 min-w-0" style="flex: 0 0 65%;">
<!-- Skeleton loading -->
<template v-if="templatesLoading">
<div v-for="n in 4" :key="n" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2 mb-3">
<Skeleton width="5rem" height="1.4rem" border-radius="999px" />
<Skeleton width="10rem" height="1rem" />
</div>
<Skeleton width="100%" height="5rem" class="mb-2" />
<div class="flex gap-1">
<Skeleton v-for="i in 3" :key="i" width="6rem" height="1.6rem" border-radius="999px" />
</div>
</div>
<Skeleton width="100%" height="5rem" class="mb-2" />
<div class="flex gap-1">
<Skeleton v-for="i in 3" :key="i" width="6rem" height="1.6rem" border-radius="999px" />
</template>
<!-- Cards de templates -->
<div v-else v-for="tpl in templates" :key="tpl.key" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-3">
<!-- Header do card -->
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">{{ tpl.label }}</span>
<Tag :value="tpl.type" :severity="tpl.type_severity" class="text-[0.65rem]" />
<Tag v-if="tpl.is_custom" value="Personalizado" severity="success" class="text-[0.65rem]" />
</div>
</div>
</template>
<!-- Cards de templates -->
<div v-else v-for="tpl in templates" :key="tpl.key" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-3">
<!-- Header do card -->
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">{{ tpl.label }}</span>
<Tag :value="tpl.type" :severity="tpl.type_severity" class="text-[0.65rem]" />
<Tag v-if="tpl.is_custom" value="Personalizado" severity="success" class="text-[0.65rem]" />
</div>
<!-- Textarea editável -->
<Textarea :ref="(el) => setTextareaRef(tpl.key, el)" v-model="tpl.body_text" rows="5" auto-resize class="w-full text-sm font-mono" />
<!-- Textarea editável -->
<Textarea :ref="(el) => setTextareaRef(tpl.key, el)" v-model="tpl.body_text" rows="5" auto-resize class="w-full text-sm font-mono" />
<!-- Variáveis clicáveis -->
<div class="flex flex-col gap-1.5">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button v-for="v in tpl.variables" :key="v" :label="`{{${v}}}`" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVariable(tpl.key, v)" />
<!-- Variáveis clicáveis -->
<div class="flex flex-col gap-1.5">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button v-for="v in tpl.variables" :key="v" :label="`{{${v}}}`" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVariable(tpl.key, v)" />
</div>
</div>
</div>
<!-- Ações -->
<div class="flex items-center gap-2 justify-end">
<Button v-if="isTemplateModified(tpl)" label="Restaurar original" icon="pi pi-undo" size="small" severity="secondary" outlined @click="confirmRestoreTemplate(tpl)" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="templateSaving[tpl.key]" :disabled="templateSaving[tpl.key]" @click="saveTemplate(tpl)" />
<!-- Ações -->
<div class="flex items-center gap-2 justify-end">
<Button v-if="isTemplateModified(tpl)" label="Restaurar original" icon="pi pi-undo" size="small" severity="secondary" outlined @click="confirmRestoreTemplate(tpl)" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="templateSaving[tpl.key]" :disabled="templateSaving[tpl.key]" @click="saveTemplate(tpl)" />
</div>
</div>
</div>
<!-- Coluna direita: guia de formatação (35%) -->
<div class="flex flex-col gap-3 sticky top-4" style="flex: 0 0 35%;">
<div class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-4">
<div class="flex items-center gap-2">
<i class="pi pi-book text-[var(--primary-color)]" />
<span class="font-semibold text-sm">Guia de formatação</span>
</div>
<!-- Formatação oficial -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-[0.7rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Formatação oficial</span>
<div class="flex-1 h-px bg-[var(--surface-border)]" />
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">*texto*</span>
<span class="text-xs font-bold">Negrito</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">_texto_</span>
<span class="text-xs italic">Itálico</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">~texto~</span>
<span class="text-xs line-through">Tachado</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">`texto`</span>
<span class="text-xs font-mono bg-[var(--surface-ground)] px-1 rounded">Monoespaçado</span>
</div>
</div>
</div>
<!-- Efeitos extras Unicode -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-[0.7rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Efeitos extras (Unicode)</span>
<div class="flex-1 h-px bg-[var(--surface-border)]" />
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-[var(--text-color-secondary)]">Negrito Unicode</span>
<span class="text-xs">𝙝𝙤𝙡𝙖</span>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Copie de sites de "font generator"</span>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-[var(--text-color-secondary)]">Cursiva Unicode</span>
<span class="text-xs">𝓽𝓮𝔁𝓽𝓸</span>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Cada letra é um caractere diferente</span>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-[var(--text-color-secondary)]">Small Caps</span>
<span class="text-xs">ᴛᴇxᴛᴏ</span>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Bom para títulos curtos</span>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-[var(--text-color-secondary)]">Sublinhado</span>
<span class="text-xs">t̲e̲x̲t̲o̲</span>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">U+0332 após cada letra</span>
</div>
</div>
</div>
<!-- Emojis mais usados -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-[0.7rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Emojis mais usados</span>
<div class="flex-1 h-px bg-[var(--surface-border)]" />
</div>
<div class="flex flex-wrap gap-1">
<button
v-for="emoji in QUICK_EMOJIS"
:key="emoji.char"
v-tooltip.top="emoji.label"
class="text-base leading-none p-1 rounded hover:bg-[var(--surface-hover)] transition-colors cursor-pointer border-0 bg-transparent"
@click="copyEmoji(emoji.char)"
>{{ emoji.char }}</button>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Clique para copiar</span>
</div>
<!-- Dica -->
<div class="flex items-start gap-2 px-3 py-2.5 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<i class="pi pi-lightbulb text-amber-500 text-xs mt-0.5 shrink-0" />
<p class="text-[0.68rem] text-[var(--text-color-secondary)] m-0 leading-relaxed">
Use <strong>*negrito*</strong> para destacar horários e datas. Evite excesso de formatação mensagens simples têm maior taxa de leitura.
</p>
</div>
</div>
</div>
</div>
</TabPanel>