Agenda google, avisos globais, feriados + avisos globais, templates de email, configuracoes empresa, preview empresa.
This commit is contained in:
@@ -0,0 +1,850 @@
|
||||
<!-- src/layout/configuracoes/ConfiguracoesEmailTemplatesPage.vue -->
|
||||
<script setup>
|
||||
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 { supabase } from '@/lib/supabase/client'
|
||||
import { renderEmail, renderTemplate, generateLayoutSection } from '@/lib/email/emailTemplateService'
|
||||
import { MOCK_DATA, TEMPLATE_DOMAINS } from '@/lib/email/emailTemplateConstants'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const router = useRouter()
|
||||
|
||||
// ── Contexto ──────────────────────────────────────────────────
|
||||
const tenantId = ref(null)
|
||||
const profileLogoUrl = ref(null)
|
||||
|
||||
async function loadUser() {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
tenantId.value = user.id
|
||||
const { data } = await supabase
|
||||
.from('company_profiles')
|
||||
.select('logo_url')
|
||||
.eq('tenant_id', user.id)
|
||||
.maybeSingle()
|
||||
profileLogoUrl.value = data?.logo_url || null
|
||||
}
|
||||
|
||||
// ── Layout config global (header/footer do tenant) ────────────
|
||||
const layoutConfigId = ref(null)
|
||||
const layoutConfig = ref({ header: defaultSection(), footer: defaultSection() })
|
||||
|
||||
function defaultSection() {
|
||||
return { enabled: false, content: '', layout: null }
|
||||
}
|
||||
|
||||
async function loadLayoutConfig() {
|
||||
if (!tenantId.value) return
|
||||
const { data } = await supabase
|
||||
.from('email_layout_config')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId.value)
|
||||
.maybeSingle()
|
||||
if (data) {
|
||||
layoutConfigId.value = data.id
|
||||
layoutConfig.value.header = { ...defaultSection(), ...(data.header_config || {}) }
|
||||
layoutConfig.value.footer = { ...defaultSection(), ...(data.footer_config || {}) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Templates globais + overrides ─────────────────────────────
|
||||
const globals = ref([])
|
||||
const overrides = ref([])
|
||||
const loading = ref(false)
|
||||
const filterDomain = ref(null)
|
||||
|
||||
async function load() {
|
||||
if (!tenantId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const [{ data: gData, error: gErr }, { data: oData, error: oErr }] = await Promise.all([
|
||||
supabase.from('email_templates_global').select('*').eq('is_active', true).order('domain').order('key'),
|
||||
supabase.from('email_templates_tenant').select('*').eq('tenant_id', tenantId.value).is('owner_id', null),
|
||||
])
|
||||
if (gErr) throw gErr
|
||||
if (oErr) throw oErr
|
||||
globals.value = gData || []
|
||||
overrides.value = oData || []
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const overrideMap = computed(() => {
|
||||
const map = {}
|
||||
for (const o of overrides.value) map[o.template_key] = o
|
||||
return map
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
const list = filterDomain.value
|
||||
? globals.value.filter(t => t.domain === filterDomain.value)
|
||||
: globals.value
|
||||
return list.map(g => {
|
||||
const ov = overrideMap.value[g.key] || null
|
||||
const mock = _mockForDomain(g.domain)
|
||||
return {
|
||||
...g,
|
||||
override: ov,
|
||||
has_override: !!ov,
|
||||
needs_sync: ov ? (ov.synced_version !== null && ov.synced_version < g.version) : false,
|
||||
rendered_subject: renderTemplate(ov?.subject ?? g.subject, mock),
|
||||
rendered_body_snippet: _bodySnippet(ov?.body_html ?? g.body_html, mock),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function _bodySnippet(html, mock) {
|
||||
const rendered = renderTemplate(html || '', mock)
|
||||
const text = rendered
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return text.length > 100 ? text.slice(0, 100) + '…' : text
|
||||
}
|
||||
|
||||
function _mockForDomain(domain) {
|
||||
if (domain === TEMPLATE_DOMAINS.SESSION) return { ...MOCK_DATA.session }
|
||||
if (domain === TEMPLATE_DOMAINS.INTAKE) return { ...MOCK_DATA.intake }
|
||||
return { ...MOCK_DATA.system }
|
||||
}
|
||||
|
||||
const DOMAIN_OPTIONS = [
|
||||
{ label: 'Todos', value: null },
|
||||
{ label: 'Sessão', value: TEMPLATE_DOMAINS.SESSION },
|
||||
{ label: 'Triagem',value: TEMPLATE_DOMAINS.INTAKE },
|
||||
{ label: 'Sistema',value: TEMPLATE_DOMAINS.SYSTEM },
|
||||
]
|
||||
const DOMAIN_LABEL = { session: 'Sessão', intake: 'Triagem', billing: 'Financeiro', system: 'Sistema' }
|
||||
const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success', system: 'secondary' }
|
||||
|
||||
// ── 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 = {
|
||||
header: { ...defaultSection(), ...layoutConfig.value.header },
|
||||
footer: { ...defaultSection(), ...layoutConfig.value.footer },
|
||||
}
|
||||
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)
|
||||
)
|
||||
|
||||
async function saveLayout() {
|
||||
if (!tenantId.value) return
|
||||
layoutDlg.value.saving = true
|
||||
try {
|
||||
const payload = {
|
||||
tenant_id: tenantId.value,
|
||||
header_config: layoutForm.value.header,
|
||||
footer_config: layoutForm.value.footer,
|
||||
}
|
||||
if (layoutConfigId.value) {
|
||||
const { error } = await supabase.from('email_layout_config').update(payload).eq('id', layoutConfigId.value)
|
||||
if (error) throw error
|
||||
} else {
|
||||
const { data, error } = await supabase.from('email_layout_config').insert(payload).select('id').single()
|
||||
if (error) throw error
|
||||
layoutConfigId.value = data.id
|
||||
}
|
||||
layoutConfig.value.header = { ...layoutForm.value.header }
|
||||
layoutConfig.value.footer = { ...layoutForm.value.footer }
|
||||
toast.add({ severity: 'success', summary: 'Layout salvo', life: 3000 })
|
||||
layoutDlg.value.open = false
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
|
||||
} finally {
|
||||
layoutDlg.value.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialog edição de template ──────────────────────────────────
|
||||
const dlg = ref({ open: false, saving: false, mode: 'create', globalTemplate: null })
|
||||
const form = ref({})
|
||||
const editorRef = ref(null)
|
||||
|
||||
function openEdit(row) {
|
||||
const ov = row.override
|
||||
form.value = {
|
||||
template_key: row.key,
|
||||
use_custom_subject: !!ov?.subject,
|
||||
use_custom_body: !!ov?.body_html,
|
||||
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 || {},
|
||||
}
|
||||
dlg.value = { open: true, saving: false, mode: ov ? 'edit' : 'create', globalTemplate: row }
|
||||
}
|
||||
|
||||
function closeDlg() { dlg.value.open = false }
|
||||
|
||||
const formVariables = computed(() => {
|
||||
const keys = Object.keys(form.value.variables || {})
|
||||
if (!keys.includes('clinic_logo_url')) keys.push('clinic_logo_url')
|
||||
return keys
|
||||
})
|
||||
|
||||
function insertVar(varName) {
|
||||
const snippet = `{{${varName}}}`
|
||||
const quill = editorRef.value?.quill
|
||||
if (!quill) { 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() {
|
||||
if (!tenantId.value) return
|
||||
if (form.value.use_custom_subject && !form.value.subject?.trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Subject não pode ser vazio', life: 3000 })
|
||||
return
|
||||
}
|
||||
dlg.value.saving = true
|
||||
try {
|
||||
const payload = {
|
||||
tenant_id: tenantId.value,
|
||||
owner_id: null,
|
||||
template_key: form.value.template_key,
|
||||
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,
|
||||
synced_version: form.value.synced_version,
|
||||
}
|
||||
if (dlg.value.mode === 'create') {
|
||||
const { error } = await supabase.from('email_templates_tenant').insert(payload)
|
||||
if (error) throw error
|
||||
toast.add({ severity: 'success', summary: 'Personalização salva', life: 3000 })
|
||||
} else {
|
||||
const { error } = await supabase.from('email_templates_tenant').update(payload).eq('id', overrideMap.value[form.value.template_key].id)
|
||||
if (error) throw error
|
||||
toast.add({ severity: 'success', summary: 'Personalização salva', life: 3000 })
|
||||
}
|
||||
closeDlg()
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
|
||||
} finally {
|
||||
dlg.value.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRevert(row) {
|
||||
confirm.require({
|
||||
message: `Reverter "${row.key}" para o template global?`,
|
||||
header: 'Remover personalização',
|
||||
icon: 'pi pi-undo',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
const { error } = await supabase.from('email_templates_tenant').delete().eq('id', row.override.id)
|
||||
if (error) throw error
|
||||
toast.add({ severity: 'success', summary: 'Revertido para o padrão', life: 3000 })
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Dialog preview ─────────────────────────────────────────────
|
||||
const preview = ref({ open: false, subject: '', body_html: '', key: '', source: '' })
|
||||
|
||||
function openPreview(row) {
|
||||
const resolved = {
|
||||
...row,
|
||||
subject: row.override?.subject ?? row.subject,
|
||||
body_html: row.override?.body_html ?? row.body_html,
|
||||
body_text: row.override?.body_text ?? row.body_text,
|
||||
}
|
||||
const mock = {
|
||||
..._mockForDomain(row.domain),
|
||||
clinic_logo_url: profileLogoUrl.value || null,
|
||||
}
|
||||
const rendered = renderEmail(resolved, mock, {
|
||||
headerConfig: layoutConfig.value.header,
|
||||
footerConfig: layoutConfig.value.footer,
|
||||
logoUrl: profileLogoUrl.value,
|
||||
})
|
||||
preview.value = { open: true, ...rendered, key: row.key, source: row.has_override ? 'personalizado' : 'global' }
|
||||
}
|
||||
|
||||
const layoutActive = computed(() =>
|
||||
layoutConfig.value.header.enabled || layoutConfig.value.footer.enabled
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadUser()
|
||||
await Promise.all([load(), loadLayoutConfig()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Subheader -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-envelope" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">Templates de E-mail</div>
|
||||
<div class="cfg-subheader__sub">Personalize os e-mails enviados aos seus pacientes</div>
|
||||
</div>
|
||||
<div class="cfg-subheader__actions">
|
||||
<Button
|
||||
label="Personalizar +"
|
||||
icon="pi pi-palette"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
@click="openLayoutDlg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtro -->
|
||||
<div class="flex 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>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="row in filtered"
|
||||
:key="row.id"
|
||||
class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] px-4 py-3 flex gap-3 items-center"
|
||||
>
|
||||
<Tag
|
||||
:value="DOMAIN_LABEL[row.domain] ?? row.domain"
|
||||
:severity="DOMAIN_SEVERITY[row.domain] ?? 'secondary'"
|
||||
class="text-[0.7rem] shrink-0 self-start mt-0.5"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap mb-0.5">
|
||||
<span class="font-mono text-xs text-[var(--text-color-secondary)]">{{ row.key }}</span>
|
||||
<Tag v-if="row.has_override" value="Personalizado" severity="success" class="text-[0.65rem]" />
|
||||
<Tag v-if="row.needs_sync" value="Desatualizado" severity="warning" class="text-[0.65rem]" />
|
||||
</div>
|
||||
<div class="text-sm font-medium truncate">{{ row.rendered_subject }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] truncate mt-0.5">{{ row.rendered_body_snippet }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button icon="pi pi-eye" text rounded size="small" severity="secondary" title="Preview" @click="openPreview(row)" />
|
||||
<Button
|
||||
:icon="row.has_override ? 'pi pi-pencil' : 'pi pi-sliders-h'"
|
||||
text rounded size="small"
|
||||
:title="row.has_override ? 'Editar personalização' : 'Personalizar'"
|
||||
@click="openEdit(row)"
|
||||
/>
|
||||
<Button
|
||||
v-if="row.has_override"
|
||||
icon="pi pi-undo" text rounded size="small" severity="danger"
|
||||
title="Reverter para padrão"
|
||||
@click="confirmRevert(row)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Dialog Layout Global (Header/Footer) ─────────────── -->
|
||||
<Dialog
|
||||
v-model:visible="layoutDlg.open"
|
||||
header="Personalizar Layout do E-mail"
|
||||
modal
|
||||
:style="{ width: '820px', maxWidth: '96vw' }"
|
||||
:draggable="false"
|
||||
>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
|
||||
<!-- Banner: trocar logo -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
||||
<i class="pi pi-image text-[var(--text-color-secondary)] text-sm opacity-60" />
|
||||
<span class="text-sm flex-1 text-[var(--text-color-secondary)]">
|
||||
Para trocar sua logo, acesse <strong>Minha Empresa</strong>.
|
||||
</span>
|
||||
<Button
|
||||
label="Minha Empresa"
|
||||
icon="pi pi-building"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="shrink-0"
|
||||
@click="router.push('/configuracoes/empresa')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── HEADER ──────────────────────────────────────── -->
|
||||
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-[var(--surface-ground)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-arrow-up text-xs opacity-50" />
|
||||
<span class="text-sm font-semibold">Header</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch v-model="layoutForm.header.enabled" inputId="sw-header" />
|
||||
<label for="sw-header" class="text-xs cursor-pointer select-none">Ativo</label>
|
||||
</div>
|
||||
</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">Só 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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── FOOTER ──────────────────────────────────────── -->
|
||||
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-[var(--surface-ground)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-arrow-down text-xs opacity-50" />
|
||||
<span class="text-sm font-semibold">Rodapé</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch v-model="layoutForm.footer.enabled" inputId="sw-footer" />
|
||||
<label for="sw-footer" class="text-xs cursor-pointer select-none">Ativo</label>
|
||||
</div>
|
||||
</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">Só 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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="layoutDlg.open = false" :disabled="layoutDlg.saving" />
|
||||
<Button label="Salvar layout" icon="pi pi-check" :loading="layoutDlg.saving" @click="saveLayout" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ── Dialog Edição de Template ────────────────────────── -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
:header="dlg.mode === 'create' ? `Personalizar — ${form.template_key}` : `Editar — ${form.template_key}`"
|
||||
modal
|
||||
:style="{ width: '840px', maxWidth: '96vw' }"
|
||||
:draggable="false"
|
||||
>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
|
||||
<!-- Subject -->
|
||||
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-[var(--surface-ground)]">
|
||||
<span class="text-sm font-semibold">Subject</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch v-model="form.use_custom_subject" inputId="sw-custom-subject" />
|
||||
<label for="sw-custom-subject" class="text-xs cursor-pointer select-none">Personalizar</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<InputText v-if="form.use_custom_subject" v-model="form.subject" class="w-full" />
|
||||
<p v-else class="text-xs text-[var(--text-color-secondary)] m-0 italic">
|
||||
Usando padrão: "{{ dlg.globalTemplate?.subject }}"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Corpo -->
|
||||
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-[var(--surface-ground)]">
|
||||
<span class="text-sm font-semibold">Corpo do e-mail</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch v-model="form.use_custom_body" inputId="sw-custom-body" />
|
||||
<label for="sw-custom-body" class="text-xs cursor-pointer select-none">Personalizar</label>
|
||||
</div>
|
||||
</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>
|
||||
<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 formVariables" :key="v"
|
||||
:label="`{{${v}}}`"
|
||||
size="small" severity="secondary" outlined
|
||||
class="font-mono !text-[0.68rem] !py-1 !px-2"
|
||||
@click="insertVar(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold">Versão texto <span class="font-normal opacity-60">(gerado do HTML se vazio)</span></label>
|
||||
<Textarea v-model="form.body_text" rows="2" class="w-full text-xs" auto-resize />
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="text-xs text-[var(--text-color-secondary)] m-0 italic">
|
||||
Usando o corpo padrão do sistema.
|
||||
</p>
|
||||
</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>
|
||||
<Button label="Cancelar" text @click="closeDlg" :disabled="dlg.saving" />
|
||||
<Button :label="dlg.mode === 'create' ? 'Salvar personalização' : 'Salvar'" icon="pi pi-check" :loading="dlg.saving" @click="save" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ── Dialog Preview ──────────────────────────────────── -->
|
||||
<Dialog
|
||||
v-model:visible="preview.open"
|
||||
:header="`Preview — ${preview.key}`"
|
||||
modal
|
||||
:style="{ width: '680px', maxWidth: '96vw' }"
|
||||
:draggable="false"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-center">
|
||||
<Tag
|
||||
:value="preview.source === 'personalizado' ? 'Usando sua personalização' : 'Usando template global'"
|
||||
:severity="preview.source === 'personalizado' ? 'success' : 'secondary'"
|
||||
/>
|
||||
</div>
|
||||
<div class="border border-[var(--surface-border)] rounded-lg p-3 bg-[var(--surface-ground)]">
|
||||
<span class="text-xs font-semibold text-[var(--text-color-secondary)] uppercase tracking-widest">Subject</span>
|
||||
<p class="mt-1 mb-0 font-medium">{{ preview.subject }}</p>
|
||||
</div>
|
||||
<div class="border border-[var(--surface-border)] rounded-lg p-5 bg-white text-gray-800 text-sm leading-relaxed">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="preview.body_html" />
|
||||
</div>
|
||||
<p v-if="!profileLogoUrl" class="text-xs text-[var(--text-color-secondary)] m-0 text-center">
|
||||
Adicione um avatar no seu perfil para exibir o logo no header/rodapé.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" @click="preview.open = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cfg-subheader {
|
||||
display: flex; align-items: center; gap: 0.65rem;
|
||||
padding: 0.875rem 1rem; border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
|
||||
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
|
||||
var(--surface-card) 100%);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: ''; position: absolute; top: -20px; right: -20px;
|
||||
width: 80px; height: 80px; border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
|
||||
filter: blur(20px); pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
|
||||
color: var(--primary-color,#6366f1); font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
|
||||
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
|
||||
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
|
||||
|
||||
/* ── 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>
|
||||
@@ -0,0 +1,706 @@
|
||||
<!-- src/layout/configuracoes/ConfiguracoesMinhaEmpresaPage.vue -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// ── Constantes ────────────────────────────────────────────────
|
||||
const AVATAR_BUCKET = 'avatars'
|
||||
|
||||
const TIPO_OPTIONS = [
|
||||
'MEI', 'ME', 'EPP', 'EIRELI', 'SLU', 'LTDA', 'S/A',
|
||||
'Associação', 'Fundação', 'Cooperativa', 'Outro'
|
||||
]
|
||||
|
||||
const REDES_OPTIONS = [
|
||||
{ label: 'Instagram', value: 'instagram' },
|
||||
{ label: 'Facebook', value: 'facebook' },
|
||||
{ label: 'LinkedIn', value: 'linkedin' },
|
||||
{ label: 'X / Twitter', value: 'twitter' },
|
||||
{ label: 'YouTube', value: 'youtube' },
|
||||
{ label: 'TikTok', value: 'tiktok' },
|
||||
{ label: 'WhatsApp', value: 'whatsapp' },
|
||||
{ label: 'Telegram', value: 'telegram' },
|
||||
{ label: 'Pinterest', value: 'pinterest' },
|
||||
{ label: 'Outro', value: 'outro' },
|
||||
]
|
||||
|
||||
const REDE_ICONS = {
|
||||
instagram: 'pi-instagram',
|
||||
facebook: 'pi-facebook',
|
||||
linkedin: 'pi-linkedin',
|
||||
twitter: 'pi-twitter',
|
||||
youtube: 'pi-youtube',
|
||||
whatsapp: 'pi-whatsapp',
|
||||
tiktok: 'pi-tiktok',
|
||||
telegram: 'pi-send',
|
||||
pinterest: 'pi-heart',
|
||||
outro: 'pi-link',
|
||||
}
|
||||
|
||||
const ESTADOS = [
|
||||
'AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS',
|
||||
'MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC',
|
||||
'SP','SE','TO'
|
||||
]
|
||||
|
||||
// ── Estado ────────────────────────────────────────────────────
|
||||
const tenantId = ref(null)
|
||||
const recordId = ref(null)
|
||||
const saving = ref(false)
|
||||
const loadingCep = ref(false)
|
||||
|
||||
const form = ref({
|
||||
nome_fantasia: '',
|
||||
razao_social: '',
|
||||
tipo_empresa: null,
|
||||
cnpj: '',
|
||||
ie: '',
|
||||
im: '',
|
||||
cep: '',
|
||||
logradouro: '',
|
||||
numero: '',
|
||||
complemento: '',
|
||||
bairro: '',
|
||||
cidade: '',
|
||||
estado: null,
|
||||
email: '',
|
||||
telefone: '',
|
||||
site: '',
|
||||
logo_url: '',
|
||||
redes_sociais: [],
|
||||
})
|
||||
|
||||
// ── Computed preview ──────────────────────────────────────────
|
||||
const logoDisplay = computed(() => logoPreview.value || form.value.logo_url || null)
|
||||
|
||||
const enderecoLinhas = computed(() => {
|
||||
const f = form.value
|
||||
const lines = []
|
||||
if (f.logradouro) {
|
||||
let l = f.logradouro
|
||||
if (f.numero) l += `, ${f.numero}`
|
||||
if (f.complemento) l += ` — ${f.complemento}`
|
||||
lines.push(l)
|
||||
}
|
||||
if (f.bairro) lines.push(f.bairro)
|
||||
const ce = [f.cidade, f.estado].filter(Boolean).join(' / ')
|
||||
if (ce) lines.push(ce)
|
||||
if (f.cep) lines.push(`CEP ${f.cep}`)
|
||||
return lines
|
||||
})
|
||||
|
||||
const redesValidas = computed(() =>
|
||||
form.value.redes_sociais.filter(r => r.rede && r.url)
|
||||
)
|
||||
|
||||
const temDados = computed(() =>
|
||||
!!(form.value.nome_fantasia || form.value.razao_social || logoDisplay.value)
|
||||
)
|
||||
|
||||
// ── Logo upload ───────────────────────────────────────────────
|
||||
const logoInputRef = ref(null)
|
||||
const logoPreview = ref(null)
|
||||
const logoFile = ref(null)
|
||||
const uploadingLogo = ref(false)
|
||||
|
||||
function onLogoClick() { logoInputRef.value?.click() }
|
||||
|
||||
function onLogoChange(e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (!['image/jpeg','image/png','image/webp','image/svg+xml'].includes(file.type)) {
|
||||
toast.add({ severity: 'warn', summary: 'Formato inválido', detail: 'Use JPG, PNG, WEBP ou SVG.', life: 3000 })
|
||||
return
|
||||
}
|
||||
logoFile.value = file
|
||||
logoPreview.value = URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
function removeLogo() {
|
||||
logoFile.value = null
|
||||
logoPreview.value = null
|
||||
form.value.logo_url = ''
|
||||
if (logoInputRef.value) logoInputRef.value.value = ''
|
||||
}
|
||||
|
||||
async function uploadLogo() {
|
||||
if (!logoFile.value || !tenantId.value) return form.value.logo_url
|
||||
const ext = logoFile.value.name.split('.').pop().toLowerCase()
|
||||
const path = `${tenantId.value}/company-logo-${Date.now()}.${ext}`
|
||||
uploadingLogo.value = true
|
||||
try {
|
||||
const { error } = await supabase.storage
|
||||
.from(AVATAR_BUCKET)
|
||||
.upload(path, logoFile.value, { upsert: true, contentType: logoFile.value.type })
|
||||
if (error) throw error
|
||||
const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
|
||||
return data?.publicUrl || ''
|
||||
} finally {
|
||||
uploadingLogo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── CEP ───────────────────────────────────────────────────────
|
||||
async function buscarCep() {
|
||||
const cep = form.value.cep.replace(/\D/g, '')
|
||||
if (cep.length !== 8) return
|
||||
loadingCep.value = true
|
||||
try {
|
||||
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`)
|
||||
const data = await res.json()
|
||||
if (data.erro) { toast.add({ severity: 'warn', summary: 'CEP não encontrado', life: 3000 }); return }
|
||||
form.value.logradouro = data.logradouro || ''
|
||||
form.value.bairro = data.bairro || ''
|
||||
form.value.cidade = data.localidade || ''
|
||||
form.value.estado = data.uf || null
|
||||
} catch {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao buscar CEP', life: 3000 })
|
||||
} finally {
|
||||
loadingCep.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Redes sociais ─────────────────────────────────────────────
|
||||
function addRede() { form.value.redes_sociais.push({ rede: null, url: '' }) }
|
||||
function removeRede(i) { form.value.redes_sociais.splice(i, 1) }
|
||||
|
||||
// ── Load / Save ───────────────────────────────────────────────
|
||||
async function load() {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
tenantId.value = user.id
|
||||
const { data } = await supabase
|
||||
.from('company_profiles')
|
||||
.select('*')
|
||||
.eq('tenant_id', user.id)
|
||||
.maybeSingle()
|
||||
if (data) {
|
||||
recordId.value = data.id
|
||||
Object.keys(form.value).forEach(k => {
|
||||
if (data[k] !== undefined && data[k] !== null) form.value[k] = data[k]
|
||||
})
|
||||
if (data.logo_url) logoPreview.value = data.logo_url
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
toast.add({ severity: 'error', summary: 'Sessão expirada', detail: 'Faça login novamente.', life: 4000 })
|
||||
return
|
||||
}
|
||||
tenantId.value = user.id
|
||||
saving.value = true
|
||||
try {
|
||||
if (logoFile.value) form.value.logo_url = await uploadLogo()
|
||||
|
||||
const payload = {
|
||||
tenant_id: tenantId.value,
|
||||
nome_fantasia: form.value.nome_fantasia || null,
|
||||
razao_social: form.value.razao_social || null,
|
||||
tipo_empresa: form.value.tipo_empresa || null,
|
||||
cnpj: form.value.cnpj || null,
|
||||
ie: form.value.ie || null,
|
||||
im: form.value.im || null,
|
||||
cep: form.value.cep || null,
|
||||
logradouro: form.value.logradouro || null,
|
||||
numero: form.value.numero || null,
|
||||
complemento: form.value.complemento || null,
|
||||
bairro: form.value.bairro || null,
|
||||
cidade: form.value.cidade || null,
|
||||
estado: form.value.estado || null,
|
||||
email: form.value.email || null,
|
||||
telefone: form.value.telefone || null,
|
||||
site: form.value.site || null,
|
||||
logo_url: form.value.logo_url || null,
|
||||
redes_sociais: form.value.redes_sociais.filter(r => r.rede && r.url),
|
||||
}
|
||||
|
||||
if (recordId.value) {
|
||||
const { error } = await supabase.from('company_profiles').update(payload).eq('id', recordId.value)
|
||||
if (error) throw error
|
||||
} else {
|
||||
const { data, error } = await supabase.from('company_profiles').insert(payload).select('id').single()
|
||||
if (error) throw error
|
||||
recordId.value = data.id
|
||||
}
|
||||
|
||||
logoFile.value = null
|
||||
toast.add({ severity: 'success', summary: 'Dados salvos com sucesso', life: 3000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dados de exemplo ──────────────────────────────────────────
|
||||
function preencherExemplo() {
|
||||
form.value.nome_fantasia = 'Clínica Mente Sã'
|
||||
form.value.razao_social = 'Mente Sã Serviços de Psicologia Ltda'
|
||||
form.value.tipo_empresa = 'LTDA'
|
||||
form.value.cnpj = '12.345.678/0001-90'
|
||||
form.value.ie = '123.456.789.110'
|
||||
form.value.im = '98765-4'
|
||||
form.value.cep = '13561-260'
|
||||
form.value.logradouro = 'Rua Episcopal'
|
||||
form.value.numero = '738'
|
||||
form.value.complemento = 'Sala 12 — 2º andar'
|
||||
form.value.bairro = 'Centro'
|
||||
form.value.cidade = 'São Carlos'
|
||||
form.value.estado = 'SP'
|
||||
form.value.email = 'contato@mentesa.com.br'
|
||||
form.value.telefone = '(16) 99123-4567'
|
||||
form.value.site = 'https://www.mentesa.com.br'
|
||||
form.value.redes_sociais = [
|
||||
{ rede: 'instagram', url: 'https://instagram.com/mentesa' },
|
||||
{ rede: 'facebook', url: 'https://facebook.com/mentesa' },
|
||||
{ rede: 'linkedin', url: 'https://linkedin.com/company/mentesa' },
|
||||
{ rede: 'whatsapp', url: 'https://wa.me/5516991234567' },
|
||||
]
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Subheader -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-building" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">Minha Empresa</div>
|
||||
<div class="cfg-subheader__sub">Dados da empresa, logomarca e presença digital</div>
|
||||
</div>
|
||||
<div class="cfg-subheader__actions">
|
||||
<Button label="Exemplo" icon="pi pi-magic-wand" size="small" severity="secondary" outlined class="rounded-full" @click="preencherExemplo" />
|
||||
<Button label="Salvar" icon="pi pi-check" size="small" class="rounded-full" :loading="saving" @click="save" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Corpo: formulário + preview -->
|
||||
<div class="flex gap-4 items-start">
|
||||
|
||||
<!-- ── Formulário (60%) ───────────────────────────────── -->
|
||||
<div class="form-col">
|
||||
|
||||
<!-- Logomarca -->
|
||||
<div class="cfg-card">
|
||||
<div class="cfg-card__head">
|
||||
<i class="pi pi-image text-xs opacity-50" />
|
||||
<span>Logomarca</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-5 px-4 py-4">
|
||||
<div class="logo-upload-preview" :class="{ empty: !logoDisplay }" @click="onLogoClick">
|
||||
<img v-if="logoDisplay" :src="logoDisplay" alt="Logo" class="w-full h-full object-contain p-1" />
|
||||
<div v-else class="flex flex-col items-center gap-1 opacity-40">
|
||||
<i class="pi pi-image text-2xl" />
|
||||
<span class="text-[0.65rem]">Clique para adicionar</span>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="logoInputRef" type="file" accept="image/*" class="hidden" @change="onLogoChange" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button label="Carregar imagem" icon="pi pi-upload" size="small" outlined @click="onLogoClick" :loading="uploadingLogo" />
|
||||
<Button v-if="logoDisplay" label="Remover" icon="pi pi-trash" size="small" severity="danger" text @click="removeLogo" />
|
||||
<p class="text-[0.7rem] text-[var(--text-color-secondary)] m-0">JPG, PNG, WEBP ou SVG.<br>Recomendado: fundo transparente.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Identificação -->
|
||||
<div class="cfg-card mt-4">
|
||||
<div class="cfg-card__head">
|
||||
<i class="pi pi-id-card text-xs opacity-50" />
|
||||
<span>Identificação</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 px-4 py-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="cfg-label">Nome fantasia</label>
|
||||
<InputText v-model="form.nome_fantasia" placeholder="Nome comercial da empresa" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="cfg-label">Razão social</label>
|
||||
<InputText v-model="form.razao_social" placeholder="Razão social completa" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="cfg-label">Tipo de empresa</label>
|
||||
<Select v-model="form.tipo_empresa" :options="TIPO_OPTIONS" placeholder="Selecione" class="w-full" show-clear />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="cfg-label">CNPJ</label>
|
||||
<InputText v-model="form.cnpj" placeholder="00.000.000/0001-00" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="cfg-label">Inscrição Estadual (IE)</label>
|
||||
<InputText v-model="form.ie" placeholder="Inscrição Estadual" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="cfg-label">Inscrição Municipal (IM)</label>
|
||||
<InputText v-model="form.im" placeholder="Inscrição Municipal" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endereço -->
|
||||
<div class="cfg-card mt-4">
|
||||
<div class="cfg-card__head">
|
||||
<i class="pi pi-map-marker text-xs opacity-50" />
|
||||
<span>Endereço</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-6 gap-3 px-4 py-4">
|
||||
<div class="flex flex-col gap-1 md:col-span-2">
|
||||
<label class="cfg-label">CEP</label>
|
||||
<div class="flex gap-2">
|
||||
<InputText v-model="form.cep" placeholder="00000-000" class="w-full" @blur="buscarCep" @keydown.enter.prevent="buscarCep" />
|
||||
<Button icon="pi pi-search" severity="secondary" outlined :loading="loadingCep" @click="buscarCep" v-tooltip="'Buscar CEP'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 md:col-span-3">
|
||||
<label class="cfg-label">Logradouro</label>
|
||||
<InputText v-model="form.logradouro" placeholder="Rua, Avenida..." class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 md:col-span-1">
|
||||
<label class="cfg-label">Número</label>
|
||||
<InputText v-model="form.numero" placeholder="Nº" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 md:col-span-2">
|
||||
<label class="cfg-label">Complemento</label>
|
||||
<InputText v-model="form.complemento" placeholder="Sala, apto..." class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 md:col-span-2">
|
||||
<label class="cfg-label">Bairro</label>
|
||||
<InputText v-model="form.bairro" placeholder="Bairro" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 md:col-span-2">
|
||||
<label class="cfg-label">Cidade</label>
|
||||
<InputText v-model="form.cidade" placeholder="Cidade" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 md:col-span-2">
|
||||
<label class="cfg-label">Estado</label>
|
||||
<Select v-model="form.estado" :options="ESTADOS" placeholder="UF" class="w-full" show-clear />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contato -->
|
||||
<div class="cfg-card mt-4">
|
||||
<div class="cfg-card__head">
|
||||
<i class="pi pi-globe text-xs opacity-50" />
|
||||
<span>Contato e Presença Digital</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 px-4 py-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="cfg-label">E-mail da empresa</label>
|
||||
<InputText v-model="form.email" placeholder="contato@empresa.com.br" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="cfg-label">Telefone / WhatsApp</label>
|
||||
<InputText v-model="form.telefone" placeholder="(00) 00000-0000" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="cfg-label">Site</label>
|
||||
<InputText v-model="form.site" placeholder="https://www.empresa.com.br" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Redes Sociais -->
|
||||
<div class="cfg-card mt-4">
|
||||
<div class="cfg-card__head">
|
||||
<i class="pi pi-share-alt text-xs opacity-50" />
|
||||
<span>Redes Sociais</span>
|
||||
<Button label="Adicionar" icon="pi pi-plus" size="small" severity="secondary" outlined class="ml-auto" @click="addRede" />
|
||||
</div>
|
||||
<div class="px-4 py-3 flex flex-col gap-2">
|
||||
<p v-if="form.redes_sociais.length === 0" class="text-sm text-[var(--text-color-secondary)] italic m-0 py-2">
|
||||
Nenhuma rede social adicionada.
|
||||
</p>
|
||||
<div v-for="(item, i) in form.redes_sociais" :key="i" class="flex gap-2 items-center">
|
||||
<Select v-model="item.rede" :options="REDES_OPTIONS" option-label="label" option-value="value" placeholder="Rede" class="w-40 shrink-0" />
|
||||
<InputText v-model="item.url" :placeholder="item.rede ? `URL do ${item.rede}` : 'URL do perfil'" class="flex-1" />
|
||||
<Button icon="pi pi-trash" severity="danger" text rounded size="small" @click="removeRede(i)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Salvar -->
|
||||
<div class="flex justify-end mt-4 pb-2">
|
||||
<Button label="Salvar dados da empresa" icon="pi pi-check" :loading="saving" @click="save" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Preview (40%) ──────────────────────────────────── -->
|
||||
<div class="preview-col">
|
||||
<div class="preview-card">
|
||||
|
||||
<!-- Placeholder vazio -->
|
||||
<div v-if="!temDados" class="preview-empty">
|
||||
<i class="pi pi-building text-3xl opacity-20" />
|
||||
<p class="text-xs opacity-40 mt-2 text-center">Preencha os dados<br>para ver o preview</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Logo -->
|
||||
<div class="preview-logo-wrap">
|
||||
<img v-if="logoDisplay" :src="logoDisplay" alt="Logo" class="preview-logo" />
|
||||
<div v-else class="preview-logo-placeholder">
|
||||
<i class="pi pi-building text-xl opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nome + tipo -->
|
||||
<div class="preview-name-block">
|
||||
<h2 v-if="form.nome_fantasia" class="preview-name">{{ form.nome_fantasia }}</h2>
|
||||
<p v-if="form.razao_social" class="preview-razao">{{ form.razao_social }}</p>
|
||||
<span v-if="form.tipo_empresa" class="preview-tipo">{{ form.tipo_empresa }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Documentos -->
|
||||
<div v-if="form.cnpj || form.ie || form.im" class="preview-section">
|
||||
<div class="preview-divider" />
|
||||
<div class="preview-docs">
|
||||
<div v-if="form.cnpj" class="preview-doc-row">
|
||||
<span class="preview-doc-label">CNPJ</span>
|
||||
<span class="preview-doc-value">{{ form.cnpj }}</span>
|
||||
</div>
|
||||
<div v-if="form.ie" class="preview-doc-row">
|
||||
<span class="preview-doc-label">IE</span>
|
||||
<span class="preview-doc-value">{{ form.ie }}</span>
|
||||
</div>
|
||||
<div v-if="form.im" class="preview-doc-row">
|
||||
<span class="preview-doc-label">IM</span>
|
||||
<span class="preview-doc-value">{{ form.im }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endereço -->
|
||||
<div v-if="enderecoLinhas.length" class="preview-section">
|
||||
<div class="preview-divider" />
|
||||
<div class="preview-info-row">
|
||||
<i class="pi pi-map-marker preview-info-icon" />
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span v-for="(linha, i) in enderecoLinhas" :key="i" class="preview-info-text">{{ linha }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contato -->
|
||||
<div v-if="form.email || form.telefone || form.site" class="preview-section">
|
||||
<div class="preview-divider" />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div v-if="form.email" class="preview-info-row">
|
||||
<i class="pi pi-envelope preview-info-icon" />
|
||||
<span class="preview-info-text">{{ form.email }}</span>
|
||||
</div>
|
||||
<div v-if="form.telefone" class="preview-info-row">
|
||||
<i class="pi pi-phone preview-info-icon" />
|
||||
<span class="preview-info-text">{{ form.telefone }}</span>
|
||||
</div>
|
||||
<div v-if="form.site" class="preview-info-row">
|
||||
<i class="pi pi-globe preview-info-icon" />
|
||||
<span class="preview-info-text truncate">{{ form.site }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Redes sociais -->
|
||||
<div v-if="redesValidas.length" class="preview-section">
|
||||
<div class="preview-divider" />
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
v-for="(r, i) in redesValidas"
|
||||
:key="i"
|
||||
:href="r.url"
|
||||
target="_blank"
|
||||
class="preview-rede"
|
||||
:title="r.rede"
|
||||
>
|
||||
<i :class="`pi ${REDE_ICONS[r.rede] || 'pi-link'}`" />
|
||||
<span>{{ REDES_OPTIONS.find(o => o.value === r.rede)?.label ?? r.rede }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Subheader ─────────────────────────────────────────────── */
|
||||
.cfg-subheader {
|
||||
display: flex; align-items: center; gap: 0.65rem;
|
||||
padding: 0.875rem 1rem; border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
|
||||
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
|
||||
var(--surface-card) 100%);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: ''; position: absolute; top: -20px; right: -20px;
|
||||
width: 80px; height: 80px; border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
|
||||
filter: blur(20px); pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
|
||||
color: var(--primary-color,#6366f1); font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
|
||||
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
|
||||
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
|
||||
|
||||
/* ── Layout colunas ────────────────────────────────────────── */
|
||||
.preview-col {
|
||||
width: 40%;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: calc(var(--layout-sticky-top, 56px) + 58px);
|
||||
align-self: flex-start;
|
||||
}
|
||||
.form-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Preview card ──────────────────────────────────────────── */
|
||||
.preview-card {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-card);
|
||||
overflow: hidden;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.preview-empty {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
min-height: 200px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.preview-logo-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.preview-logo {
|
||||
max-width: 120px;
|
||||
max-height: 72px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.preview-logo-placeholder {
|
||||
width: 72px; height: 72px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-ground);
|
||||
border: 2px dashed var(--surface-border);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.preview-name-block {
|
||||
text-align: center;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
}
|
||||
.preview-name {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
}
|
||||
.preview-razao {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.preview-tipo {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 25%, transparent);
|
||||
color: var(--primary-color,#6366f1);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.preview-section { margin-top: 0.75rem; }
|
||||
.preview-divider { height: 1px; background: var(--surface-border); margin-bottom: 0.75rem; }
|
||||
.preview-docs { display: flex; flex-direction: column; gap: 4px; }
|
||||
.preview-doc-row { display: flex; gap: 8px; align-items: baseline; }
|
||||
.preview-doc-label {
|
||||
font-size: 0.65rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; color: var(--text-color-secondary); opacity: 0.6;
|
||||
width: 28px; flex-shrink: 0;
|
||||
}
|
||||
.preview-doc-value { font-size: 0.8rem; color: var(--text-color); font-family: monospace; }
|
||||
|
||||
.preview-info-row { display: flex; align-items: flex-start; gap: 8px; }
|
||||
.preview-info-icon { font-size: 0.75rem; color: var(--primary-color,#6366f1); opacity: 0.7; margin-top: 2px; flex-shrink: 0; }
|
||||
.preview-info-text { font-size: 0.8rem; color: var(--text-color); line-height: 1.4; }
|
||||
|
||||
.preview-rede {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.preview-rede:hover {
|
||||
border-color: color-mix(in srgb, var(--primary-color,#6366f1) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--primary-color,#6366f1) 6%, var(--surface-card));
|
||||
}
|
||||
.preview-rede i { font-size: 0.75rem; }
|
||||
|
||||
/* ── Form cards ────────────────────────────────────────────── */
|
||||
.cfg-card {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cfg-card__head {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.65rem 1rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.cfg-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.logo-upload-preview {
|
||||
width: 120px; height: 80px;
|
||||
border: 2px dashed var(--surface-border);
|
||||
border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
background: var(--surface-ground);
|
||||
transition: border-color 0.15s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.logo-upload-preview:hover { border-color: var(--primary-color,#6366f1); }
|
||||
.logo-upload-preview.empty { color: var(--text-color-secondary); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user