templates/editor: layout 3-col + tabs cabecalho/corpo/rodape

Refatora DocumentTemplateEditor em 3 colunas seguindo padrao
MelissaAgendaConfig:

- COL 1 (esquerda, 240-280px): form de metadados — nome, tipo,
  descricao (Textarea com autoResize), URL do logo
- COL 2 (centro, flex 1): sub-tabs Cabecalho/Corpo/Rodape, 1
  editor visivel por vez. Cada editor com minHeight: 450px (era
  120/350/120). Tab ativa destacada com border-bottom primary +
  background sutil.
- COL 3 (direita, 220-260px): variaveis agrupadas por categoria,
  hint dinamico mostrando qual sub-tab esta ativa ("Clique para
  inserir no Cabecalho/Corpo/Rodape"). Botoes com {{ }} braces
  em monospace + cor primary.

Scroll interno:
- .dte-page flex column, gap 12, min-height 0, padding 12
- Cada coluna eh card (border + radius) com header sticky + body
  scrollable interno (overflow-y: auto, scrollbar-width: thin)
- Variaveis com max-height proprio + scroll interno

Mobile (<1024px):
- 3-col vira 1-col stacked
- Container do .dte-cols ganha overflow-y auto (scroll da pagina
  inteira em vez de scroll interno em cada coluna)
- Variaveis ganha max-height 320px pra nao ocupar a tela toda

Preview (toggle no topo):
- Documento A4-like centralizado (max-width 794px ≈ 96dpi)
- Padding 48/56px, shadow sutil
- Mobile: padding reduzido pra max width disponivel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 16:59:21 -03:00
parent f1c24242e0
commit 80cce772db
@@ -23,6 +23,8 @@ const emit = defineEmits(['update:modelValue', 'save', 'cancel'])
const { TIPOS_TEMPLATE, TEMPLATE_VARIABLES, variablesGrouped, previewHtml } = useDocumentTemplates()
const activeTab = ref('editor') // editor | preview
// Sub-tab do editor (centro do layout 3-col): qual seção renderiza
const editorTab = ref('corpo') // cabecalho | corpo | rodape
// ── Form reativo synced com modelValue ──────────────────────
@@ -89,127 +91,469 @@ function onSave() {
}
</script>
<style scoped>
/* ═══════ Page chrome (preenche o espaço do container pai) ═══════ */
.dte-page {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
/* padding pra não grudar nas bordas do container pai (mdt-body) */
padding: 12px;
/* fallback pra quando o pai não é flex */
height: 100%;
}
.dte-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 16px;
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 10px;
flex-shrink: 0;
}
.dte-toolbar__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.92rem;
font-weight: 600;
color: var(--text-color);
}
.dte-toolbar__title > i {
color: var(--text-color-secondary);
opacity: 0.7;
}
.dte-toolbar__tabs {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* ═══════ 3-col grid (form / editor / variáveis) ═══════ */
.dte-cols {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(220px, 260px);
gap: 12px;
align-items: stretch;
}
/* COL 1 — Form metadados */
.dte-side {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 10px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.dte-side__head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--surface-border);
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color);
flex-shrink: 0;
}
.dte-side__head > i {
color: var(--text-color-secondary);
opacity: 0.7;
}
.dte-side__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
}
.dte-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.dte-field label {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-color-secondary);
}
/* COL 2 — Editor com sub-tabs */
.dte-main {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 10px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.dte-main__tabs {
display: flex;
align-items: center;
gap: 0;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
flex-shrink: 0;
}
.dte-tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 18px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-color-secondary);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease;
font-family: inherit;
}
.dte-tab:hover {
color: var(--text-color);
background: color-mix(in srgb, var(--p-primary-color) 4%, transparent);
}
.dte-tab.is-active {
color: var(--p-primary-color);
border-bottom-color: var(--p-primary-color);
background: color-mix(in srgb, var(--p-primary-color) 6%, transparent);
}
.dte-tab > i { font-size: 0.82rem; }
.dte-main__editor {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px;
background: var(--surface-card);
scrollbar-width: thin;
}
.dte-editor-wrap {
min-height: 450px;
display: flex;
flex-direction: column;
}
.dte-editor-wrap > * {
flex: 1;
min-height: 450px;
}
/* COL 3 — Variáveis */
.dte-vars {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 10px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.dte-vars__head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px 8px;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color);
flex-shrink: 0;
}
.dte-vars__head > i {
color: var(--p-primary-color);
}
.dte-vars__hint {
margin: 0 14px 6px;
font-size: 0.72rem;
color: var(--text-color-secondary);
line-height: 1.4;
flex-shrink: 0;
}
.dte-vars__hint strong {
color: var(--text-color);
}
.dte-vars__list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 10px 12px;
display: flex;
flex-direction: column;
gap: 14px;
scrollbar-width: thin;
}
.dte-vars__group {
display: flex;
flex-direction: column;
gap: 4px;
}
.dte-vars__group-title {
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-color-secondary);
opacity: 0.75;
padding: 0 4px;
}
.dte-vars__group-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.dte-vars__btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-color);
font-size: 0.74rem;
text-align: left;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
font-family: inherit;
}
.dte-vars__btn:hover {
background: color-mix(in srgb, var(--p-primary-color) 10%, transparent);
color: var(--p-primary-color);
}
.dte-vars__btn-brace {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-size: 0.66rem;
color: var(--p-primary-color);
opacity: 0.6;
}
.dte-vars__btn-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ═══════ Preview ═══════ */
.dte-preview {
flex: 1;
min-height: 0;
overflow-y: auto;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
border-radius: 10px;
padding: 24px;
display: flex;
justify-content: center;
}
.dte-preview__doc {
background: white;
color: #1a1a1a;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
width: 100%;
max-width: 794px; /* ≈ A4 a 96dpi */
padding: 48px 56px;
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 12pt;
line-height: 1.6;
min-height: 500px;
}
.dte-preview__header {
text-align: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #ccc;
}
.dte-preview__body {
min-height: 300px;
}
.dte-preview__footer {
margin-top: 32px;
padding-top: 12px;
border-top: 1px solid #ccc;
text-align: center;
font-size: 10pt;
color: #666;
}
/* ═══════ Mobile (<1024px): empilha 1 col ═══════ */
@media (max-width: 1023px) {
.dte-cols {
grid-template-columns: 1fr;
overflow-y: auto;
align-items: start;
}
.dte-side,
.dte-main,
.dte-vars {
height: auto;
}
.dte-vars__list {
max-height: 320px;
}
.dte-preview {
padding: 12px;
}
.dte-preview__doc {
padding: 24px 18px;
}
}
</style>
<template>
<div class="flex flex-col gap-3 xl:gap-4">
<!-- Card: Identificação -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<i class="pi pi-tag text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-sm">Identificação</span>
<div class="dte-page">
<!-- Toggle Editor / Preview no topo -->
<div class="dte-toolbar">
<div class="dte-toolbar__title">
<i class="pi pi-file-edit" />
<span>Conteúdo do documento</span>
</div>
<div class="p-4 flex flex-col gap-3">
<div class="grid grid-cols-1 sm:grid-cols-[1fr_220px] gap-3">
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Nome do template</label>
<div class="dte-toolbar__tabs">
<Button
label="Editor"
icon="pi pi-pencil"
:severity="activeTab === 'editor' ? undefined : 'secondary'"
:outlined="activeTab !== 'editor'"
size="small"
class="rounded-full"
@click="activeTab = 'editor'"
/>
<Button
label="Preview"
icon="pi pi-eye"
:severity="activeTab === 'preview' ? undefined : 'secondary'"
:outlined="activeTab !== 'preview'"
size="small"
class="rounded-full"
@click="activeTab = 'preview'"
/>
</div>
</div>
<!-- EDITOR 3 colunas (form / editor / variáveis) -->
<div v-show="activeTab === 'editor'" class="dte-cols">
<!-- COL 1 (esquerda): Form de metadados -->
<aside class="dte-side">
<div class="dte-side__head">
<i class="pi pi-tag" />
<span>Identificação</span>
</div>
<div class="dte-side__body">
<div class="dte-field">
<label>Nome do template</label>
<InputText v-model="form.nome_template" placeholder="Ex: Declaração de comparecimento" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Tipo</label>
<div class="dte-field">
<label>Tipo</label>
<Select v-model="form.tipo" :options="TIPOS_TEMPLATE" optionLabel="label" optionValue="value" class="w-full" />
</div>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Descrição</label>
<InputText v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">URL do logo (opcional)</label>
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
</div>
</div>
</div>
<!-- Card: Conteúdo -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2 min-w-0">
<i class="pi pi-file-edit text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-sm">Conteúdo do documento</span>
</div>
<!-- Tabs: Editor / Preview -->
<div class="flex items-center gap-1">
<Button
:label="'Editor'"
icon="pi pi-pencil"
:severity="activeTab === 'editor' ? undefined : 'secondary'"
:outlined="activeTab !== 'editor'"
size="small"
class="rounded-full"
@click="activeTab = 'editor'"
/>
<Button
:label="'Preview'"
icon="pi pi-eye"
:severity="activeTab === 'preview' ? undefined : 'secondary'"
:outlined="activeTab !== 'preview'"
size="small"
class="rounded-full"
@click="activeTab = 'preview'"
/>
</div>
</div>
<!-- Editor -->
<div v-show="activeTab === 'editor'" class="p-4 flex flex-col lg:flex-row gap-4">
<!-- Campos HTML -->
<div class="flex-1 min-w-0 flex flex-col gap-3">
<div class="flex flex-col gap-1" @focusin="cursorField = 'cabecalho_html'">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Cabeçalho</label>
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
<div class="dte-field">
<label>Descrição</label>
<Textarea v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" rows="3" autoResize />
</div>
<div class="flex flex-col gap-1" @focusin="cursorField = 'corpo_html'">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Corpo do documento</label>
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="350" />
</div>
<div class="flex flex-col gap-1" @focusin="cursorField = 'rodape_html'">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Rodapé</label>
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
<div class="dte-field">
<label>URL do logo (opcional)</label>
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
</div>
</div>
</aside>
<!-- Painel de variáveis -->
<div class="w-full lg:w-[240px] shrink-0">
<div class="lg:sticky lg:top-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)]/50 overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 border-b border-[var(--surface-border,#e2e8f0)]">
<i class="pi pi-code text-[var(--text-color-secondary)] opacity-60 text-xs" />
<span class="font-semibold text-xs">Variáveis</span>
</div>
<div class="px-3 pt-2 text-[0.68rem] text-[var(--text-color-secondary)] opacity-75 italic">Clique para inserir no campo ativo</div>
<div class="flex flex-col gap-3 p-3 max-h-[500px] overflow-y-auto">
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo">
<div class="text-[0.62rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">{{ grupo }}</div>
<div class="flex flex-col gap-0.5">
<button
v-for="v in vars"
:key="v.key"
class="text-left text-xs px-2 py-1 rounded-md bg-transparent border-none hover:bg-[var(--primary-color,#6366f1)]/10 hover:text-[var(--primary-color,#6366f1)] transition-colors truncate cursor-pointer"
:title="v.key"
@click="insertVariable(v.key)"
>
<span class="font-mono text-[0.62rem] opacity-60">&lbrace;&lbrace;</span>
{{ v.label }}
<span class="font-mono text-[0.62rem] opacity-60">&rbrace;&rbrace;</span>
</button>
</div>
</div>
<!-- COL 2 (centro): Tabs Cabeçalho/Corpo/Rodapé + editor -->
<main class="dte-main">
<div class="dte-main__tabs">
<button
type="button"
class="dte-tab"
:class="{ 'is-active': editorTab === 'cabecalho' }"
@click="editorTab = 'cabecalho'"
>
<i class="pi pi-align-left" />
<span>Cabeçalho</span>
</button>
<button
type="button"
class="dte-tab"
:class="{ 'is-active': editorTab === 'corpo' }"
@click="editorTab = 'corpo'"
>
<i class="pi pi-align-justify" />
<span>Corpo</span>
</button>
<button
type="button"
class="dte-tab"
:class="{ 'is-active': editorTab === 'rodape' }"
@click="editorTab = 'rodape'"
>
<i class="pi pi-align-center" />
<span>Rodapé</span>
</button>
</div>
<div class="dte-main__editor">
<div v-show="editorTab === 'cabecalho'" class="dte-editor-wrap" @focusin="cursorField = 'cabecalho_html'">
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="450" layoutButtons :logoUrl="form.logo_url" />
</div>
<div v-show="editorTab === 'corpo'" class="dte-editor-wrap" @focusin="cursorField = 'corpo_html'">
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="450" />
</div>
<div v-show="editorTab === 'rodape'" class="dte-editor-wrap" @focusin="cursorField = 'rodape_html'">
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="450" layoutButtons :logoUrl="form.logo_url" />
</div>
</div>
</main>
<!-- COL 3 (direita): Variáveis disponíveis -->
<aside class="dte-vars">
<div class="dte-vars__head">
<i class="pi pi-code" />
<span>Variáveis</span>
</div>
<p class="dte-vars__hint">
Clique para inserir no
<strong>{{ editorTab === 'cabecalho' ? 'Cabeçalho' : editorTab === 'rodape' ? 'Rodapé' : 'Corpo' }}</strong>.
</p>
<div class="dte-vars__list">
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo" class="dte-vars__group">
<div class="dte-vars__group-title">{{ grupo }}</div>
<div class="dte-vars__group-items">
<button
v-for="v in vars"
:key="v.key"
class="dte-vars__btn"
:title="v.key"
@click="insertVariable(v.key)"
>
<span class="dte-vars__btn-brace">&lbrace;&lbrace;</span>
<span class="dte-vars__btn-label">{{ v.label }}</span>
<span class="dte-vars__btn-brace">&rbrace;&rbrace;</span>
</button>
</div>
</div>
</div>
</div>
<!-- Preview -->
<div v-show="activeTab === 'preview'" class="p-4">
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-white overflow-hidden">
<div class="p-6 text-black" style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 12pt; line-height: 1.6;">
<div v-if="form.cabecalho_html" class="text-center mb-4 pb-3 border-b border-gray-300" v-html="renderedCabecalho" />
<div class="min-h-[300px]" v-html="renderedPreview" />
<div v-if="form.rodape_html" class="mt-8 pt-3 border-t border-gray-300 text-center text-[10pt] text-gray-500" v-html="renderedRodape" />
</div>
</div>
</div>
</aside>
</div>
<!-- PREVIEW full width -->
<div v-show="activeTab === 'preview'" class="dte-preview">
<div class="dte-preview__doc">
<div v-if="form.cabecalho_html" class="dte-preview__header" v-html="renderedCabecalho" />
<div class="dte-preview__body" v-html="renderedPreview" />
<div v-if="form.rodape_html" class="dte-preview__footer" v-html="renderedRodape" />
</div>
</div>
</div>
</template>