Compare commits
36 Commits
4024469952
...
c17c547ed2
| Author | SHA1 | Date | |
|---|---|---|---|
| c17c547ed2 | |||
| 4f05c2cf1b | |||
| 512bcc979c | |||
| 61bb0d9c26 | |||
| 6c39c58dc8 | |||
| 4e1ebeba13 | |||
| 51c33e73b9 | |||
| 682840f355 | |||
| c6105df98a | |||
| 402def7539 | |||
| 5dc91614ad | |||
| 597f8c05d5 | |||
| 79425a3c9a | |||
| 87a1ac1358 | |||
| 6860628087 | |||
| 134f562a1f | |||
| bbbb08ba9d | |||
| 17f114f32f | |||
| c9afe8f009 | |||
| c7e311b851 | |||
| 0aabea7753 | |||
| 80cce772db | |||
| f1c24242e0 | |||
| b821db6438 | |||
| 0fafc28581 | |||
| 75e67eae5d | |||
| 9a6eb56827 | |||
| 652571da69 | |||
| 30367392ff | |||
| b40116fe5d | |||
| ffd8eab72d | |||
| dee89ccd84 | |||
| 6a8ee52ad8 | |||
| 7516468f78 | |||
| 20d2b3aee4 | |||
| ae1e1388b9 |
@@ -1508,3 +1508,57 @@ TOTAL DA SESSAO (24/05 - 25/05, ~24 commits):
|
||||
- Agenda decomposicao A+B1+B2: -991L em useMelissaAgenda (~33%)
|
||||
- Agenda Fases C+D: Rail+Clinica adotam billing core
|
||||
- useAgendaStatusChange composable novo
|
||||
|
||||
## [2026-05-21 23:00] session | Melissa Fase 2 UX iter + bug isFinite(null)
|
||||
Touched: feedback_isfinite_strict, feedback_teleport_body_styles
|
||||
Detalhes:
|
||||
|
||||
Sessao de testes manuais Fase 2 (templates + paciente.documentos).
|
||||
4 ajustes UX + 1 bug funcional resolvido. 5 commits, 0 push (SSL
|
||||
self-signed Gitea — user faz manual amanha).
|
||||
|
||||
1) MelissaPatientDocuments (4e1ebeb, 6c39c58):
|
||||
Aba Documentos no /melissa/paciente?id=X foi convertida de embed
|
||||
<DocumentsListPage> pra pagina nativa 2-col Melissa. Drawer mobile
|
||||
bugava (transform/filter em ancestrais trapando position:fixed).
|
||||
Fix:
|
||||
- <Teleport to="body"> no drawer + backdrop pra escapar stacking
|
||||
- styles do drawer movidos pra <style> nao-scoped (teleport perde
|
||||
data-v attrs do scoped)
|
||||
- wrapper teleportado recebe class "win11-root" pra herdar vars
|
||||
--m-* (definidas nesse escopo no MelissaLayout)
|
||||
- cascata --mpd-bg/border/text: --m-* -> --p-* -> hardcoded
|
||||
|
||||
2) DocumentGenerateDialog (61bb0d9, 512bcc9):
|
||||
Inputs trocados pra FloatLabel variant="on". Adicionado map de
|
||||
ORIGEM dos campos (TEMPLATE_VARIABLES.source) — hint embaixo de
|
||||
cada campo vazio explica onde cadastrar (ex: "Perfil -> Registro
|
||||
Profissional"). Banner verde/amber no topo conta preenchidos.
|
||||
|
||||
3) Bug critico (4f05c2c) — RAIZ do "campos vem vazio mesmo com
|
||||
profile preenchido":
|
||||
loadAllVariables crashava com TypeError "Cannot read properties
|
||||
of null (reading toFixed)" quando NAO havia sessao vinculada
|
||||
(agendaEventoId=null) E sem extras.valor. Toda a Promise
|
||||
estourava, variables zerava.
|
||||
|
||||
Causa: isFinite(null) global retorna TRUE (Number(null)===0),
|
||||
entrava no branch valorNum.toFixed e crashava.
|
||||
|
||||
Fix: trocar por Number.isFinite (strict, nao coerce).
|
||||
Salvo como memoria feedback_isfinite_strict.
|
||||
|
||||
PROXIMA SESSAO (retomar amanha 22/05):
|
||||
- Continuar Fase 2: 2.7-2.9 (gerar PDF dentro da aba Documentos
|
||||
do paciente, conferir vars CRP/UF preenchem, doc aparece como
|
||||
tipo_documento='outro')
|
||||
- Gerar JSON docs Fase 2 (#6 + templates page)
|
||||
- Fase 3: Portal assinatura #7
|
||||
- Fase 4: Recibo profissional #14 testes
|
||||
- Fase 5: Relatorios export #13
|
||||
- Fase 6: C12 UX iter (deferred 20/05)
|
||||
- Fase 7: Regressao Agenda C7-C13
|
||||
|
||||
PUSH PENDENTE: 35 commits ahead of origin/main; SSL self-signed
|
||||
do Gitea exige `git -c http.sslVerify=false push origin main`
|
||||
+ credenciais (user faz manual).
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP #5 — campo livre quando tipo de registro = 'outro'
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Migration 20260521000003 adicionou professional_registration_type com CHECK
|
||||
-- limitado a 8 valores (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS/outro). Quando o
|
||||
-- profissional escolhe 'outro', precisa informar qual conselho/instituição
|
||||
-- (ex: associações privadas, conselhos não-listados).
|
||||
--
|
||||
-- Esta migration adiciona professional_registration_type_other (text livre),
|
||||
-- que só é preenchido quando type = 'outro'.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN IF NOT EXISTS professional_registration_type_other text;
|
||||
|
||||
COMMENT ON COLUMN public.profiles.professional_registration_type_other IS
|
||||
'Nome livre do conselho/instituição quando professional_registration_type = ''outro''. Aparece em recibos/laudos no lugar do tipo padrão.';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,168 @@
|
||||
-- Importação da doc Fase 1 (Busca global + Recently viewed)
|
||||
-- Gerado a partir de development/saas-docs/01-busca-global-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
-- 1) Cria a doc principal
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Busca global e Acessados recentemente',
|
||||
$HTML$<h2>Busca global no Layout Melissa</h2>
|
||||
|
||||
<p>A <strong>busca global</strong> é o atalho mais rápido para encontrar pacientes, sessões, documentos e cadastros recebidos sem precisar navegar pelos menus. Você acessa pelo <em>dock central</em> do Layout Melissa ou usando o atalho de teclado <kbd>Ctrl</kbd> + <kbd>K</kbd> (em qualquer página do Melissa).</p>
|
||||
|
||||
<h3>1. Como abrir</h3>
|
||||
<p>Localize o campo de busca no dock central do Melissa. Ele aparece como um botão com o ícone de lupa e o placeholder <em>"Buscar paciente, agenda, atalho…"</em>, com o atalho <kbd>Ctrl K</kbd> indicado no canto direito.</p>
|
||||
|
||||
<div style="border: 1px solid #cbd5e1; border-radius: 12px; padding: 0 14px; height: 44px; max-width: 480px; display: flex; align-items: center; gap: 10px; background: #1e2333; color: #cbd5e1; font-family: 'Segoe UI', sans-serif; margin: 12px 0;">
|
||||
<i class="pi pi-search" style="font-size: 0.95rem;"></i>
|
||||
<span style="flex: 1; font-size: 0.9rem;">Buscar paciente, agenda, atalho…</span>
|
||||
<span style="font-size: 0.62rem; padding: 2px 7px; border-radius: 4px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15); letter-spacing: 0.05em;">Ctrl K</span>
|
||||
</div>
|
||||
|
||||
<p><strong>Três jeitos de abrir:</strong></p>
|
||||
<ul>
|
||||
<li>Clicando no campo no dock central</li>
|
||||
<li>Pressionando <kbd>Ctrl + K</kbd> (Windows/Linux) ou <kbd>⌘ + K</kbd> (Mac) em qualquer página</li>
|
||||
<li>Pelo menu lateral, opção "Buscar" (quando disponível)</li>
|
||||
</ul>
|
||||
|
||||
<h3>2. O Dialog Spotlight</h3>
|
||||
<p>Ao abrir, o sistema mostra um <strong>diálogo centralizado</strong> com o input grande no topo e os resultados em colunas abaixo. Isso é o padrão Spotlight (igual ao usado em macOS, Linear, GitHub, Slack).</p>
|
||||
|
||||
<div style="background: var(--surface-card, #fff); border: 1px solid #e2e8f0; border-radius: 14px; max-width: 520px; box-shadow: 0 12px 32px rgba(0,0,0,0.15); overflow: hidden; margin: 12px 0; font-family: 'Segoe UI', sans-serif;">
|
||||
<div style="padding: 14px 18px; border-bottom: 1px solid #e2e8f0; display: flex; align-items: center; gap: 12px;">
|
||||
<i class="pi pi-search" style="color: #64748b;"></i>
|
||||
<span style="flex: 1; color: #94a3b8; font-size: 1.05rem;">Buscar paciente, agenda, atalho…</span>
|
||||
<span style="font-size: 0.65rem; padding: 2px 8px; border-radius: 4px; background: #f1f5f9; border: 1px solid #e2e8f0; color: #64748b;">Esc</span>
|
||||
</div>
|
||||
<div style="padding: 6px;">
|
||||
<div style="text-transform: uppercase; letter-spacing: 0.18em; color: #64748b; font-size: 0.62rem; font-weight: 700; padding: 8px 10px 4px; opacity: 0.75;">Acessados recentemente</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: 8px;">
|
||||
<span style="width: 32px; height: 32px; display: grid; place-items: center; border-radius: 7px; background: rgba(244,114,182,0.18); color: #ec4899; font-size: 0.9rem;">
|
||||
<i class="pi pi-user"></i>
|
||||
</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-size: 0.88rem; font-weight: 500;">André Green</div>
|
||||
<div style="font-size: 0.74rem; color: #64748b;">andre@email.com</div>
|
||||
</div>
|
||||
<i class="pi pi-arrow-right" style="color: #94a3b8; font-size: 0.75rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>3. Onde a busca procura</h3>
|
||||
<p>Digitando <strong>pelo menos 2 caracteres</strong>, o sistema dispara uma busca completa em 5 categorias:</p>
|
||||
<ul>
|
||||
<li><strong style="color: #ec4899;">Pacientes</strong> — por nome completo, e-mail, telefone ou CPF</li>
|
||||
<li><strong style="color: #6366f1;">Sessões</strong> — por título ou nome do paciente, em qualquer data</li>
|
||||
<li><strong style="color: #0ea5e9;">Documentos</strong> — por nome do arquivo ou descrição</li>
|
||||
<li><strong style="color: #f97316;">Cadastros recebidos</strong> — solicitações de novos pacientes pendentes</li>
|
||||
<li><strong>Atalhos</strong> — ações rápidas como "Agenda", "Financeiro", etc.</li>
|
||||
</ul>
|
||||
|
||||
<p>Cada categoria aparece com um <strong>ícone colorido distinto</strong> para facilitar a leitura visual. Os resultados são limitados aos 6 mais relevantes por categoria.</p>
|
||||
|
||||
<h3>4. Como navegar nos resultados</h3>
|
||||
<p>Você pode usar o mouse ou o teclado:</p>
|
||||
<ul>
|
||||
<li><kbd>↑</kbd> / <kbd>↓</kbd> — navegar entre os resultados</li>
|
||||
<li><kbd>Enter</kbd> — abrir o item selecionado</li>
|
||||
<li><kbd>Esc</kbd> — fechar o diálogo</li>
|
||||
<li><kbd>Clique no backdrop</kbd> — fecha também</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. Acessados recentemente</h3>
|
||||
<p>Quando você abre a busca <strong>sem digitar nada</strong>, a primeira seção mostra <strong>"Acessados recentemente"</strong> — os últimos 5 pacientes que você visitou (em qualquer dispositivo deste navegador).</p>
|
||||
|
||||
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px 16px; margin: 12px 0; font-size: 0.88rem; color: #475569;">
|
||||
<strong>💡 Dica:</strong> Use Ctrl+K + Enter para reabrir o último paciente acessado em 2 segundos.
|
||||
</div>
|
||||
|
||||
<p>Esses 5 pacientes ficam salvos no seu navegador (não no banco de dados), então:</p>
|
||||
<ul>
|
||||
<li>São <strong>privados</strong> — outros usuários não veem</li>
|
||||
<li>São <strong>por navegador</strong> — se trocar do Chrome pro Firefox, a lista recomeça</li>
|
||||
<li><strong>Persistem</strong> após fechar o navegador (localStorage)</li>
|
||||
<li>Auto-rotacionam: ao acessar o 6º paciente, o mais antigo sai</li>
|
||||
</ul>
|
||||
|
||||
<h3>6. Clique nos resultados</h3>
|
||||
<p>Ao clicar:</p>
|
||||
<ul>
|
||||
<li><strong>Paciente</strong> → abre o prontuário (<code>/melissa/paciente?id=…</code>)</li>
|
||||
<li><strong>Sessão</strong> → abre o evento na agenda</li>
|
||||
<li><strong>Documento</strong> → abre o prontuário do paciente na aba Documentos</li>
|
||||
<li><strong>Cadastro recebido</strong> → vai pra lista de Cadastros recebidos</li>
|
||||
<li><strong>Atalho</strong> → navega pra seção (Agenda, Financeiro, etc.)</li>
|
||||
</ul>
|
||||
|
||||
<h3>7. Tema claro × escuro</h3>
|
||||
<p>O Dialog adapta automaticamente as cores conforme o tema escolhido em <strong>Meu Perfil → Preferências</strong>. Texto, fundos e bordas seguem as configurações do sistema. Apenas os ícones por categoria (paciente rosa, sessão índigo, documento azul, cadastro laranja) mantêm a mesma cor para preservar a identificação visual rápida.</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<p>Atualmente o componente <code>MelissaBusca.vue</code> <strong>não tem atributos <code>id</code></strong> em seus elementos. Para o sistema de highlight da ajuda funcionar (links <code>data-highlight</code>), sugere-se adicionar:</p>
|
||||
<ul>
|
||||
<li><code>id="melissa-busca-trigger"</code> no botão de trigger no dock</li>
|
||||
<li><code>id="melissa-busca-dialog"</code> no Dialog</li>
|
||||
<li><code>id="melissa-busca-input"</code> no input dentro do Dialog</li>
|
||||
<li><code>id="melissa-busca-recent"</code> no grupo de Acessados recentemente</li>
|
||||
</ul>$HTML$,
|
||||
'Navegação',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa',
|
||||
1,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
-- 2) Insere os 12 FAQ items vinculados
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como abrir a busca rapidamente?',
|
||||
$FAQ$Use o atalho <kbd>Ctrl + K</kbd> (Windows/Linux) ou <kbd>⌘ + K</kbd> (Mac) em qualquer página do Melissa. Você também pode clicar diretamente no campo de busca no dock central.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Posso buscar paciente por telefone ou CPF?',
|
||||
$FAQ$Sim. A busca de pacientes encontra pelo <strong>nome completo, e-mail, telefone ou CPF</strong>. Digite pelo menos 2 caracteres e aguarde os resultados.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'O que aparece em "Acessados recentemente"?',
|
||||
$FAQ$Os últimos 5 pacientes que você abriu pelo prontuário, em ordem do mais recente pro mais antigo. A lista aparece quando você abre a busca <strong>sem digitar nada</strong>.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'Outros usuários veem meus "Acessados recentemente"?',
|
||||
$FAQ$Não. A lista é <strong>privada e local</strong> — fica salva apenas no seu navegador atual (localStorage). Se você logar em outro navegador ou computador, a lista começa vazia naquele dispositivo.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'Quantos caracteres preciso digitar pra começar a buscar?',
|
||||
$FAQ$<strong>Pelo menos 2</strong>. Buscas de 1 caractere são muito amplas e não disparam pesquisa. A partir de 2 caracteres, o sistema aguarda 200ms (tempo de digitação) antes de consultar o banco — assim você não dispara dezenas de buscas digitando rápido.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'Por que minha busca não retorna nada?',
|
||||
$FAQ$Verifique: (1) digitou pelo menos 2 caracteres; (2) o termo está sem erros graves de digitação (a busca tolera pequenas variações via similarity); (3) o paciente/sessão realmente existe no seu cadastro. Se persistir, faça uma busca mais ampla — ex: apenas o primeiro nome.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'O que cada cor de ícone significa?',
|
||||
$FAQ$Cada categoria tem uma cor própria: <strong style="color: #ec4899;">Rosa</strong> = Paciente, <strong style="color: #6366f1;">Índigo</strong> = Sessão da agenda, <strong style="color: #0ea5e9;">Azul</strong> = Documento, <strong style="color: #f97316;">Laranja</strong> = Cadastro recebido pendente. Atalhos vêm em cinza neutro.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Como navegar pelos resultados sem usar o mouse?',
|
||||
$FAQ$Use as setas do teclado <kbd>↑</kbd> e <kbd>↓</kbd> para navegar entre os itens e <kbd>Enter</kbd> para abrir o selecionado. Pra fechar sem selecionar, use <kbd>Esc</kbd>.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'Posso buscar documentos pelo nome do paciente?',
|
||||
$FAQ$Sim. A busca de documentos cruza pelo nome do arquivo, descrição e <strong>nome do paciente vinculado</strong>. Ao clicar num resultado de documento, você é levado direto pra aba Documentos do prontuário daquele paciente.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Como limpar a lista de "Acessados recentemente"?',
|
||||
$FAQ$Hoje não há um botão na interface — a lista é gerenciada automaticamente (limite de 5, mais antigo cai quando você acessa um novo). Pra limpar manualmente, você pode apagar os dados do site no seu navegador (Configurações → Privacidade → Limpar dados de navegação → escopo "localStorage").$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'A busca encontra sessões antigas ou só as de hoje?',
|
||||
$FAQ$Encontra sessões de <strong>qualquer data</strong> — passadas e futuras. O grupo "Agenda de hoje" mostra apenas as do dia atual (preview rápido); o grupo "Sessões" inclui todas as outras encontradas no banco. Cada item mostra a data e horário da sessão.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Os atalhos (Agenda, Financeiro, etc.) sempre aparecem?',
|
||||
$FAQ$Sim. Quando o campo está vazio, mostramos 4 atalhos padrão. Conforme você digita, os atalhos que combinam com sua busca permanecem visíveis (junto com os resultados do banco).$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
File diff suppressed because one or more lines are too long
@@ -17,31 +17,61 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const isOnline = ref(true); // começa como true; detecta em onMounted
|
||||
// ── Estado ────────────────────────────────────────────────────
|
||||
// Começa otimista (true) — só marca offline com confirmação dupla.
|
||||
const isOnline = ref(true);
|
||||
const wasOffline = ref(false);
|
||||
const showReconnected = ref(false);
|
||||
|
||||
let pollTimer = null;
|
||||
let reconnectedTimer = null;
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
// ── Detecção real: tenta buscar um recurso minúsculo ──────────
|
||||
// Em DEV, ignora completamente o polling: Vite HMR + dev server podem
|
||||
// disparar falhas pontuais que geram falso positivo constante. Em DEV,
|
||||
// só confia em navigator.onLine + eventos nativos (mais conservador).
|
||||
const IS_DEV = import.meta.env?.DEV === true;
|
||||
|
||||
// Tolerância: precisa N falhas seguidas pra considerar offline. Evita
|
||||
// falso positivo de slow request / HMR rebuild / network blip.
|
||||
const FAILURE_THRESHOLD = 2;
|
||||
const POLL_INTERVAL = IS_DEV ? 60_000 : 30_000;
|
||||
const FETCH_TIMEOUT = 8_000;
|
||||
|
||||
// ── Detecção: navigator.onLine primeiro, fetch como confirmação ──
|
||||
//
|
||||
// navigator.onLine é a fonte autoritativa do browser. Se for true,
|
||||
// quase certo que tem rede física. Se for false, com certeza offline.
|
||||
// O fetch só serve pra detectar "rede funciona mas servidor parado".
|
||||
async function checkConnectivity() {
|
||||
// 1) Browser offline = confia direto, sem fetch
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
consecutiveFailures = FAILURE_THRESHOLD;
|
||||
setOffline();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Browser online — confirma com HEAD no favicon (rápido, cacheável)
|
||||
try {
|
||||
// favicon do próprio app (cache busted) — não depende de rede externa
|
||||
await fetch('/favicon.ico?_t=' + Date.now(), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(4000)
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT)
|
||||
});
|
||||
consecutiveFailures = 0;
|
||||
setOnline();
|
||||
} catch {
|
||||
setOffline();
|
||||
consecutiveFailures++;
|
||||
// Só marca offline após N falhas consecutivas — evita falso positivo
|
||||
// de slow request, HMR rebuild, transient blip.
|
||||
if (consecutiveFailures >= FAILURE_THRESHOLD) {
|
||||
setOffline();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setOnline() {
|
||||
if (!isOnline.value && wasOffline.value) {
|
||||
// acabou de reconectar
|
||||
showReconnected.value = true;
|
||||
if (reconnectedTimer) clearTimeout(reconnectedTimer);
|
||||
reconnectedTimer = setTimeout(() => {
|
||||
@@ -59,19 +89,25 @@ function setOffline() {
|
||||
}
|
||||
|
||||
// ── Eventos nativos do browser ────────────────────────────────
|
||||
// navigator.onLine + offline/online events são SUPER confiáveis pra
|
||||
// estado real (sem rede física, wifi caiu, etc). Outros falsos
|
||||
// positivos vinham só do fetch agressivo.
|
||||
function onBrowserOffline() {
|
||||
consecutiveFailures = FAILURE_THRESHOLD;
|
||||
setOffline();
|
||||
}
|
||||
function onBrowserOnline() {
|
||||
consecutiveFailures = 0;
|
||||
checkConnectivity();
|
||||
} // confirma antes de marcar online
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('offline', onBrowserOffline);
|
||||
window.addEventListener('online', onBrowserOnline);
|
||||
|
||||
// Polling a cada 10 s — captura quedas que não disparam evento
|
||||
pollTimer = setInterval(checkConnectivity, 10_000);
|
||||
// Polling defensivo — captura quedas que não disparam evento
|
||||
// (raras, ex: DNS travado em wifi público).
|
||||
pollTimer = setInterval(checkConnectivity, POLL_INTERVAL);
|
||||
|
||||
// Verifica estado atual ao montar (útil se já começou offline)
|
||||
checkConnectivity();
|
||||
|
||||
@@ -197,8 +197,17 @@ function generateUser() {
|
||||
});
|
||||
}
|
||||
|
||||
function patientsListRoute() {
|
||||
// Rota de destino do "Salvar e ver paciente". Em melissa, prefere a
|
||||
// view individual do paciente recém-criado (id vem de data.id no
|
||||
// emit('created')); fallback pra lista.
|
||||
function patientViewRoute(patientId) {
|
||||
const p = String(route.path || '');
|
||||
if (p.startsWith('/melissa') && patientId) {
|
||||
return { path: '/melissa/paciente', query: { id: String(patientId) } };
|
||||
}
|
||||
if (p.startsWith('/melissa')) {
|
||||
return '/melissa/pacientes';
|
||||
}
|
||||
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
|
||||
}
|
||||
|
||||
@@ -252,7 +261,10 @@ async function submit(mode = 'only') {
|
||||
|
||||
emit('created', data);
|
||||
if (props.closeOnCreated) close();
|
||||
if (mode === 'view') await router.push(patientsListRoute());
|
||||
if (mode === 'view') {
|
||||
const pid = data?.id || null;
|
||||
await router.push(patientViewRoute(pid));
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.';
|
||||
errorMsg.value = msg;
|
||||
@@ -334,10 +346,10 @@ async function submit(mode = 'only') {
|
||||
<!-- Na rota de pacientes OU em fluxo (hideViewListButton): só "Salvar" / "Salvar e fechar" -->
|
||||
<Button v-if="isOnPatientsPage" label="Salvar" :loading="saving" :disabled="saving" @click="submit('only')" />
|
||||
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="saving" :disabled="saving" @click="submit('only')" />
|
||||
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
|
||||
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver paciente" -->
|
||||
<template v-else>
|
||||
<Button label="Salvar e fechar" severity="secondary" outlined :loading="saving" :disabled="saving" @click="submit('only')" />
|
||||
<Button label="Salvar e ver pacientes" :loading="saving" :disabled="saving" @click="submit('view')" />
|
||||
<Button label="Salvar e ver paciente" :loading="saving" :disabled="saving" @click="submit('view')" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -34,7 +34,7 @@ export const PAGES = [
|
||||
{ id: 'p_t_medicos', label: 'Médicos referenciadores', icon: 'pi pi-user-edit', sublabel: 'Médicos que encaminham pacientes', path: '/therapist/patients/medicos', roles: ['therapist'], keywords: kw('medicos','encaminhadores','referenciadores','indicacao') },
|
||||
{ id: 'p_t_link_externo', label: 'Link de cadastro externo', icon: 'pi pi-link', sublabel: 'Link público pra pacientes', path: '/therapist/patients/link-externo', roles: ['therapist'], keywords: kw('link','externo','publico','cadastro paciente','convite') },
|
||||
{ id: 'p_t_cad_recebidos', label: 'Cadastros recebidos', icon: 'pi pi-inbox', sublabel: 'Pacientes aguardando aceite', path: '/therapist/patients/cadastro/recebidos', roles: ['therapist'], keywords: kw('recebidos','pendentes','aceitar','intake','novos') },
|
||||
{ id: 'p_t_doc_templates', label: 'Templates de documentos', icon: 'pi pi-file-edit', sublabel: 'Modelos reutilizáveis', path: '/therapist/documents/templates', roles: ['therapist'], keywords: kw('templates','modelos','contratos','documentos') },
|
||||
{ id: 'p_cfg_doc_templates', label: 'Modelos de documentos', icon: 'pi pi-file-edit', sublabel: 'Configurações → Documentos', path: '/configuracoes/documentos/templates', roles: ['therapist','admin'], keywords: kw('templates','modelos','contratos','documentos','recibo','atestado','laudo','tcle','lgpd','consent') },
|
||||
{ id: 'p_t_online_sched', label: 'Agendamento online', icon: 'pi pi-globe', sublabel: 'Página pública de agendamento', path: '/therapist/online-scheduling', roles: ['therapist'], keywords: kw('online','publico','agendar','landing','pagina','site') },
|
||||
{ id: 'p_t_ag_recebidos', label: 'Agendamentos recebidos', icon: 'pi pi-calendar-plus',sublabel: 'Solicitações da agenda pública', path: '/therapist/agendamentos-recebidos', roles: ['therapist'], keywords: kw('solicitacoes','recebidos','publico','pedidos') },
|
||||
{ id: 'p_t_fin_lanc', label: 'Lançamentos financeiros', icon: 'pi pi-list', sublabel: 'Entradas e saídas', path: '/therapist/financeiro/lancamentos', roles: ['therapist'], keywords: kw('lancamentos','entradas','saidas','fluxo de caixa','receitas','despesas') },
|
||||
|
||||
@@ -117,14 +117,14 @@ function buildConfig() {
|
||||
];
|
||||
|
||||
// Toolbar completa para o corpo do e-mail
|
||||
// Botões hr (linha horizontal), eraser (apagar formatação) e source (HTML)
|
||||
// foram removidos — não funcionavam de forma esperada.
|
||||
const bodyButtons = [
|
||||
'bold', 'italic', 'underline', 'strikethrough', '|',
|
||||
'ul', 'ol', '|',
|
||||
'font', 'fontsize', 'brush', 'paragraph', '|',
|
||||
'align', '|',
|
||||
'link', 'table', '|',
|
||||
'hr', 'eraser', '|',
|
||||
'source'
|
||||
'link', 'table'
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -194,7 +194,24 @@ watch(
|
||||
|
||||
// ── API exposta ───────────────────────────────────────────────
|
||||
defineExpose({
|
||||
insertHTML: (html) => jodit?.selection.insertHTML(html)
|
||||
insertHTML: (html) => jodit?.selection.insertHTML(html),
|
||||
// Salva markers da seleção atual antes do foco sair do editor
|
||||
// (ex: usuário abre drawer e perde o cursor). Retorna o array de
|
||||
// markers que pode ser passado pra restoreSelection depois.
|
||||
saveSelection: () => {
|
||||
if (!jodit) return null;
|
||||
try { return jodit.selection.save(); }
|
||||
catch { return null; }
|
||||
},
|
||||
// Restaura selection a partir dos markers salvos. Re-foca o editor.
|
||||
restoreSelection: (markers) => {
|
||||
if (!jodit) return;
|
||||
try {
|
||||
jodit.focus();
|
||||
if (markers) jodit.selection.restore(markers);
|
||||
} catch { /* silencioso */ }
|
||||
},
|
||||
focus: () => jodit?.focus()
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -65,11 +65,22 @@ const router = useRouter();
|
||||
|
||||
const isOnPatientsPage = computed(() => {
|
||||
const p = String(route.path || '');
|
||||
return p.includes('/patients') || p.includes('/pacientes');
|
||||
// /melissa/paciente (singular — prontuário) é página de paciente.
|
||||
// /melissa/pacientes (plural — lista) também.
|
||||
return p.includes('/patients') || p.includes('/pacientes') || p.startsWith('/melissa/paciente');
|
||||
});
|
||||
|
||||
function patientsListRoute() {
|
||||
// Rota de destino quando o usuário pede "Salvar e ver paciente":
|
||||
// — no Melissa, abre o prontuário do paciente (singular, via query id)
|
||||
// — no Therapist/Admin, volta pra lista (não há rota dedicada de view).
|
||||
function patientViewRoute(patientId) {
|
||||
const p = String(route.path || '');
|
||||
if (p.startsWith('/melissa') && patientId) {
|
||||
return { path: '/melissa/paciente', query: { id: String(patientId) } };
|
||||
}
|
||||
if (p.startsWith('/melissa')) {
|
||||
return '/melissa/pacientes';
|
||||
}
|
||||
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
|
||||
}
|
||||
|
||||
@@ -82,7 +93,9 @@ async function onCreated(data) {
|
||||
isOpen.value = false;
|
||||
emit('created', data);
|
||||
if (pendingMode.value === 'view') {
|
||||
await router.push(patientsListRoute());
|
||||
// data.id vem do PatientsCadastroPage (criação ou edição)
|
||||
const pid = data?.id || props.patientId || null;
|
||||
await router.push(patientViewRoute(pid));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -197,10 +210,10 @@ async function onCreated(data) {
|
||||
<!-- Na rota de pacientes OU em fluxo (hideViewListButton): só um botao -->
|
||||
<Button v-if="isOnPatientsPage" label="Salvar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
|
||||
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver paciente" -->
|
||||
<template v-else>
|
||||
<Button label="Salvar e fechar" severity="secondary" outlined :loading="pendingMode === 'only' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||
<Button label="Salvar e ver pacientes" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
|
||||
<Button label="Salvar e ver paciente" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -79,11 +79,19 @@ const editableVars = computed(() => {
|
||||
key,
|
||||
label: meta?.label || key,
|
||||
grupo: meta?.grupo || 'Outros',
|
||||
source: meta?.source || '',
|
||||
value: variables.value[key] || ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Estatística pro topo: quantos campos vieram do auto-fill vs vazios
|
||||
const varStats = computed(() => {
|
||||
const total = editableVars.value.length
|
||||
const filled = editableVars.value.filter(v => String(variables.value[v.key] || '').trim() !== '').length
|
||||
return { total, filled, empty: total - filled }
|
||||
})
|
||||
|
||||
const varGroups = computed(() => {
|
||||
const groups = {}
|
||||
for (const v of editableVars.value) {
|
||||
@@ -192,17 +200,54 @@ function close() {
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Editar variaveis -->
|
||||
<div v-else-if="step === 'edit'" class="flex flex-col gap-4">
|
||||
<div v-else-if="step === 'edit'" class="flex flex-col gap-5">
|
||||
<!-- Resumo do preenchimento automático -->
|
||||
<div
|
||||
v-if="varStats.total"
|
||||
class="flex items-center gap-2 text-xs px-3 py-2 rounded-lg"
|
||||
:class="varStats.empty === 0
|
||||
? 'bg-green-500/10 text-green-700 dark:text-green-400'
|
||||
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400'"
|
||||
>
|
||||
<i :class="varStats.empty === 0 ? 'pi pi-check-circle' : 'pi pi-info-circle'" />
|
||||
<span v-if="varStats.empty === 0">
|
||||
Todos os {{ varStats.total }} campos foram preenchidos automaticamente.
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ varStats.filled }} de {{ varStats.total }} preenchidos. Os campos vazios mostram onde cadastrar o dado.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Erro de carregamento de variáveis -->
|
||||
<div
|
||||
v-if="genError"
|
||||
class="flex items-center gap-2 text-xs px-3 py-2 rounded-lg bg-red-500/10 text-red-600"
|
||||
>
|
||||
<i class="pi pi-exclamation-circle" />
|
||||
<span>{{ genError }}</span>
|
||||
</div>
|
||||
|
||||
<div v-for="(vars, grupo) in varGroups" :key="grupo">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">{{ grupo }}</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">{{ grupo }}</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div v-for="v in vars" :key="v.key" class="flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">{{ v.label }}</label>
|
||||
<InputText
|
||||
:modelValue="variables[v.key] || ''"
|
||||
@update:modelValue="onVarChange(v.key, $event)"
|
||||
class="w-full"
|
||||
/>
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
:id="`docgen-var-${v.key}`"
|
||||
:modelValue="variables[v.key] || ''"
|
||||
@update:modelValue="onVarChange(v.key, $event)"
|
||||
class="w-full"
|
||||
:invalid="!String(variables[v.key] || '').trim()"
|
||||
/>
|
||||
<label :for="`docgen-var-${v.key}`">{{ v.label }}</label>
|
||||
</FloatLabel>
|
||||
<small
|
||||
v-if="!String(variables[v.key] || '').trim() && v.source"
|
||||
class="text-[0.65rem] text-[var(--text-color-secondary)] flex items-center gap-1 ml-1"
|
||||
>
|
||||
<i class="pi pi-link text-[0.55rem]" />
|
||||
{{ v.source }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useDocumentTemplates } from '../composables/useDocumentTemplates'
|
||||
import JoditEmailEditor from '@/components/ui/JoditEmailEditor.vue'
|
||||
|
||||
@@ -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 ──────────────────────
|
||||
|
||||
@@ -70,13 +72,43 @@ function insertVariable(varKey) {
|
||||
rodape_html: editorRodape
|
||||
}
|
||||
const editorRef = editorMap[cursorField.value]
|
||||
|
||||
// No mobile: fecha drawer + defere insertHTML pós-transição.
|
||||
// Restaura a selection capturada quando o drawer abriu (cursor
|
||||
// original do usuário) antes de inserir → variável aparece no
|
||||
// ponto certo do texto, não no final.
|
||||
if (isMobile.value) {
|
||||
drawerOpen.value = false;
|
||||
const markers = savedSelection.value;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (markers && editorRef?.value?.restoreSelection) {
|
||||
editorRef.value.restoreSelection(markers);
|
||||
}
|
||||
if (editorRef?.value?.insertHTML) {
|
||||
editorRef.value.insertHTML(tag);
|
||||
} else {
|
||||
// Fallback se a API expose falhar
|
||||
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag;
|
||||
}
|
||||
} catch {
|
||||
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag;
|
||||
}
|
||||
if (!form.value.variaveis.includes(varKey)) {
|
||||
form.value.variaveis = [...form.value.variaveis, varKey];
|
||||
}
|
||||
savedSelection.value = null;
|
||||
}, 280);
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop: insertHTML mantém posição do cursor (foco já tá no editor)
|
||||
if (editorRef?.value?.insertHTML) {
|
||||
editorRef.value.insertHTML(tag)
|
||||
} else {
|
||||
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag
|
||||
}
|
||||
|
||||
// Adiciona a variavel na lista se nao estiver
|
||||
if (!form.value.variaveis.includes(varKey)) {
|
||||
form.value.variaveis = [...form.value.variaveis, varKey]
|
||||
}
|
||||
@@ -87,129 +119,756 @@ function insertVariable(varKey) {
|
||||
function onSave() {
|
||||
emit('save', { ...form.value })
|
||||
}
|
||||
|
||||
// ── Mobile drawer (espelha padrão MelissaBloqueios/Templates) ─
|
||||
// No mobile, form (col 1) + variáveis (col 3) viram tabs dentro
|
||||
// de um drawer único. Só o editor (col 2) fica visível na tela.
|
||||
const drawerOpen = ref(false);
|
||||
const drawerTab = ref('form'); // form | vars
|
||||
const isMobile = ref(false);
|
||||
let _mqMobile = null;
|
||||
function _onMqMobileChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
if (!e.matches) drawerOpen.value = false;
|
||||
}
|
||||
// Selection salva do editor ativo no momento de abrir o drawer de
|
||||
// variáveis. Permite inserir na posição original do cursor mesmo
|
||||
// depois do user navegar pelo drawer/perder foco.
|
||||
const savedSelection = ref(null);
|
||||
|
||||
function openDrawer(tab) {
|
||||
drawerTab.value = tab || 'form';
|
||||
// Quando abre "Variáveis", salva selection do editor ativo agora
|
||||
// (cursor original do usuário) pra restaurar depois da inserção.
|
||||
if (tab === 'vars') {
|
||||
const editorMap = {
|
||||
cabecalho_html: editorCabecalho,
|
||||
corpo_html: editorCorpo,
|
||||
rodape_html: editorRodape
|
||||
};
|
||||
const editorRef = editorMap[cursorField.value];
|
||||
savedSelection.value = editorRef?.value?.saveSelection?.() || null;
|
||||
} else {
|
||||
savedSelection.value = null;
|
||||
}
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
function fecharDrawer() {
|
||||
drawerOpen.value = false;
|
||||
savedSelection.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
|
||||
catch { _mqMobile.addListener(_onMqMobileChange); }
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) {
|
||||
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
|
||||
catch { _mqMobile.removeListener(_onMqMobileChange); }
|
||||
}
|
||||
});
|
||||
|
||||
</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: hidden;
|
||||
padding: 14px;
|
||||
background: var(--surface-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dte-editor-wrap {
|
||||
flex: 1;
|
||||
min-height: 450px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dte-editor-wrap > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Força o Jodit interno a expandir 100% da altura disponível
|
||||
(substitui o height: minHeight em pixels que o JoditEmailEditor seta) */
|
||||
.dte-editor-wrap :deep(.jodit-container) {
|
||||
flex: 1 !important;
|
||||
height: 100% !important;
|
||||
min-height: 450px !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.dte-editor-wrap :deep(.jodit-workplace) {
|
||||
flex: 1 !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
.dte-editor-wrap :deep(.jodit-wysiwyg) {
|
||||
min-height: 100% !important;
|
||||
}
|
||||
|
||||
/* 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 ═══════ */
|
||||
/* Container externo: scroll vertical interno + fundo sutil.
|
||||
NÃO usa flex (que limitava a altura intrínseca do doc) — usa
|
||||
block normal com o doc centralizado via margin auto. */
|
||||
.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;
|
||||
}
|
||||
.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 */
|
||||
margin: 0 auto;
|
||||
padding: 48px 56px;
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
font-size: 12pt;
|
||||
line-height: 1.6;
|
||||
min-height: 500px;
|
||||
/* Garante que o background-white cresce com o conteúdo
|
||||
(em vez de ficar travado no min-height quando o doc é grande) */
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ═══════ Toolbar mobile actions (botões "Identificação" / "Variáveis") ═══════ */
|
||||
.dte-toolbar__mobile-actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.dte-mobile-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
font-family: inherit;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
.dte-mobile-btn:hover { background: color-mix(in srgb, var(--p-primary-color) 8%, transparent); }
|
||||
.dte-mobile-btn > i { color: var(--p-primary-color); font-size: 0.82rem; }
|
||||
|
||||
/* ═══════ Mobile drawer (form + variáveis em tabs) ═══════ */
|
||||
.dte-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(360px, 92vw);
|
||||
z-index: 80;
|
||||
background: var(--surface-card);
|
||||
border-right: 1px solid var(--surface-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--text-color);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.dte-mobile-drawer.is-open { transform: translateX(0); }
|
||||
/* Durante a transição de saída, drawer ignora eventos pra não capturar
|
||||
touch/click "perdidos" e prevenir trava no Jodit. */
|
||||
.dte-mobile-drawer:not(.is-open) { pointer-events: none; }
|
||||
|
||||
.dte-mobile-drawer__tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dte-drawer-tab {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
.dte-drawer-tab:hover {
|
||||
color: var(--text-color);
|
||||
background: color-mix(in srgb, var(--p-primary-color) 4%, transparent);
|
||||
}
|
||||
.dte-drawer-tab.is-active {
|
||||
color: var(--p-primary-color);
|
||||
border-bottom-color: var(--p-primary-color);
|
||||
}
|
||||
.dte-drawer-tab > i { font-size: 0.82rem; }
|
||||
|
||||
.dte-drawer-close {
|
||||
width: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
border-left: 1px solid var(--surface-border);
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.dte-drawer-close:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.dte-mobile-drawer__pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.dte-mobile-drawer__pane > .dte-side,
|
||||
.dte-mobile-drawer__pane > .dte-vars {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dte-mobile-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 79;
|
||||
}
|
||||
.dte-drawer-fade-enter-active,
|
||||
.dte-drawer-fade-leave-active { transition: opacity 200ms ease; }
|
||||
.dte-drawer-fade-enter-from,
|
||||
.dte-drawer-fade-leave-to { opacity: 0; }
|
||||
|
||||
/* ═══════ Mobile (<1024px): só o editor visível ═══════ */
|
||||
@media (max-width: 1023px) {
|
||||
/* Editor ocupa tela inteira — col 1 e col 3 viram drawer */
|
||||
.dte-cols {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dte-cols > .dte-side,
|
||||
.dte-cols > .dte-vars { display: none; }
|
||||
|
||||
/* Mostra os botões "Identificação" / "Variáveis" no header */
|
||||
.dte-toolbar__mobile-actions { display: inline-flex; }
|
||||
|
||||
/* Esconde o título canônico no mobile (espaço pros botões) */
|
||||
.dte-toolbar__title > span { display: none; }
|
||||
.dte-toolbar__title > i { display: none; }
|
||||
|
||||
.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>
|
||||
<!-- ══ Mobile drawer (form + variáveis em tabs) ════════════ -->
|
||||
<Transition name="dte-drawer-fade">
|
||||
<div
|
||||
v-show="isMobile && drawerOpen"
|
||||
class="dte-mobile-drawer"
|
||||
:class="{ 'is-open': drawerOpen }"
|
||||
>
|
||||
<div class="dte-mobile-drawer__tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="dte-drawer-tab"
|
||||
:class="{ 'is-active': drawerTab === 'form' }"
|
||||
@click="drawerTab = 'form'"
|
||||
>
|
||||
<i class="pi pi-tag" />
|
||||
<span>Identificação</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dte-drawer-tab"
|
||||
:class="{ 'is-active': drawerTab === 'vars' }"
|
||||
@click="drawerTab = 'vars'"
|
||||
>
|
||||
<i class="pi pi-code" />
|
||||
<span>Variáveis</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dte-drawer-close"
|
||||
v-tooltip.bottom="'Fechar'"
|
||||
@click="fecharDrawer"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</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 id="dte-mobile-drawer-form" v-show="drawerTab === 'form'" class="dte-mobile-drawer__pane" />
|
||||
<div id="dte-mobile-drawer-vars" v-show="drawerTab === 'vars'" class="dte-mobile-drawer__pane" />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="dte-drawer-fade">
|
||||
<div
|
||||
v-show="isMobile && drawerOpen"
|
||||
class="dte-mobile-drawer__backdrop"
|
||||
@click="fecharDrawer"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div class="dte-page">
|
||||
<!-- ══ Toggle Editor / Preview no topo ══════════════════ -->
|
||||
<div class="dte-toolbar">
|
||||
<!-- Botões "Identificação" e "Variáveis" — mobile-only -->
|
||||
<div class="dte-toolbar__mobile-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="dte-mobile-btn"
|
||||
v-tooltip.bottom="'Identificação do template'"
|
||||
@click="openDrawer('form')"
|
||||
>
|
||||
<i class="pi pi-tag" />
|
||||
<span>Identificação</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dte-mobile-btn"
|
||||
v-tooltip.bottom="'Inserir variáveis'"
|
||||
@click="openDrawer('vars')"
|
||||
>
|
||||
<i class="pi pi-code" />
|
||||
<span>Variáveis</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dte-toolbar__title">
|
||||
<i class="pi pi-file-edit" />
|
||||
<span>Conteúdo do documento</span>
|
||||
</div>
|
||||
<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 — teleporta pro drawer no mobile ─── -->
|
||||
<Teleport to="#dte-mobile-drawer-form" :disabled="!isMobile">
|
||||
<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>
|
||||
</Teleport>
|
||||
|
||||
<!-- 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">{{</span>
|
||||
{{ v.label }}
|
||||
<span class="font-mono text-[0.62rem] opacity-60">}}</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 — teleporta pro drawer no mobile ─── -->
|
||||
<Teleport to="#dte-mobile-drawer-vars" :disabled="!isMobile">
|
||||
<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">{{</span>
|
||||
<span class="dte-vars__btn-label">{{ v.label }}</span>
|
||||
<span class="dte-vars__btn-brace">}}</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>
|
||||
</Teleport>
|
||||
</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>
|
||||
|
||||
@@ -47,8 +47,15 @@ export function useDocumentGenerate() {
|
||||
error.value = null;
|
||||
try {
|
||||
variables.value = await loadAllVariables(patientId, agendaEventoId);
|
||||
// Hint útil pra diagnostico: se vier objeto mas todos campos vazios,
|
||||
// sinaliza que perfil/clínica/paciente provavelmente nao tem dados.
|
||||
const filled = Object.values(variables.value).filter(v => String(v ?? '').trim() !== '').length;
|
||||
if (filled === 0) {
|
||||
error.value = 'Nenhum dado foi encontrado pra auto-preencher. Verifique o cadastro do paciente, perfil e clínica.';
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar dados do paciente.';
|
||||
console.error('[useDocumentGenerate.loadVariables] falha:', e);
|
||||
error.value = e?.message || 'Erro ao carregar dados pra preenchimento.';
|
||||
variables.value = {};
|
||||
} finally {
|
||||
loading.value = false;
|
||||
|
||||
@@ -210,6 +210,21 @@ const grupos = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'documentos',
|
||||
label: 'Documentos',
|
||||
desc: 'Modelos e geração de recibos, atestados, laudos, TCLE e LGPD.',
|
||||
icon: 'pi pi-file',
|
||||
items: [
|
||||
{
|
||||
key: 'documentos-templates',
|
||||
label: 'Modelos de documentos',
|
||||
desc: 'Cadastre e edite templates de recibos, atestados, laudos, TCLE, LGPD e mais.',
|
||||
icon: 'pi pi-file-edit',
|
||||
to: '/configuracoes/documentos/templates'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'plataforma',
|
||||
label: 'Empresa & Plataforma',
|
||||
|
||||
+208
-108
@@ -14,7 +14,7 @@
|
||||
* Quando promover pra produção: trocar a busca por chamada à RPC
|
||||
* `search_global` + manter a mesma estrutura de panel/items.
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useRecentPatients } from '@/composables/useRecentPatients';
|
||||
|
||||
@@ -79,6 +79,9 @@ const filteredAtalhos = computed(() => {
|
||||
|
||||
// Pacientes — combina RPC (autoritativo, todos os pacientes) com props (preview de hoje).
|
||||
// RPC tem prioridade; props complementa quando RPC ainda não trouxe nada.
|
||||
//
|
||||
// Shape do RPC search_global (patients): { id, label, sublabel, avatar_url, deeplink, score }
|
||||
// label = nome_completo; sublabel = email_principal ou telefone.
|
||||
const filteredPacientes = computed(() => {
|
||||
const q = normalize(query.value);
|
||||
if (q.length < 2) return [];
|
||||
@@ -87,15 +90,16 @@ const filteredPacientes = computed(() => {
|
||||
if (rpc.length) {
|
||||
return rpc.slice(0, 5).map(p => ({
|
||||
id: p.id,
|
||||
nome: p.nome_completo || p.nome_social || p.nome || '(sem nome)',
|
||||
email: p.email,
|
||||
telefone: p.telefone
|
||||
nome: p.label || '(sem nome)',
|
||||
sub: p.sublabel || '',
|
||||
avatar_url: p.avatar_url || null
|
||||
}));
|
||||
}
|
||||
// Fallback client-side
|
||||
// Fallback client-side (props.pacientes vem do MelissaLayout — shape diferente)
|
||||
return props.pacientes
|
||||
.filter((p) => normalize(p.nome).includes(q))
|
||||
.slice(0, 5);
|
||||
.slice(0, 5)
|
||||
.map(p => ({ id: p.id, nome: p.nome, sub: '', avatar_url: null }));
|
||||
});
|
||||
|
||||
const filteredEventos = computed(() => {
|
||||
@@ -139,9 +143,17 @@ function selectEntry(entry) {
|
||||
else if (entry.group === 'pacientes') emit('paciente', entry.item);
|
||||
else if (entry.group === 'recent') emit('paciente', { id: entry.item.id, nome: entry.item.nome, ...entry.item.extras });
|
||||
else if (entry.group === 'eventos') emit('evento', entry.item);
|
||||
else if (entry.group === 'rpc-appointments') emit('evento', entry.item);
|
||||
else if (entry.group === 'rpc-documents') emit('documento', entry.item);
|
||||
else if (entry.group === 'rpc-intakes') emit('intake', entry.item);
|
||||
else if (entry.group === 'rpc-appointments') {
|
||||
// Sessão da RPC: deeplink pra agenda com evento focado
|
||||
emit('evento', { id: entry.item.id, deeplink: entry.item.deeplink });
|
||||
} else if (entry.group === 'rpc-documents') {
|
||||
// Documento da RPC: extrai patient_id da deeplink se possível
|
||||
const dl = entry.item.deeplink || '';
|
||||
const m = dl.match(/patients\/([0-9a-f-]+)/i);
|
||||
emit('documento', { id: entry.item.id, patient_id: m?.[1] || null, label: entry.item.label });
|
||||
} else if (entry.group === 'rpc-intakes') {
|
||||
emit('intake', entry.item);
|
||||
}
|
||||
closePanel();
|
||||
}
|
||||
|
||||
@@ -149,22 +161,15 @@ function closePanel() {
|
||||
showPanel.value = false;
|
||||
query.value = '';
|
||||
activeIndex.value = -1;
|
||||
inputEl.value?.blur();
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
function openDialog() {
|
||||
showPanel.value = true;
|
||||
}
|
||||
|
||||
function onClickOutside(e) {
|
||||
if (rootEl.value && !rootEl.value.contains(e.target)) {
|
||||
showPanel.value = false;
|
||||
activeIndex.value = -1;
|
||||
}
|
||||
// Foca input do Dialog após ele montar
|
||||
nextTick(() => inputEl.value?.focus());
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (!showPanel.value) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, flatList.value.length - 1);
|
||||
@@ -174,19 +179,15 @@ function onKeydown(e) {
|
||||
} else if (e.key === 'Enter' && activeIndex.value >= 0) {
|
||||
e.preventDefault();
|
||||
selectEntry(flatList.value[activeIndex.value]);
|
||||
} else if (e.key === 'Escape') {
|
||||
// Stop bubbling pra ESC do parent não fechar overlay aleatório
|
||||
e.stopPropagation();
|
||||
closePanel();
|
||||
}
|
||||
// Escape é tratado pelo Dialog (dismissableMask + closable)
|
||||
}
|
||||
|
||||
function onGlobalKeydown(e) {
|
||||
// Ctrl+K / ⌘+K → foca input
|
||||
// Ctrl+K / ⌘+K → abre dialog
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
showPanel.value = true;
|
||||
inputEl.value?.focus();
|
||||
openDialog();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,11 +235,9 @@ watch(query, (v) => {
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
window.addEventListener('keydown', onGlobalKeydown);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
window.removeEventListener('keydown', onGlobalKeydown);
|
||||
if (debounceT) clearTimeout(debounceT);
|
||||
});
|
||||
@@ -246,22 +245,45 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div ref="rootEl" class="mb-search">
|
||||
<div class="mb-field">
|
||||
<!-- Trigger: aparência de input, mas é botão (abre Dialog) -->
|
||||
<button type="button" class="mb-field" @click="openDialog" :aria-label="'Buscar (Ctrl+K)'">
|
||||
<i class="pi pi-search mb-field__icon" />
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="query"
|
||||
type="text"
|
||||
placeholder="Buscar paciente, agenda, atalho…"
|
||||
class="mb-field__input"
|
||||
@focus="onFocus"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<span class="mb-field__placeholder">Buscar paciente, agenda, atalho…</span>
|
||||
<span class="mb-field__kbd" aria-hidden="true">Ctrl K</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Transition name="mb-fade">
|
||||
<div v-if="showPanel" class="mb-panel" role="listbox">
|
||||
<!-- Dialog Spotlight: input grande no topo + resultados em coluna -->
|
||||
<Dialog
|
||||
v-model:visible="showPanel"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="false"
|
||||
:dismissableMask="true"
|
||||
:showHeader="false"
|
||||
class="mb-dialog"
|
||||
:style="{ width: '640px', maxWidth: '94vw' }"
|
||||
pt:mask:class="mb-dialog__mask"
|
||||
pt:content:class="mb-dialog__content"
|
||||
@hide="closePanel"
|
||||
>
|
||||
<!-- Field do Dialog (input real, autofocus) -->
|
||||
<div class="mb-dialog__field">
|
||||
<i class="pi pi-search mb-dialog__field-icon" />
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="query"
|
||||
type="text"
|
||||
placeholder="Buscar paciente, agenda, atalho…"
|
||||
class="mb-dialog__input"
|
||||
@keydown="onKeydown"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="mb-dialog__esc" aria-hidden="true">Esc</span>
|
||||
</div>
|
||||
|
||||
<!-- Painel de resultados (scroll interno) -->
|
||||
<div class="mb-panel" role="listbox">
|
||||
<div
|
||||
v-if="query.trim().length >= 2 && !hasAnyResult"
|
||||
class="mb-empty"
|
||||
@@ -320,10 +342,10 @@ onBeforeUnmount(() => {
|
||||
@click="selectEntry({ group: 'pacientes', item: p })"
|
||||
@mouseenter="activeIndex = findFlatIndex('pacientes', i)"
|
||||
>
|
||||
<span class="mb-item__icon"><i class="pi pi-user" /></span>
|
||||
<span class="mb-item__icon mb-item__icon--patient"><i class="pi pi-user" /></span>
|
||||
<span class="mb-item__main">
|
||||
<span class="mb-item__label">{{ p.nome }}</span>
|
||||
<span class="mb-item__sub">Abrir prontuário</span>
|
||||
<span class="mb-item__sub">{{ p.sub || 'Abrir prontuário' }}</span>
|
||||
</span>
|
||||
<i class="mb-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
@@ -354,7 +376,10 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- RPC: Sessões/agendamentos (qualquer data) -->
|
||||
<!-- RPC: Sessões/agendamentos (qualquer data)
|
||||
RPC retorna { id, label, sublabel, deeplink }. Sublabel ja vem
|
||||
com "Paciente · dd/mm/yyyy HH:MM". Cor do icone = cor de sessao
|
||||
(indigo-500, igual ao pickColor() padrao). -->
|
||||
<div v-if="rpcAppointments.length" class="mb-group">
|
||||
<div class="mb-group__title">Sessões</div>
|
||||
<button
|
||||
@@ -365,10 +390,10 @@ onBeforeUnmount(() => {
|
||||
@click="selectEntry({ group: 'rpc-appointments', item: e })"
|
||||
@mouseenter="activeIndex = findFlatIndex('rpc-appointments', i)"
|
||||
>
|
||||
<span class="mb-item__icon"><i class="pi pi-calendar" /></span>
|
||||
<span class="mb-item__icon mb-item__icon--sessao"><i class="pi pi-calendar" /></span>
|
||||
<span class="mb-item__main">
|
||||
<span class="mb-item__label">{{ e.paciente_nome || e.title || 'Sessão' }}</span>
|
||||
<span class="mb-item__sub">{{ e.inicio_em ? new Date(e.inicio_em).toLocaleDateString('pt-BR') + ' ' + new Date(e.inicio_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : 'Sem data' }}</span>
|
||||
<span class="mb-item__label">{{ e.label || 'Sessão' }}</span>
|
||||
<span class="mb-item__sub">{{ e.sublabel || 'Sem detalhes' }}</span>
|
||||
</span>
|
||||
<i class="mb-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
@@ -385,10 +410,10 @@ onBeforeUnmount(() => {
|
||||
@click="selectEntry({ group: 'rpc-documents', item: d })"
|
||||
@mouseenter="activeIndex = findFlatIndex('rpc-documents', i)"
|
||||
>
|
||||
<span class="mb-item__icon"><i class="pi pi-file" /></span>
|
||||
<span class="mb-item__icon mb-item__icon--doc"><i class="pi pi-file" /></span>
|
||||
<span class="mb-item__main">
|
||||
<span class="mb-item__label">{{ d.nome_original || 'Documento' }}</span>
|
||||
<span class="mb-item__sub">{{ d.paciente_nome ? `${d.paciente_nome} · ` : '' }}{{ d.tipo_documento || 'outro' }}</span>
|
||||
<span class="mb-item__label">{{ d.label || 'Documento' }}</span>
|
||||
<span class="mb-item__sub">{{ d.sublabel || '' }}</span>
|
||||
</span>
|
||||
<i class="mb-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
@@ -405,16 +430,16 @@ onBeforeUnmount(() => {
|
||||
@click="selectEntry({ group: 'rpc-intakes', item: r })"
|
||||
@mouseenter="activeIndex = findFlatIndex('rpc-intakes', i)"
|
||||
>
|
||||
<span class="mb-item__icon"><i class="pi pi-inbox" /></span>
|
||||
<span class="mb-item__icon mb-item__icon--intake"><i class="pi pi-inbox" /></span>
|
||||
<span class="mb-item__main">
|
||||
<span class="mb-item__label">{{ r.nome_completo || 'Cadastro' }}</span>
|
||||
<span class="mb-item__sub">{{ r.created_at ? new Date(r.created_at).toLocaleDateString('pt-BR') : '' }}</span>
|
||||
<span class="mb-item__label">{{ r.label || 'Cadastro' }}</span>
|
||||
<span class="mb-item__sub">{{ r.sublabel || '' }}</span>
|
||||
</span>
|
||||
<i class="mb-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -423,16 +448,17 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
/* Só horizontal — top/bottom ficam livres pro parent (mt-X do Tailwind) */
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* ─── Trigger no dock (visual de input, mas é botão que abre Dialog) ─── */
|
||||
.mb-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: var(--m-bg-soft);
|
||||
backdrop-filter: blur(20px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||||
@@ -440,11 +466,12 @@ onBeforeUnmount(() => {
|
||||
border-radius: 12px;
|
||||
padding: 0 14px;
|
||||
height: 44px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
.mb-field:focus-within {
|
||||
.mb-field:hover {
|
||||
background: var(--m-bg-soft-hover);
|
||||
border-color: var(--m-border-strong);
|
||||
}
|
||||
.mb-field__icon {
|
||||
color: var(--m-text-muted);
|
||||
@@ -452,18 +479,15 @@ onBeforeUnmount(() => {
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mb-field__input {
|
||||
.mb-field__placeholder {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: white;
|
||||
color: var(--m-text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
.mb-field__input::placeholder {
|
||||
color: var(--m-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mb-field__kbd {
|
||||
color: var(--m-text-muted);
|
||||
@@ -478,49 +502,109 @@ onBeforeUnmount(() => {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ─── Dialog Spotlight (PrimeVue Dialog customizado) ─── */
|
||||
:global(.mb-dialog__mask) {
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.55) !important;
|
||||
}
|
||||
:global(.mb-dialog) {
|
||||
border-radius: 14px !important;
|
||||
overflow: hidden;
|
||||
/* Posiciona mais alto que o centro (estilo Spotlight) */
|
||||
margin-top: 10vh !important;
|
||||
align-self: flex-start;
|
||||
}
|
||||
:global(.mb-dialog .mb-dialog__content) {
|
||||
padding: 0 !important;
|
||||
background: var(--surface-card) !important;
|
||||
border-radius: 14px !important;
|
||||
overflow: hidden;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mb-dialog__field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mb-dialog__field-icon {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 1.05rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mb-dialog__input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-color);
|
||||
font-size: 1.05rem;
|
||||
font-family: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
.mb-dialog__input::placeholder {
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.mb-dialog__esc {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--surface-border);
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Panel agora vive DENTRO do Dialog (não mais absolute). Scroll interno
|
||||
resolve o bug de overflow — o conteúdo nunca passa do bottom porque
|
||||
o Dialog tem max-height e o panel é flex:1. */
|
||||
.mb-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
max-height: 60vh;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border: 1px solid var(--m-border-strong);
|
||||
border-radius: 12px;
|
||||
overflow-x: hidden;
|
||||
padding: 6px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||
background: var(--surface-card);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--m-border-strong) transparent;
|
||||
scrollbar-color: var(--surface-border) transparent;
|
||||
min-height: 0; /* permite shrink no flex */
|
||||
}
|
||||
.mb-panel::-webkit-scrollbar { width: 6px; }
|
||||
.mb-panel::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
background: var(--surface-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mb-empty {
|
||||
padding: 18px 14px;
|
||||
text-align: center;
|
||||
color: var(--m-text-muted);
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.mb-group + .mb-group {
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--m-border);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
.mb-group__title {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--m-text-faint);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
padding: 8px 10px 4px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.mb-item {
|
||||
@@ -528,11 +612,11 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
padding: 9px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
color: var(--text-color);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
@@ -540,53 +624,69 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.mb-item:hover,
|
||||
.mb-item.is-active {
|
||||
background: var(--m-bg-soft);
|
||||
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent);
|
||||
}
|
||||
.mb-item__icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--m-bg-soft);
|
||||
background: var(--surface-ground);
|
||||
border-radius: 7px;
|
||||
color: var(--m-text-muted);
|
||||
color: var(--text-color-secondary);
|
||||
flex-shrink: 0;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
/* Cores por tipo — semântica fixa (não depende do tema, é categoria). */
|
||||
.mb-item__icon--patient {
|
||||
background: rgba(244, 114, 182, 0.18);
|
||||
color: #ec4899;
|
||||
}
|
||||
.mb-item__icon--sessao {
|
||||
background: rgba(99, 102, 241, 0.20);
|
||||
color: #6366f1;
|
||||
}
|
||||
.mb-item__icon--doc {
|
||||
background: rgba(14, 165, 233, 0.18);
|
||||
color: #0ea5e9;
|
||||
}
|
||||
.mb-item__icon--intake {
|
||||
background: rgba(251, 146, 60, 0.18);
|
||||
color: #f97316;
|
||||
}
|
||||
/* Dark mode: clareia as cores semânticas pra manter contraste */
|
||||
:root.app-dark .mb-item__icon--patient { color: #f9a8d4; }
|
||||
:root.app-dark .mb-item__icon--sessao { color: #a5b4fc; }
|
||||
:root.app-dark .mb-item__icon--doc { color: #7dd3fc; }
|
||||
:root.app-dark .mb-item__icon--intake { color: #fdba74; }
|
||||
|
||||
.mb-item__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
gap: 2px;
|
||||
}
|
||||
.mb-item__label {
|
||||
font-size: 0.85rem;
|
||||
color: white;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mb-item__sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--m-text-muted);
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-color-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mb-item__go {
|
||||
color: var(--m-text-faint);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.5;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mb-fade-enter-active,
|
||||
.mb-fade-leave-active {
|
||||
transition: opacity 140ms ease, transform 160ms ease;
|
||||
}
|
||||
.mb-fade-enter-from,
|
||||
.mb-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Lógica idêntica à DocumentTemplatesPage (composable
|
||||
* useDocumentTemplates + DocumentTemplateEditor reusado).
|
||||
*/
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useDocumentTemplates } from '@/features/documents/composables/useDocume
|
||||
import DocumentTemplateEditor from '@/features/documents/components/DocumentTemplateEditor.vue';
|
||||
// Button/Menu/Skeleton: auto via PrimeVueResolver
|
||||
import Menu from 'primevue/menu';
|
||||
import DataView from 'primevue/dataview';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const toast = useToast();
|
||||
@@ -29,11 +30,15 @@ const {
|
||||
fetchTemplates, create, update, remove, duplicate
|
||||
} = useDocumentTemplates();
|
||||
|
||||
// ── Views ───────────────────────────────────────────────
|
||||
// ── Views: list | create | edit | preview ───────────────
|
||||
const view = ref('list');
|
||||
const editingTemplate = ref({});
|
||||
const editingId = ref(null);
|
||||
|
||||
// Preview de template global (somente leitura) — abre antes de duplicar
|
||||
// para o usuário ler o conteúdo. Inclui botão "Duplicar" no header.
|
||||
const previewTemplate = ref(null);
|
||||
|
||||
// ── Acoes ───────────────────────────────────────────────
|
||||
function openCreate() {
|
||||
editingId.value = null;
|
||||
@@ -56,6 +61,58 @@ function openEdit(tpl) {
|
||||
view.value = 'edit';
|
||||
}
|
||||
|
||||
// Preview de template do sistema — leitura + botão Duplicar.
|
||||
// Clique na sidebar de templates do sistema cai aqui em vez de
|
||||
// duplicar direto.
|
||||
function openPreview(tpl) {
|
||||
previewTemplate.value = tpl;
|
||||
view.value = 'preview';
|
||||
// No mobile, fecha o drawer pra dar espaço ao preview
|
||||
if (isMobile.value) drawerOpen.value = false;
|
||||
}
|
||||
|
||||
// Monta HTML completo do template (cabeçalho + corpo + rodapé) com
|
||||
// estilos básicos pra preview legível dentro do iframe.
|
||||
const previewHtml = computed(() => {
|
||||
const tpl = previewTemplate.value;
|
||||
if (!tpl) return '';
|
||||
const cabecalho = tpl.cabecalho_html || '';
|
||||
const corpo = tpl.corpo_html || '';
|
||||
const rodape = tpl.rodape_html || '';
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm 15mm 25mm 15mm; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
font-size: 12pt;
|
||||
line-height: 1.6;
|
||||
color: #1a1a1a;
|
||||
background: #ffffff;
|
||||
padding: 32px;
|
||||
margin: 0;
|
||||
}
|
||||
h2 { font-size: 16pt; margin-bottom: 16px; }
|
||||
h3 { font-size: 13pt; margin-top: 20px; margin-bottom: 8px; }
|
||||
p { margin: 8px 0; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
td { padding: 4px 8px; }
|
||||
hr { border: none; border-top: 1px solid #333; }
|
||||
ul, ol { margin: 8px 0; padding-left: 24px; }
|
||||
.doc-header { text-align: center; margin-bottom: 24px; padding-bottom: 12px; border-bottom: 1px solid #ccc; }
|
||||
.doc-footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid #ccc; font-size: 10pt; color: #666; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${cabecalho ? `<div class="doc-header">${cabecalho}</div>` : ''}
|
||||
<div class="doc-content">${corpo}</div>
|
||||
${rodape ? `<div class="doc-footer">${rodape}</div>` : ''}
|
||||
</body>
|
||||
</html>`;
|
||||
});
|
||||
|
||||
async function onSave(payload) {
|
||||
try {
|
||||
if (view.value === 'create') {
|
||||
@@ -82,7 +139,12 @@ function onDuplicate(tpl) {
|
||||
accept: async () => {
|
||||
try {
|
||||
await duplicate(tpl.id);
|
||||
toast.add({ severity: 'success', summary: 'Duplicado', detail: `"${tpl.nome_template}" copiado para Meus Templates.`, life: 3000 });
|
||||
toast.add({ severity: 'success', summary: 'Duplicado', detail: `"${tpl.nome_template}" copiado para Seus documentos.`, life: 3000 });
|
||||
// Se veio do preview, volta pra list pra mostrar o novo template no main
|
||||
if (view.value === 'preview') {
|
||||
view.value = 'list';
|
||||
previewTemplate.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message });
|
||||
}
|
||||
@@ -114,6 +176,15 @@ function tipoLabel(tipo) {
|
||||
return TIPOS_TEMPLATE.find((t) => t.value === tipo)?.label || tipo;
|
||||
}
|
||||
|
||||
// Formata as variáveis do template como string "{{nome}}, {{outra}}…"
|
||||
// (helper externo — evita conflito de {{ }} aninhado no template Vue)
|
||||
function formatVarsPreview(vars, max = 5) {
|
||||
if (!Array.isArray(vars) || !vars.length) return '';
|
||||
const open = '{';
|
||||
const close = '}';
|
||||
return vars.slice(0, max).map((v) => `${open}${open}${v}${close}${close}`).join(', ');
|
||||
}
|
||||
|
||||
// ── Card menu (templates do tenant) ─────────────────────
|
||||
function getCardMenuItems(tpl) {
|
||||
const items = [
|
||||
@@ -129,16 +200,69 @@ function getCardMenuItems(tpl) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── Mobile drawer (espelha padrão MelissaBloqueios) ─────
|
||||
const drawerOpen = ref(false);
|
||||
const isMobile = ref(false);
|
||||
let _mqMobile = null;
|
||||
function _onMqMobileChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
if (!e.matches) drawerOpen.value = false;
|
||||
}
|
||||
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
|
||||
function fecharDrawer() { drawerOpen.value = false; }
|
||||
|
||||
onMounted(() => {
|
||||
fetchTemplates(true);
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
|
||||
catch { _mqMobile.addListener(_onMqMobileChange); }
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) {
|
||||
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
|
||||
catch { _mqMobile.removeListener(_onMqMobileChange); }
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
<!-- Mobile drawer (templates do sistema) -->
|
||||
<Transition name="mdt-drawer-fade">
|
||||
<div
|
||||
v-show="isMobile && drawerOpen"
|
||||
class="mdt-mobile-drawer"
|
||||
:class="{ 'is-open': drawerOpen }"
|
||||
>
|
||||
<div id="mdt-mobile-drawer-target" class="mdt-mobile-drawer__scroll" />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="mdt-drawer-fade">
|
||||
<div
|
||||
v-show="isMobile && drawerOpen"
|
||||
class="mdt-mobile-drawer__backdrop"
|
||||
@click="fecharDrawer"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- ConfirmDialog global mora no MelissaLayout — montar aqui causava
|
||||
callback duplicado (dois listeners pro mesmo require) -->
|
||||
|
||||
<section class="mdt-page">
|
||||
<header class="mdt-page__head">
|
||||
<!-- Botão "Menu" mobile-only (vira título no mobile, abre drawer com templates do sistema) -->
|
||||
<button
|
||||
v-if="view === 'list' || view === 'preview'"
|
||||
class="mdt-menu-btn mdt-menu-btn--mobile-only"
|
||||
v-tooltip.bottom="'Templates do sistema'"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
<span>Templates do sistema</span>
|
||||
</button>
|
||||
<div class="mdt-page__title">
|
||||
<button
|
||||
v-if="view !== 'list'"
|
||||
@@ -152,7 +276,8 @@ onMounted(() => {
|
||||
<span>
|
||||
<template v-if="view === 'list'">Templates de documentos</template>
|
||||
<template v-else-if="view === 'create'">Novo template</template>
|
||||
<template v-else>Editar template</template>
|
||||
<template v-else-if="view === 'edit'">Editar template</template>
|
||||
<template v-else-if="view === 'preview'">Visualizar template</template>
|
||||
</span>
|
||||
<span v-if="view === 'list'" class="mdt-page__count">{{ templates.length }}</span>
|
||||
</div>
|
||||
@@ -210,8 +335,8 @@ onMounted(() => {
|
||||
|
||||
<!-- Body -->
|
||||
<div class="mdt-body">
|
||||
<!-- ══ LIST VIEW ══ -->
|
||||
<template v-if="view === 'list'">
|
||||
<!-- ══ LIST + PREVIEW VIEWS — sidebar do sistema sempre presente ══ -->
|
||||
<template v-if="view === 'list' || view === 'preview'">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading && !templates.length" class="mdt-loading">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
@@ -229,103 +354,183 @@ onMounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Templates globais (padrão) -->
|
||||
<div v-if="globalTemplates.length" class="mdt-section">
|
||||
<div class="mdt-section__head">
|
||||
<div class="mdt-section__title">
|
||||
<!-- ══ Layout 2-col: sidebar (globais) + main (do tenant) ══ -->
|
||||
<div v-else class="mdt-cols">
|
||||
<!-- ─── COL 1 — Sidebar: Templates do sistema (teleporta pro drawer no mobile) ─── -->
|
||||
<Teleport to="#mdt-mobile-drawer-target" :disabled="!isMobile">
|
||||
<aside class="mdt-side">
|
||||
<header class="mdt-side__head">
|
||||
<div class="mdt-side__title">
|
||||
<i class="pi pi-shield" />
|
||||
<span>Templates padrão do sistema</span>
|
||||
<span>Templates do sistema</span>
|
||||
</div>
|
||||
<span class="mdt-section__count is-info">{{ globalTemplates.length }}</span>
|
||||
<span class="mdt-page__count">{{ globalTemplates.length }}</span>
|
||||
</header>
|
||||
<p class="mdt-side__subtitle">Modelos padrão da plataforma. Clique pra duplicar e personalizar.</p>
|
||||
|
||||
<div v-if="!globalTemplates.length" class="mdt-side__empty">
|
||||
<i class="pi pi-info-circle" />
|
||||
<span>Sem templates do sistema disponíveis.</span>
|
||||
</div>
|
||||
<div class="mdt-grid">
|
||||
<button
|
||||
|
||||
<ul v-else class="mdt-side__list">
|
||||
<li
|
||||
v-for="tpl in globalTemplates"
|
||||
:key="tpl.id"
|
||||
class="mdt-card mdt-card--global"
|
||||
type="button"
|
||||
@click="onDuplicate(tpl)"
|
||||
>
|
||||
<div class="mdt-card__head">
|
||||
<span class="mdt-card__icon mdt-card__icon--info">
|
||||
<i class="pi pi-file" />
|
||||
</span>
|
||||
<div class="mdt-card__main">
|
||||
<div class="mdt-card__name">{{ tpl.nome_template }}</div>
|
||||
<div class="mdt-card__tipo">{{ tipoLabel(tpl.tipo) }}</div>
|
||||
<div v-if="tpl.descricao" class="mdt-card__desc">{{ tpl.descricao }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="mdt-card__badge mdt-card__badge--info">padrão</span>
|
||||
<div class="mdt-card__hint">
|
||||
<i class="pi pi-copy" />
|
||||
Click pra duplicar e personalizar
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Templates do tenant (meus) -->
|
||||
<div v-if="tenantTemplates.length" class="mdt-section">
|
||||
<div class="mdt-section__head">
|
||||
<div class="mdt-section__title">
|
||||
<i class="pi pi-user-edit" />
|
||||
<span>Meus templates</span>
|
||||
</div>
|
||||
<span class="mdt-section__count is-accent">{{ tenantTemplates.length }}</span>
|
||||
</div>
|
||||
<div class="mdt-grid">
|
||||
<div
|
||||
v-for="tpl in tenantTemplates"
|
||||
:key="tpl.id"
|
||||
class="mdt-card mdt-card--tenant"
|
||||
class="mdt-side__item"
|
||||
:class="{ 'is-active': view === 'preview' && previewTemplate?.id === tpl.id }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openEdit(tpl)"
|
||||
@keydown.enter.prevent="openEdit(tpl)"
|
||||
@click="openPreview(tpl)"
|
||||
@keydown.enter.prevent="openPreview(tpl)"
|
||||
>
|
||||
<div class="mdt-card__head">
|
||||
<span class="mdt-card__icon mdt-card__icon--primary">
|
||||
<i class="pi pi-file-edit" />
|
||||
</span>
|
||||
<div class="mdt-card__main">
|
||||
<div class="mdt-card__name">{{ tpl.nome_template }}</div>
|
||||
<div class="mdt-card__tipo">{{ tipoLabel(tpl.tipo) }}</div>
|
||||
<div v-if="tpl.descricao" class="mdt-card__desc">{{ tpl.descricao }}</div>
|
||||
<span class="mdt-side__item-icon">
|
||||
<i class="pi pi-file" />
|
||||
</span>
|
||||
<div class="mdt-side__item-main">
|
||||
<div class="mdt-side__item-name">{{ tpl.nome_template }}</div>
|
||||
<div class="mdt-side__item-tipo">{{ tipoLabel(tpl.tipo) }}</div>
|
||||
<div v-if="tpl.descricao" class="mdt-side__item-desc">{{ tpl.descricao }}</div>
|
||||
</div>
|
||||
<i class="pi pi-eye mdt-side__item-action" v-tooltip.left="'Visualizar'" />
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</Teleport>
|
||||
|
||||
<!-- ─── COL 2 — Main: Seus documentos OU Preview do sistema ─── -->
|
||||
<main class="mdt-main">
|
||||
<!-- ── HEADER: muda conforme view ── -->
|
||||
<header class="mdt-main__head">
|
||||
<template v-if="view === 'list'">
|
||||
<div class="mdt-main__title-row">
|
||||
<div class="mdt-main__title">
|
||||
<i class="pi pi-user-edit" />
|
||||
<span>Seus documentos</span>
|
||||
</div>
|
||||
<span class="mdt-page__count">{{ tenantTemplates.length }}</span>
|
||||
</div>
|
||||
<p class="mdt-main__subtitle">
|
||||
Templates personalizados da sua clínica. Crie do zero ou duplique um modelo do sistema.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="view === 'preview' && previewTemplate">
|
||||
<div class="mdt-main__title-row">
|
||||
<div class="mdt-main__title">
|
||||
<i class="pi pi-eye mdt-main__title-icon-eye" />
|
||||
<span>{{ previewTemplate.nome_template }}</span>
|
||||
</div>
|
||||
<div class="mdt-preview-actions">
|
||||
<button class="mdt-act-btn" @click="view = 'list'">
|
||||
<i class="pi pi-arrow-left" />
|
||||
<span>Voltar</span>
|
||||
</button>
|
||||
<button class="mdt-act-btn mdt-act-btn--primary" @click="onDuplicate(previewTemplate)">
|
||||
<i class="pi pi-copy" />
|
||||
<span>Duplicar pra meus templates</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mdt-main__subtitle">
|
||||
<strong>{{ tipoLabel(previewTemplate.tipo) }}</strong>
|
||||
<span v-if="previewTemplate.descricao"> · {{ previewTemplate.descricao }}</span>
|
||||
</p>
|
||||
</template>
|
||||
</header>
|
||||
|
||||
<!-- Menu de ações -->
|
||||
<div class="mdt-card__menu" @click.stop>
|
||||
<Button
|
||||
icon="pi pi-ellipsis-v"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="!w-7 !h-7"
|
||||
@click.stop="$refs[`menu_${tpl.id}`]?.[0]?.toggle($event)"
|
||||
/>
|
||||
<Menu
|
||||
:ref="`menu_${tpl.id}`"
|
||||
:model="getCardMenuItems(tpl)"
|
||||
:popup="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mdt-card__foot">
|
||||
<span v-if="!tpl.ativo" class="mdt-card__badge mdt-card__badge--inactive">
|
||||
inativo
|
||||
</span>
|
||||
<span class="mdt-card__vars">
|
||||
<i class="pi pi-code" />
|
||||
{{ tpl.variaveis?.length || 0 }} variáveis
|
||||
</span>
|
||||
</div>
|
||||
<!-- ── PREVIEW VIEW: iframe com o template renderizado ── -->
|
||||
<div v-if="view === 'preview' && previewTemplate" class="mdt-preview-wrap">
|
||||
<iframe
|
||||
:srcdoc="previewHtml"
|
||||
class="mdt-preview-iframe"
|
||||
sandbox="allow-same-origin"
|
||||
title="Pré-visualização do template"
|
||||
/>
|
||||
<div v-if="previewTemplate.variaveis?.length" class="mdt-preview-vars">
|
||||
<i class="pi pi-info-circle" />
|
||||
<span>
|
||||
Este template usa {{ previewTemplate.variaveis.length }} variável(eis):
|
||||
<code>{{ formatVarsPreview(previewTemplate.variaveis, 5) }}</code>
|
||||
<span v-if="previewTemplate.variaveis.length > 5"> e +{{ previewTemplate.variaveis.length - 5 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── LIST VIEW: empty ou grid ── -->
|
||||
<div v-else-if="!tenantTemplates.length" class="mdt-main__empty">
|
||||
<i class="pi pi-file-edit mdt-main__empty-icon" />
|
||||
<div class="mdt-main__empty-title">Nenhum template personalizado ainda</div>
|
||||
<div class="mdt-main__empty-hint">
|
||||
Clique em "Novo template" no topo da página ou duplique um modelo do sistema na coluna ao lado.
|
||||
</div>
|
||||
<button class="mdt-act-btn mdt-act-btn--primary mdt-main__empty-btn" @click="openCreate">
|
||||
<i class="pi pi-plus" />
|
||||
<span>Criar primeiro template</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- DataView dos templates do tenant — paginação + layout grid -->
|
||||
<DataView
|
||||
v-else
|
||||
:value="tenantTemplates"
|
||||
layout="grid"
|
||||
:paginator="tenantTemplates.length > 12"
|
||||
:rows="12"
|
||||
class="mdt-dataview"
|
||||
>
|
||||
<template #grid="slotProps">
|
||||
<div class="mdt-grid">
|
||||
<div
|
||||
v-for="tpl in slotProps.items"
|
||||
:key="tpl.id"
|
||||
class="mdt-card mdt-card--tenant"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openEdit(tpl)"
|
||||
@keydown.enter.prevent="openEdit(tpl)"
|
||||
>
|
||||
<div class="mdt-card__head">
|
||||
<span class="mdt-card__icon mdt-card__icon--primary">
|
||||
<i class="pi pi-file-edit" />
|
||||
</span>
|
||||
<div class="mdt-card__main">
|
||||
<div class="mdt-card__name">{{ tpl.nome_template }}</div>
|
||||
<div class="mdt-card__tipo">{{ tipoLabel(tpl.tipo) }}</div>
|
||||
<div v-if="tpl.descricao" class="mdt-card__desc">{{ tpl.descricao }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu de ações -->
|
||||
<div class="mdt-card__menu" @click.stop>
|
||||
<Button
|
||||
icon="pi pi-ellipsis-v"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="!w-7 !h-7 mdt-card__menu-btn"
|
||||
@click.stop="$refs[`menu_${tpl.id}`]?.[0]?.toggle($event)"
|
||||
/>
|
||||
<Menu
|
||||
:ref="`menu_${tpl.id}`"
|
||||
:model="getCardMenuItems(tpl)"
|
||||
:popup="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mdt-card__foot">
|
||||
<span v-if="!tpl.ativo" class="mdt-card__badge mdt-card__badge--inactive">
|
||||
inativo
|
||||
</span>
|
||||
<span class="mdt-card__vars">
|
||||
< {{ tpl.variaveis?.length || 0 }} variáveis >
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataView>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ══ CREATE / EDIT VIEW ══ -->
|
||||
@@ -445,15 +650,19 @@ onMounted(() => {
|
||||
color: var(--m-text);
|
||||
}
|
||||
.mdt-act-btn:hover { background: var(--m-bg-soft-hover); }
|
||||
/* Estilo outlined: borda primary + texto primary + bg transparente.
|
||||
Resolve problema do modo escuro onde bg primary deixava o texto
|
||||
ilegível (cor primary clara contra texto branco). */
|
||||
.mdt-act-btn--primary {
|
||||
background: var(--m-accent);
|
||||
border-color: var(--m-accent);
|
||||
color: white;
|
||||
background: transparent;
|
||||
border-color: var(--p-primary-color);
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
.mdt-act-btn--primary:hover {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
background: color-mix(in srgb, var(--p-primary-color) 10%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.mdt-act-btn--primary > i { color: var(--p-primary-color); }
|
||||
.mdt-act-btn > i { font-size: 0.78rem; }
|
||||
|
||||
/* Subheader */
|
||||
@@ -473,19 +682,307 @@ onMounted(() => {
|
||||
.mdt-subheader__text { flex: 1; min-width: 0; }
|
||||
.mdt-subheader__text strong { color: var(--m-text); font-weight: 600; }
|
||||
|
||||
/* Body */
|
||||
/* Body (container externo — fica em flex pra acomodar 2-col body OU editor full) */
|
||||
.mdt-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ─── Layout 2-col: sidebar (sistema) + main (do tenant) ─── */
|
||||
.mdt-cols {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.mdt-cols {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── COL 1: Sidebar (templates do sistema) ── */
|
||||
.mdt-side {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mdt-side__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 14px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mdt-side__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
color: var(--m-text);
|
||||
}
|
||||
.mdt-side__title > i {
|
||||
font-size: 0.9rem;
|
||||
color: var(--m-text-muted);
|
||||
}
|
||||
.mdt-side__subtitle {
|
||||
margin: 0 14px 8px;
|
||||
font-size: 0.74rem;
|
||||
color: var(--m-text-muted);
|
||||
line-height: 1.45;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mdt-side__empty {
|
||||
padding: 24px 14px;
|
||||
text-align: center;
|
||||
color: var(--m-text-muted);
|
||||
font-size: 0.78rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.mdt-side__empty > i { font-size: 1.4rem; color: var(--m-text-faint); }
|
||||
|
||||
.mdt-side__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px 12px;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--m-border-strong) transparent;
|
||||
}
|
||||
.mdt-body::-webkit-scrollbar { width: 5px; }
|
||||
.mdt-body::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
|
||||
.mdt-side__list::-webkit-scrollbar { width: 5px; }
|
||||
.mdt-side__list::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
|
||||
|
||||
.mdt-side__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 10px;
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 9px;
|
||||
color: var(--m-text);
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.mdt-side__item:hover,
|
||||
.mdt-side__item:focus-visible {
|
||||
background: var(--m-bg-soft-hover);
|
||||
border-color: var(--p-primary-color);
|
||||
outline: none;
|
||||
}
|
||||
.mdt-side__item.is-active {
|
||||
background: color-mix(in srgb, var(--p-primary-color) 12%, var(--m-bg-medium));
|
||||
border-color: var(--p-primary-color);
|
||||
}
|
||||
.mdt-side__item.is-active .mdt-side__item-action {
|
||||
opacity: 1;
|
||||
}
|
||||
.mdt-side__item-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, rgb(37, 99, 235) 14%, transparent);
|
||||
color: rgb(37, 99, 235);
|
||||
border-radius: 7px;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.mdt-side__item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.mdt-side__item-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mdt-side__item-tipo {
|
||||
font-size: 0.66rem;
|
||||
color: var(--m-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.mdt-side__item-desc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--m-text-muted);
|
||||
margin-top: 3px;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mdt-side__item-action {
|
||||
flex-shrink: 0;
|
||||
color: var(--m-text-faint);
|
||||
font-size: 0.78rem;
|
||||
opacity: 0;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
.mdt-side__item:hover .mdt-side__item-action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── COL 2: Main (Seus documentos) ── */
|
||||
.mdt-main {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mdt-main__head {
|
||||
padding: 12px 14px 8px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mdt-main__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.mdt-main__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
color: var(--m-text);
|
||||
}
|
||||
.mdt-main__title > i {
|
||||
font-size: 0.9rem;
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
.mdt-main__subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--m-text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.mdt-main__empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 56px 28px;
|
||||
text-align: center;
|
||||
color: var(--m-text-muted);
|
||||
gap: 8px;
|
||||
}
|
||||
.mdt-main__empty-icon { font-size: 2.4rem; color: var(--m-text-faint); margin-bottom: 6px; }
|
||||
.mdt-main__empty-title { font-size: 0.95rem; font-weight: 600; color: var(--m-text); }
|
||||
.mdt-main__empty-hint { font-size: 0.82rem; max-width: 360px; line-height: 1.5; }
|
||||
.mdt-main__empty-btn { margin-top: 10px; }
|
||||
|
||||
.mdt-main .mdt-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* DataView wrapper — preenche o main e quebra em paginator/grid */
|
||||
.mdt-dataview {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
.mdt-dataview :deep(.p-dataview-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
background: transparent;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--m-border-strong) transparent;
|
||||
}
|
||||
.mdt-dataview :deep(.p-dataview-content)::-webkit-scrollbar { width: 5px; }
|
||||
.mdt-dataview :deep(.p-dataview-content)::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mdt-dataview :deep(.p-dataview-paginator-bottom) {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border-top: 1px solid var(--m-border);
|
||||
}
|
||||
|
||||
/* ── Preview de template do sistema ── */
|
||||
.mdt-preview-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mdt-preview-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
background: #f4f4f4;
|
||||
}
|
||||
.mdt-preview-iframe {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: #ffffff;
|
||||
}
|
||||
.mdt-preview-vars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--m-bg-soft);
|
||||
border-top: 1px solid var(--m-border);
|
||||
color: var(--m-text-muted);
|
||||
font-size: 0.74rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mdt-preview-vars > i { color: var(--p-primary-color); flex-shrink: 0; }
|
||||
.mdt-preview-vars code {
|
||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||
background: var(--m-bg-medium);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
/* Loading + Empty */
|
||||
.mdt-loading,
|
||||
@@ -534,25 +1031,8 @@ onMounted(() => {
|
||||
font-size: 0.92rem;
|
||||
color: var(--m-text-muted);
|
||||
}
|
||||
.mdt-section__count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mdt-section__count.is-info {
|
||||
background: color-mix(in srgb, rgb(37, 99, 235) 18%, transparent);
|
||||
color: rgb(37, 99, 235);
|
||||
}
|
||||
.mdt-section__count.is-accent {
|
||||
background: var(--m-accent);
|
||||
color: white;
|
||||
}
|
||||
/* .mdt-section__count removido — substituido por .mdt-page__count
|
||||
(mesmo estilo do header, evita conflito de contraste no dark mode) */
|
||||
|
||||
.mdt-grid {
|
||||
display: grid;
|
||||
@@ -575,6 +1055,9 @@ onMounted(() => {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
/* Acomoda titulo em ate 3 linhas + tipo + descricao 2 linhas + foot */
|
||||
max-height: 240px;
|
||||
overflow: hidden;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.mdt-card:hover {
|
||||
@@ -618,11 +1101,14 @@ onMounted(() => {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-text);
|
||||
line-height: 1.3;
|
||||
/* Permite quebrar em até 3 linhas se o nome for longo */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-word;
|
||||
}
|
||||
.mdt-card__tipo {
|
||||
font-size: 0.72rem;
|
||||
@@ -684,23 +1170,126 @@ onMounted(() => {
|
||||
.mdt-card__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.mdt-card__vars {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--m-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--p-primary-color);
|
||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.mdt-card__vars > i { font-size: 0.65rem; }
|
||||
|
||||
/* Mobile (<1024px) */
|
||||
/* Botão 3-pontos do menu do card — cor primary */
|
||||
.mdt-card__menu-btn:deep(.p-button-icon) {
|
||||
color: var(--p-primary-color) !important;
|
||||
}
|
||||
.mdt-card__menu-btn:hover:deep(.p-button-icon) {
|
||||
color: var(--p-primary-color) !important;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Ícone eye do header de preview — cor primary */
|
||||
.mdt-main__title-icon-eye {
|
||||
color: var(--p-primary-color) !important;
|
||||
}
|
||||
|
||||
/* ═══════ Botão "Menu" mobile-only (abre drawer com templates do sistema) ═══════ */
|
||||
.mdt-menu-btn {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--m-border);
|
||||
background: var(--m-bg-soft);
|
||||
color: var(--m-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mdt-menu-btn:hover { background: var(--m-bg-soft-hover); }
|
||||
|
||||
/* ═══════ Mobile drawer (templates do sistema teleportados) ═══════ */
|
||||
.mdt-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(360px, 88vw);
|
||||
z-index: 80;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border-right: 1px solid var(--m-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--m-text);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mdt-mobile-drawer.is-open { transform: translateX(0); }
|
||||
.mdt-mobile-drawer__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--m-border-strong) transparent;
|
||||
}
|
||||
.mdt-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
|
||||
.mdt-mobile-drawer__scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
/* No mobile a .mdt-side é teleportada pra dentro do drawer scroll */
|
||||
.mdt-mobile-drawer__scroll .mdt-side {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--m-border);
|
||||
}
|
||||
|
||||
.mdt-mobile-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 79;
|
||||
}
|
||||
.mdt-drawer-fade-enter-active,
|
||||
.mdt-drawer-fade-leave-active { transition: opacity 200ms ease; }
|
||||
.mdt-drawer-fade-enter-from,
|
||||
.mdt-drawer-fade-leave-to { opacity: 0; }
|
||||
|
||||
/* ═══════ Mobile (<1024px) — ajustes ═══════ */
|
||||
@media (max-width: 1023px) {
|
||||
.mdt-page__title > span:nth-child(2):not(.mdt-page__count) {
|
||||
font-size: 0.92rem;
|
||||
/* Esconde a sidebar inline (templates do sistema viram drawer) */
|
||||
.mdt-cols {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mdt-cols > .mdt-side { display: none; }
|
||||
|
||||
/* Mostra botão Menu, esconde título canônico (vira sub-info) */
|
||||
.mdt-menu-btn--mobile-only { display: inline-flex; }
|
||||
.mdt-page__title > span:not(.mdt-page__count) { display: none; }
|
||||
.mdt-page__title-icon { display: none; }
|
||||
.mdt-page__count { display: none; }
|
||||
|
||||
/* Compacta botões de ação */
|
||||
.mdt-act-btn span { display: none; }
|
||||
.mdt-act-btn { width: 32px; padding: 0; justify-content: center; }
|
||||
.mdt-grid { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useToast } from 'primevue/usetoast';
|
||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
|
||||
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
|
||||
import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
|
||||
import MelissaPatientDocuments from '@/layout/melissa/MelissaPatientDocuments.vue';
|
||||
import PatientConversationsTab from '@/features/patients/prontuario/PatientConversationsTab.vue';
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||
import { usePatientDetail } from '@/features/patients/composables/usePatientDetail';
|
||||
@@ -2092,16 +2092,11 @@ onBeforeUnmount(() => {
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- DocumentsListPage embedded (ja vem com upload/preview/lista) -->
|
||||
<section class="mpa-w mpa-embed">
|
||||
<div class="mpa-w__body mpa-embed__body">
|
||||
<DocumentsListPage
|
||||
:patient-id="patientId"
|
||||
:patient-name="nomeCompleto || ''"
|
||||
embedded
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Documentos nativos Melissa (2-col com tipos na sidebar) -->
|
||||
<MelissaPatientDocuments
|
||||
:patient-id="patientId"
|
||||
:patient-name="nomeCompleto || ''"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — MelissaPatientDocuments
|
||||
|--------------------------------------------------------------------------
|
||||
| Página nativa Melissa pra aba "Documentos" do prontuário (substitui o
|
||||
| embed do DocumentsListPage).
|
||||
|
|
||||
| Layout 2-col (espelha MelissaDocumentosTemplates):
|
||||
| - COL 1 (esquerda): tipos de documento como sidebar com contadores
|
||||
| - COL 2 (direita): DataView dos documentos do tipo selecionado,
|
||||
| com upload/preview/edit/sign/share/delete via dialogs reaproveitados
|
||||
| - Mobile (<1024px): sidebar vira drawer (botão "Tipos" no header)
|
||||
|
|
||||
| Reaproveita do feature/documents:
|
||||
| - useDocuments composable (fetch + CRUD + URLs)
|
||||
| - DocumentCard pra item visual
|
||||
| - DocumentUploadDialog / PreviewDialog / GenerateDialog /
|
||||
| SignatureDialog / ShareDialog
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import Menu from 'primevue/menu';
|
||||
import DataView from 'primevue/dataview';
|
||||
|
||||
import { useDocuments } from '@/features/documents/composables/useDocuments';
|
||||
import DocumentCard from '@/features/documents/components/DocumentCard.vue';
|
||||
import DocumentUploadDialog from '@/features/documents/components/DocumentUploadDialog.vue';
|
||||
import DocumentPreviewDialog from '@/features/documents/components/DocumentPreviewDialog.vue';
|
||||
import DocumentGenerateDialog from '@/features/documents/components/DocumentGenerateDialog.vue';
|
||||
import DocumentSignatureDialog from '@/features/documents/components/DocumentSignatureDialog.vue';
|
||||
import DocumentShareDialog from '@/features/documents/components/DocumentShareDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
patientId: { type: String, default: null },
|
||||
patientName: { type: String, default: '' }
|
||||
});
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// ── useDocuments composable ───────────────────────────────
|
||||
const {
|
||||
documents, loading, error, filters, usedTags, stats,
|
||||
TIPOS_DOCUMENTO,
|
||||
fetchDocuments, upload, update, remove,
|
||||
download, getPreviewUrl, fetchUsedTags
|
||||
} = useDocuments(() => props.patientId);
|
||||
|
||||
// ── Dialogs ─────────────────────────────────────────────────
|
||||
const uploadDlg = ref(false);
|
||||
const previewDlg = ref(false);
|
||||
const generateDlg = ref(false);
|
||||
const signatureDlg = ref(false);
|
||||
const shareDlg = ref(false);
|
||||
const selectedDoc = ref(null);
|
||||
const previewUrl = ref('');
|
||||
|
||||
// ── Tipo selecionado (filtro pela sidebar) ────────────────
|
||||
// null = todos os tipos
|
||||
const selectedTipo = ref(null);
|
||||
|
||||
const docsByTipo = computed(() => {
|
||||
const groups = {};
|
||||
for (const d of documents.value) {
|
||||
const tipo = d.tipo_documento || 'outro';
|
||||
if (!groups[tipo]) groups[tipo] = [];
|
||||
groups[tipo].push(d);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const tipoCounts = computed(() => {
|
||||
const counts = {};
|
||||
TIPOS_DOCUMENTO.forEach(t => { counts[t.value] = (docsByTipo.value[t.value] || []).length; });
|
||||
return counts;
|
||||
});
|
||||
|
||||
const filteredDocs = computed(() => {
|
||||
if (!selectedTipo.value) return documents.value;
|
||||
return docsByTipo.value[selectedTipo.value] || [];
|
||||
});
|
||||
|
||||
function tipoLabel(tipo) {
|
||||
return TIPOS_DOCUMENTO.find(t => t.value === tipo)?.label || tipo;
|
||||
}
|
||||
|
||||
// ── Mobile drawer ─────────────────────────────────────────
|
||||
const drawerOpen = ref(false);
|
||||
const isMobile = ref(false);
|
||||
let _mqMobile = null;
|
||||
function _onMqMobileChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
if (!e.matches) drawerOpen.value = false;
|
||||
}
|
||||
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
|
||||
function fecharDrawer() { drawerOpen.value = false; }
|
||||
function selectTipo(tipo) {
|
||||
selectedTipo.value = tipo;
|
||||
if (isMobile.value) drawerOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────
|
||||
async function onUploaded({ file, meta }) {
|
||||
try {
|
||||
await upload(file, props.patientId, meta);
|
||||
toast.add({ severity: 'success', summary: 'Enviado', detail: file.name, life: 3000 });
|
||||
fetchUsedTags();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro no upload', detail: e?.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function onPreview(doc) {
|
||||
selectedDoc.value = doc;
|
||||
try { previewUrl.value = await getPreviewUrl(doc); }
|
||||
catch { previewUrl.value = ''; }
|
||||
previewDlg.value = true;
|
||||
}
|
||||
|
||||
function onDownload(doc) { download(doc); }
|
||||
|
||||
function onEdit(doc) {
|
||||
selectedDoc.value = doc;
|
||||
// Reusa preview dialog em modo "ver" — edit completo só via DocumentsListPage
|
||||
onPreview(doc);
|
||||
}
|
||||
|
||||
function onDelete(doc) {
|
||||
confirm.require({
|
||||
header: 'Excluir documento',
|
||||
message: `Excluir "${doc.nome_original}"? Esta ação pode ser revertida no Lixo.`,
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Excluir',
|
||||
rejectLabel: 'Cancelar',
|
||||
acceptSeverity: 'danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
await remove(doc.id);
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: doc.nome_original, life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao excluir', detail: e?.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onShare(doc) {
|
||||
selectedDoc.value = doc;
|
||||
shareDlg.value = true;
|
||||
}
|
||||
function onSign(doc) {
|
||||
selectedDoc.value = doc;
|
||||
signatureDlg.value = true;
|
||||
}
|
||||
function onGenerated() {
|
||||
fetchDocuments();
|
||||
fetchUsedTags();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
|
||||
catch { _mqMobile.addListener(_onMqMobileChange); }
|
||||
}
|
||||
await Promise.all([fetchDocuments(), fetchUsedTags()]);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) {
|
||||
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
|
||||
catch { _mqMobile.removeListener(_onMqMobileChange); }
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Mobile drawer (tipos) — teleportado pro body pra escapar de
|
||||
stacking contexts pais (transform/filter no MelissaPaciente
|
||||
travavam o fixed). win11-root no wrapper pra herdar os
|
||||
tokens --m-* definidos nesse escopo. -->
|
||||
<Teleport to="body">
|
||||
<div class="win11-root mpd-drawer-portal">
|
||||
<Transition name="mpd-drawer-fade">
|
||||
<div
|
||||
v-show="isMobile && drawerOpen"
|
||||
class="mpd-mobile-drawer"
|
||||
:class="{ 'is-open': drawerOpen }"
|
||||
>
|
||||
<div id="mpd-mobile-drawer-target" class="mpd-mobile-drawer__scroll" />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="mpd-drawer-fade">
|
||||
<div
|
||||
v-show="isMobile && drawerOpen"
|
||||
class="mpd-mobile-drawer__backdrop"
|
||||
@click="fecharDrawer"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<section class="mpd-page">
|
||||
<!-- ── HEADER ─────────────────────────────────────── -->
|
||||
<header class="mpd-page__head">
|
||||
<button
|
||||
class="mpd-menu-btn mpd-menu-btn--mobile-only"
|
||||
v-tooltip.bottom="'Tipos de documento'"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
<span>Tipos</span>
|
||||
</button>
|
||||
<div class="mpd-page__title">
|
||||
<i class="pi pi-file mpd-page__title-icon" />
|
||||
<span>Documentos</span>
|
||||
<span class="mpd-page__count">{{ documents.length }}</span>
|
||||
</div>
|
||||
<div class="mpd-page__actions">
|
||||
<button
|
||||
class="mpd-act-btn"
|
||||
v-tooltip.bottom="'Atualizar'"
|
||||
:disabled="loading"
|
||||
@click="fetchDocuments"
|
||||
>
|
||||
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
|
||||
</button>
|
||||
<button
|
||||
class="mpd-act-btn"
|
||||
v-tooltip.bottom="'Gerar a partir de template'"
|
||||
:disabled="!patientId"
|
||||
@click="generateDlg = true"
|
||||
>
|
||||
<i class="pi pi-file-pdf" />
|
||||
<span>Gerar</span>
|
||||
</button>
|
||||
<button
|
||||
class="mpd-act-btn mpd-act-btn--primary"
|
||||
v-tooltip.bottom="'Enviar arquivo'"
|
||||
:disabled="!patientId"
|
||||
@click="uploadDlg = true"
|
||||
>
|
||||
<i class="pi pi-upload" />
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── BODY 2-col ─────────────────────────────────── -->
|
||||
<div class="mpd-body">
|
||||
<!-- COL 1 — Sidebar de tipos (teleporta no mobile) -->
|
||||
<Teleport to="#mpd-mobile-drawer-target" :disabled="!isMobile">
|
||||
<aside class="mpd-side">
|
||||
<div class="mpd-side__head">
|
||||
<i class="pi pi-folder" />
|
||||
<span>Tipos</span>
|
||||
</div>
|
||||
<ul class="mpd-side__list">
|
||||
<li
|
||||
class="mpd-side__item"
|
||||
:class="{ 'is-active': selectedTipo === null }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="selectTipo(null)"
|
||||
@keydown.enter.prevent="selectTipo(null)"
|
||||
>
|
||||
<span class="mpd-side__item-name">Todos</span>
|
||||
<span class="mpd-side__item-count">{{ documents.length }}</span>
|
||||
</li>
|
||||
<li
|
||||
v-for="t in TIPOS_DOCUMENTO"
|
||||
:key="t.value"
|
||||
class="mpd-side__item"
|
||||
:class="{ 'is-active': selectedTipo === t.value, 'is-empty': tipoCounts[t.value] === 0 }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="selectTipo(t.value)"
|
||||
@keydown.enter.prevent="selectTipo(t.value)"
|
||||
>
|
||||
<span class="mpd-side__item-name">{{ t.label }}</span>
|
||||
<span class="mpd-side__item-count">{{ tipoCounts[t.value] || 0 }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</Teleport>
|
||||
|
||||
<!-- COL 2 — Main: lista de documentos -->
|
||||
<main class="mpd-main">
|
||||
<header class="mpd-main__head">
|
||||
<div class="mpd-main__title-row">
|
||||
<div class="mpd-main__title">
|
||||
<i class="pi pi-file" />
|
||||
<span>{{ selectedTipo ? tipoLabel(selectedTipo) : 'Todos os documentos' }}</span>
|
||||
</div>
|
||||
<span class="mpd-page__count">{{ filteredDocs.length }}</span>
|
||||
</div>
|
||||
<p class="mpd-main__subtitle">
|
||||
<template v-if="selectedTipo">
|
||||
Documentos do tipo <strong>{{ tipoLabel(selectedTipo) }}</strong> deste paciente.
|
||||
</template>
|
||||
<template v-else>
|
||||
Todos os documentos clínicos vinculados a este paciente.
|
||||
</template>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading && !documents.length" class="mpd-loading">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
<span>Carregando documentos…</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty global -->
|
||||
<div v-else-if="!documents.length" class="mpd-empty">
|
||||
<i class="pi pi-folder-open mpd-empty__icon" />
|
||||
<div class="mpd-empty__title">Nenhum documento ainda</div>
|
||||
<div class="mpd-empty__hint">
|
||||
Faça upload de um arquivo ou gere um documento a partir de template.
|
||||
</div>
|
||||
<div class="mpd-empty__actions">
|
||||
<button class="mpd-act-btn" @click="generateDlg = true">
|
||||
<i class="pi pi-file-pdf" /><span>Gerar template</span>
|
||||
</button>
|
||||
<button class="mpd-act-btn mpd-act-btn--primary" @click="uploadDlg = true">
|
||||
<i class="pi pi-upload" /><span>Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty (filtrado) -->
|
||||
<div v-else-if="!filteredDocs.length" class="mpd-empty">
|
||||
<i class="pi pi-filter mpd-empty__icon" />
|
||||
<div class="mpd-empty__title">Nenhum {{ tipoLabel(selectedTipo).toLowerCase() }} ainda</div>
|
||||
<div class="mpd-empty__hint">
|
||||
Outros tipos têm documentos —
|
||||
<button class="mpd-link" @click="selectTipo(null)">ver todos</button>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DataView -->
|
||||
<DataView
|
||||
v-else
|
||||
:value="filteredDocs"
|
||||
layout="grid"
|
||||
:paginator="filteredDocs.length > 12"
|
||||
:rows="12"
|
||||
class="mpd-dataview"
|
||||
>
|
||||
<template #grid="slotProps">
|
||||
<div class="mpd-grid">
|
||||
<DocumentCard
|
||||
v-for="doc in slotProps.items"
|
||||
:key="doc.id"
|
||||
:doc="doc"
|
||||
@preview="onPreview"
|
||||
@download="onDownload"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@share="onShare"
|
||||
@sign="onSign"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DataView>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ── Dialogs reaproveitados ─────────────────────── -->
|
||||
<DocumentUploadDialog
|
||||
v-model:visible="uploadDlg"
|
||||
:patient-id="patientId"
|
||||
@uploaded="onUploaded"
|
||||
/>
|
||||
<DocumentPreviewDialog
|
||||
v-model:visible="previewDlg"
|
||||
:doc="selectedDoc"
|
||||
:preview-url="previewUrl"
|
||||
@updated="fetchDocuments"
|
||||
/>
|
||||
<DocumentGenerateDialog
|
||||
v-if="patientId"
|
||||
v-model:visible="generateDlg"
|
||||
:patient-id="patientId"
|
||||
:patient-name="patientName"
|
||||
@generated="onGenerated"
|
||||
/>
|
||||
<DocumentSignatureDialog
|
||||
:visible="signatureDlg"
|
||||
@update:visible="signatureDlg = $event"
|
||||
:doc="selectedDoc"
|
||||
/>
|
||||
<DocumentShareDialog
|
||||
v-model:visible="shareDlg"
|
||||
:doc="selectedDoc"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ═══════ Page chrome ═══════ */
|
||||
.mpd-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
color: var(--m-text);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.mpd-page__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.mpd-page__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mpd-page__title-icon { color: var(--p-primary-color); font-size: 1rem; }
|
||||
.mpd-page__count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-accent);
|
||||
background: var(--m-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.mpd-page__actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
.mpd-act-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 140ms ease;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
}
|
||||
.mpd-act-btn:hover:not(:disabled) { background: var(--m-bg-soft-hover); }
|
||||
.mpd-act-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.mpd-act-btn--primary {
|
||||
background: transparent;
|
||||
border-color: var(--p-primary-color);
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
.mpd-act-btn--primary:hover:not(:disabled) { background: color-mix(in srgb, var(--p-primary-color) 10%, transparent); }
|
||||
.mpd-act-btn > i { font-size: 0.78rem; }
|
||||
|
||||
.mpd-menu-btn {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--m-border);
|
||||
background: var(--m-bg-soft);
|
||||
color: var(--m-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mpd-menu-btn:hover { background: var(--m-bg-soft-hover); }
|
||||
|
||||
/* ═══════ Body 2-col ═══════ */
|
||||
.mpd-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 260px) 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* COL 1 — Sidebar */
|
||||
.mpd-side {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mpd-side__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mpd-side__head > i { color: var(--m-text-muted); font-size: 0.9rem; }
|
||||
.mpd-side__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.mpd-side__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.mpd-side__item:hover,
|
||||
.mpd-side__item:focus-visible {
|
||||
background: var(--m-bg-soft-hover);
|
||||
outline: none;
|
||||
}
|
||||
.mpd-side__item.is-active {
|
||||
background: color-mix(in srgb, var(--p-primary-color) 12%, var(--m-bg-medium));
|
||||
color: var(--p-primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.mpd-side__item.is-empty { opacity: 0.5; }
|
||||
.mpd-side__item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mpd-side__item-count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--m-text-muted);
|
||||
background: var(--m-bg-medium);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mpd-side__item.is-active .mpd-side__item-count {
|
||||
color: var(--p-primary-color);
|
||||
background: color-mix(in srgb, var(--p-primary-color) 18%, transparent);
|
||||
}
|
||||
|
||||
/* COL 2 — Main */
|
||||
.mpd-main {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mpd-main__head {
|
||||
padding: 12px 14px 8px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mpd-main__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.mpd-main__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mpd-main__title > i { color: var(--p-primary-color); font-size: 0.9rem; }
|
||||
.mpd-main__subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--m-text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mpd-loading,
|
||||
.mpd-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 28px;
|
||||
text-align: center;
|
||||
color: var(--m-text-muted);
|
||||
gap: 8px;
|
||||
}
|
||||
.mpd-loading > i { font-size: 1.4rem; color: var(--p-primary-color); }
|
||||
.mpd-empty__icon { font-size: 2.4rem; color: var(--m-text-faint); margin-bottom: 6px; }
|
||||
.mpd-empty__title { font-size: 0.95rem; font-weight: 600; color: var(--m-text); }
|
||||
.mpd-empty__hint { font-size: 0.82rem; max-width: 360px; line-height: 1.5; }
|
||||
.mpd-empty__actions { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.mpd-link {
|
||||
background: none; border: none; color: var(--p-primary-color);
|
||||
cursor: pointer; padding: 0; font: inherit; text-decoration: underline;
|
||||
}
|
||||
|
||||
.mpd-dataview {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
.mpd-dataview :deep(.p-dataview-content) {
|
||||
flex: 1; min-height: 0; overflow-y: auto;
|
||||
background: transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.mpd-dataview :deep(.p-dataview-paginator-bottom) {
|
||||
flex-shrink: 0; background: transparent;
|
||||
border-top: 1px solid var(--m-border);
|
||||
}
|
||||
.mpd-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Drawer styles foram movidos pro <style> não-scoped abaixo —
|
||||
teleporte pro body perde o atributo data-v scoped */
|
||||
|
||||
/* ═══════ Mobile (<1024px) ═══════ */
|
||||
@media (max-width: 1023px) {
|
||||
.mpd-body {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mpd-body > .mpd-side { display: none; }
|
||||
.mpd-menu-btn--mobile-only { display: inline-flex; }
|
||||
.mpd-page__title > span:first-of-type { display: none; }
|
||||
.mpd-page__title-icon { display: none; }
|
||||
.mpd-act-btn span { display: none; }
|
||||
.mpd-act-btn { width: 32px; padding: 0; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Styles do drawer mobile NÃO-scoped — o <Teleport to="body"> tira o
|
||||
elemento da árvore do componente, então perde os atributos data-v
|
||||
do scoped. O wrapper .mpd-drawer-portal carrega .win11-root pra
|
||||
herdar os tokens --m-* (definidos em MelissaLayout). -->
|
||||
<style>
|
||||
/* Wrapper inerte — não cria stacking, só serve como host das vars */
|
||||
.mpd-drawer-portal {
|
||||
/* Garante que mesmo sem .win11-root (improvável) caia em fallbacks
|
||||
de PrimeVue tokens, que existem globalmente e respeitam dark/light. */
|
||||
--mpd-bg: var(--m-bg-medium, var(--p-content-background, #ffffff));
|
||||
--mpd-border: var(--m-border, var(--p-content-border-color, rgba(0,0,0,0.1)));
|
||||
--mpd-text: var(--m-text, var(--p-text-color, #1a1a1a));
|
||||
}
|
||||
.mpd-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(320px, 86vw);
|
||||
z-index: 80;
|
||||
background: var(--mpd-bg);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border-right: 1px solid var(--mpd-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--mpd-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.mpd-mobile-drawer.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.mpd-mobile-drawer:not(.is-open) {
|
||||
pointer-events: none;
|
||||
}
|
||||
.mpd-mobile-drawer__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Sidebar teleportada herda altura plena */
|
||||
.mpd-mobile-drawer__scroll .mpd-side {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--mpd-border);
|
||||
}
|
||||
.mpd-mobile-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 79;
|
||||
}
|
||||
.mpd-drawer-fade-enter-active,
|
||||
.mpd-drawer-fade-leave-active {
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
.mpd-drawer-fade-enter-from,
|
||||
.mpd-drawer-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -31,7 +31,30 @@ const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const router = useRouter();
|
||||
const tenantStore = useTenantStore();
|
||||
const { layoutConfig, setVariant } = useLayout();
|
||||
const { layoutConfig, setVariant, isDarkTheme, toggleDarkMode } = useLayout();
|
||||
|
||||
// Opções do CHECK constraint da migration 20260521000003 (CFP #5)
|
||||
const REGISTRATION_TYPE_OPTIONS = [
|
||||
{ value: '', label: '— Não informado —' },
|
||||
{ value: 'CRP', label: 'CRP — Psicólogo(a)' },
|
||||
{ value: 'CRM', label: 'CRM — Médico(a)' },
|
||||
{ value: 'CRFa', label: 'CRFa — Fonoaudiólogo(a)' },
|
||||
{ value: 'CREFITO', label: 'CREFITO — Fisioterapeuta / T.O.' },
|
||||
{ value: 'CRESS', label: 'CRESS — Assistente Social' },
|
||||
{ value: 'CRN', label: 'CRN — Nutricionista' },
|
||||
{ value: 'RMS', label: 'RMS — Residência Multiprofissional' },
|
||||
{ value: 'outro', label: 'Outro' }
|
||||
];
|
||||
|
||||
const UF_OPTIONS = [
|
||||
'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'
|
||||
].map(uf => ({ value: uf, label: uf }));
|
||||
|
||||
function goSeguranca() {
|
||||
// Página nativa Melissa — não vaza pra layout clássico
|
||||
router.push('/melissa/seguranca');
|
||||
}
|
||||
|
||||
// Troca de layout variant (classic/rail/melissa). Confirma + persiste +
|
||||
// hard reload — sair do shell Melissa requer reload pq AppLayout não tem
|
||||
@@ -134,7 +157,12 @@ const form = reactive({
|
||||
social_instagram: '',
|
||||
social_youtube: '',
|
||||
social_facebook: '',
|
||||
social_x: ''
|
||||
social_x: '',
|
||||
// Registro profissional (CFP #5 — exigido pra emissão de recibos/laudos)
|
||||
professional_registration_type: '',
|
||||
professional_registration_type_other: '',
|
||||
professional_registration_number: '',
|
||||
professional_registration_uf: ''
|
||||
});
|
||||
|
||||
const customSocials = ref([]);
|
||||
@@ -345,7 +373,7 @@ async function loadProfile() {
|
||||
const { data: prof, error: pErr } = await supabase
|
||||
.from('profiles')
|
||||
.select(
|
||||
'role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom'
|
||||
'role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, professional_registration_type, professional_registration_type_other, professional_registration_number, professional_registration_uf'
|
||||
)
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
@@ -364,6 +392,10 @@ async function loadProfile() {
|
||||
form.social_youtube = prof.social_youtube ?? '';
|
||||
form.social_facebook = prof.social_facebook ?? '';
|
||||
form.social_x = prof.social_x ?? '';
|
||||
form.professional_registration_type = prof.professional_registration_type ?? '';
|
||||
form.professional_registration_type_other = prof.professional_registration_type_other ?? '';
|
||||
form.professional_registration_number = prof.professional_registration_number ?? '';
|
||||
form.professional_registration_uf = prof.professional_registration_uf ?? '';
|
||||
customSocials.value = Array.isArray(prof.social_custom) ? prof.social_custom : [];
|
||||
ui.avatarPreview = form.avatar_url;
|
||||
}
|
||||
@@ -430,7 +462,16 @@ async function saveAll() {
|
||||
social_youtube: String(form.social_youtube || '').trim() || null,
|
||||
social_facebook: String(form.social_facebook || '').trim() || null,
|
||||
social_x: String(form.social_x || '').trim() || null,
|
||||
social_custom: customSocials.value.filter((s) => s.name || s.url)
|
||||
social_custom: customSocials.value.filter((s) => s.name || s.url),
|
||||
// Registro profissional (CFP) — null se vazio
|
||||
professional_registration_type: String(form.professional_registration_type || '').trim() || null,
|
||||
// type_other só preenchido quando type === 'outro' (limpa quando muda)
|
||||
professional_registration_type_other:
|
||||
form.professional_registration_type === 'outro'
|
||||
? (String(form.professional_registration_type_other || '').trim() || null)
|
||||
: null,
|
||||
professional_registration_number: String(form.professional_registration_number || '').trim() || null,
|
||||
professional_registration_uf: String(form.professional_registration_uf || '').trim() || null
|
||||
};
|
||||
|
||||
const { data: updatedProfile, error: pErr2 } = await supabase
|
||||
@@ -833,6 +874,94 @@ onBeforeUnmount(() => {
|
||||
</div><!-- /.mpr-w__body -->
|
||||
</div>
|
||||
|
||||
<!-- ── Registro Profissional (CFP #5) ── -->
|
||||
<div id="mpr-sec-registro" class="mpr-w">
|
||||
<div class="mpr-w__head">
|
||||
<div class="mpr-w__icon"><i class="pi pi-id-card" /></div>
|
||||
<div class="mpr-w__title">
|
||||
<div class="mpr-w__title-text">Registro profissional</div>
|
||||
<div class="mpr-w__sub">Conselho regional — exigido para emissão de recibos, atestados e laudos</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mpr-w__body">
|
||||
<div class="mpr-grid">
|
||||
<div class="mpr-field mpr-field--half">
|
||||
<FloatLabel variant="on">
|
||||
<Select
|
||||
id="mpr_reg_type"
|
||||
v-model="form.professional_registration_type"
|
||||
:options="REGISTRATION_TYPE_OPTIONS"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
@change="markDirty"
|
||||
/>
|
||||
<label for="mpr_reg_type">Tipo de registro</label>
|
||||
</FloatLabel>
|
||||
<small class="mpr-hint">Conselho profissional ao qual você é vinculado.</small>
|
||||
</div>
|
||||
|
||||
<!-- Campo livre quando tipo='outro' -->
|
||||
<div v-if="form.professional_registration_type === 'outro'" class="mpr-field mpr-field--half">
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
id="mpr_reg_type_other"
|
||||
v-model="form.professional_registration_type_other"
|
||||
class="w-full"
|
||||
@input="markDirty"
|
||||
/>
|
||||
<label for="mpr_reg_type_other">Nome do conselho/instituição *</label>
|
||||
</FloatLabel>
|
||||
<small class="mpr-hint">Ex: APM, ABRAP, etc.</small>
|
||||
</div>
|
||||
|
||||
<div class="mpr-field mpr-field--half">
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
id="mpr_reg_number"
|
||||
v-model="form.professional_registration_number"
|
||||
class="w-full"
|
||||
:disabled="!form.professional_registration_type"
|
||||
@input="markDirty"
|
||||
/>
|
||||
<label for="mpr_reg_number">Número do registro</label>
|
||||
</FloatLabel>
|
||||
<small class="mpr-hint">Ex: 06/12345</small>
|
||||
</div>
|
||||
|
||||
<div class="mpr-field mpr-field--half">
|
||||
<FloatLabel variant="on">
|
||||
<Select
|
||||
id="mpr_reg_uf"
|
||||
v-model="form.professional_registration_uf"
|
||||
:options="UF_OPTIONS"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:disabled="!form.professional_registration_type"
|
||||
:filter="true"
|
||||
class="w-full"
|
||||
@change="markDirty"
|
||||
/>
|
||||
<label for="mpr_reg_uf">UF</label>
|
||||
</FloatLabel>
|
||||
<small class="mpr-hint">Estado do conselho.</small>
|
||||
</div>
|
||||
|
||||
<div v-if="form.professional_registration_type && form.professional_registration_number" class="mpr-field mpr-field--full">
|
||||
<div class="mpr-preview-box">
|
||||
<span class="mpr-preview-label">Aparecerá nos documentos como:</span>
|
||||
<strong class="mpr-preview-value">
|
||||
{{ form.professional_registration_type === 'outro'
|
||||
? (form.professional_registration_type_other || 'Conselho não informado')
|
||||
: form.professional_registration_type }}
|
||||
{{ form.professional_registration_number }}{{ form.professional_registration_uf ? '/' + form.professional_registration_uf : '' }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.mpr-w__body -->
|
||||
</div>
|
||||
|
||||
<!-- ── Contato ── -->
|
||||
<div id="mpr-sec-contato" class="mpr-w">
|
||||
<div class="mpr-w__head">
|
||||
@@ -1041,7 +1170,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div>
|
||||
<div class="mpr-lv-name">Rail</div>
|
||||
<div class="mpr-lv-sub">Mini rail + painel expansível, full-width</div>
|
||||
<div class="mpr-lv-sub">Ícones no canto esquerdo + painel expansível. Disponível apenas no desktop.</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -1069,6 +1198,91 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div><!-- /.mpr-w__body -->
|
||||
</div>
|
||||
|
||||
<!-- ── Preferências (tema + aparência) ── -->
|
||||
<div id="mpr-sec-preferencias" class="mpr-w">
|
||||
<div class="mpr-w__head">
|
||||
<div class="mpr-w__icon"><i class="pi pi-palette" /></div>
|
||||
<div class="mpr-w__title">
|
||||
<div class="mpr-w__title-text">Preferências</div>
|
||||
<div class="mpr-w__sub">Aparência do sistema</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mpr-w__body">
|
||||
<div class="mpr-grid">
|
||||
<div class="mpr-field mpr-field--full">
|
||||
<label class="mpr-label">Tema</label>
|
||||
<div class="mpr-theme-row">
|
||||
<button
|
||||
type="button"
|
||||
class="mpr-lv-card mpr-theme-card"
|
||||
:class="{ 'mpr-lv-card--current': !isDarkTheme }"
|
||||
@click="isDarkTheme && toggleDarkMode()"
|
||||
>
|
||||
<i class="pi pi-sun mpr-theme-icon" style="color: #f59e0b;" />
|
||||
<div class="mpr-theme-text">
|
||||
<div class="mpr-lv-name">Claro</div>
|
||||
<div class="mpr-lv-sub">Fundo branco</div>
|
||||
</div>
|
||||
<div class="mpr-lv-radio">
|
||||
<div v-if="!isDarkTheme" class="mpr-lv-dot" />
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mpr-lv-card mpr-theme-card"
|
||||
:class="{ 'mpr-lv-card--current': isDarkTheme }"
|
||||
@click="!isDarkTheme && toggleDarkMode()"
|
||||
>
|
||||
<i class="pi pi-moon mpr-theme-icon" style="color: #6366f1;" />
|
||||
<div class="mpr-theme-text">
|
||||
<div class="mpr-lv-name">Escuro</div>
|
||||
<div class="mpr-lv-sub">Menos fadiga visual</div>
|
||||
</div>
|
||||
<div class="mpr-lv-radio">
|
||||
<div v-if="isDarkTheme" class="mpr-lv-dot" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<small class="mpr-hint">A preferência é salva no seu perfil.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.mpr-w__body -->
|
||||
</div>
|
||||
|
||||
<!-- ── Segurança ── -->
|
||||
<div id="mpr-sec-seguranca" class="mpr-w">
|
||||
<div class="mpr-w__head">
|
||||
<div class="mpr-w__icon"><i class="pi pi-shield" /></div>
|
||||
<div class="mpr-w__title">
|
||||
<div class="mpr-w__title-text">Segurança</div>
|
||||
<div class="mpr-w__sub">Senha e proteção da conta</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mpr-w__body">
|
||||
<div class="mpr-grid">
|
||||
<div class="mpr-field mpr-field--full">
|
||||
<div class="mpr-info-row">
|
||||
<div class="mpr-info-text">
|
||||
<div class="mpr-info-title">E-mail de acesso</div>
|
||||
<div class="mpr-info-value">{{ userEmail }}</div>
|
||||
</div>
|
||||
<small class="mpr-hint mpr-info-hint">Para trocar o e-mail, contate o suporte.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mpr-field mpr-field--full">
|
||||
<button type="button" class="mpr-lv-card mpr-action-card" @click="goSeguranca">
|
||||
<i class="pi pi-key mpr-action-icon" />
|
||||
<div class="mpr-action-text">
|
||||
<div class="mpr-lv-name">Trocar senha</div>
|
||||
<div class="mpr-lv-sub">Atualize sua senha de acesso ao sistema</div>
|
||||
</div>
|
||||
<i class="pi pi-arrow-right mpr-action-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.mpr-w__body -->
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1943,4 +2157,101 @@ onBeforeUnmount(() => {
|
||||
line-height: 1.3;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ═══════════ Registro Profissional — preview box ═══════════ */
|
||||
.mpr-preview-box {
|
||||
background: color-mix(in srgb, var(--p-primary-color) 7%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--p-primary-color) 22%, var(--surface-border));
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 0.88rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mpr-preview-label {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.mpr-preview-value {
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* ═══════════ Preferências — tema em 1 linha ═══════════ */
|
||||
.mpr-theme-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.mpr-theme-card {
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.mpr-theme-icon {
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mpr-theme-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ═══════════ Segurança — info row + action card ═══════════ */
|
||||
.mpr-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-ground, transparent);
|
||||
}
|
||||
.mpr-info-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.mpr-info-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.mpr-info-value {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.mpr-info-hint {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mpr-action-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mpr-action-icon {
|
||||
font-size: 1.4rem;
|
||||
color: var(--text-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mpr-action-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.mpr-action-arrow {
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -79,6 +79,15 @@ export const MELISSA_CONFIG_GRUPOS = [
|
||||
{ key: 'cfg-email-templates', label: 'Templates de E-mail', desc: 'Personalize os e-mails enviados aos pacientes.', icon: 'pi pi-envelope' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'documentos',
|
||||
label: 'Documentos',
|
||||
desc: 'Modelos, assinaturas e configurações da geração de documentos.',
|
||||
icon: 'pi pi-file',
|
||||
items: [
|
||||
{ key: 'documentos-templates', label: 'Modelos de documentos', desc: 'Cadastre e edite templates de recibos, atestados, laudos, TCLE, LGPD e mais.', icon: 'pi pi-file-edit' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'plataforma',
|
||||
label: 'Plataforma',
|
||||
|
||||
@@ -73,8 +73,8 @@ export default function adminMenu(ctx = {}) {
|
||||
{ label: 'Tags', icon: 'pi pi-fw pi-tags', to: { name: 'admin-pacientes-tags' } },
|
||||
{ label: 'Médicos & Referências', icon: 'pi pi-fw pi-heart', to: { name: 'admin-pacientes-medicos' } },
|
||||
{ label: 'Link Externo', icon: 'pi pi-fw pi-link', to: { name: 'admin-pacientes-link-externo' } },
|
||||
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' }, badgeKey: 'cadastrosRecebidos' },
|
||||
{ label: 'Templates de Documentos', icon: 'pi pi-fw pi-file-edit', to: { name: 'admin-documents-templates' }, feature: 'documents.templates', proBadge: true }
|
||||
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' }, badgeKey: 'cadastrosRecebidos' }
|
||||
// "Templates de Documentos" movido pra Configurações
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export default [
|
||||
{ label: 'Tags', icon: 'pi pi-tags', to: '/therapist/patients/tags' },
|
||||
{ label: 'Médicos & Referências', icon: 'pi pi-heart', to: '/therapist/patients/medicos' },
|
||||
{ label: 'Documentos', icon: 'pi pi-file', to: '/therapist/documents', feature: 'documents.upload' },
|
||||
{ label: 'Templates', icon: 'pi pi-file-edit', to: '/therapist/documents/templates', feature: 'documents.templates', proBadge: true },
|
||||
// "Templates" movido pra /configuracoes/documentos/templates — agora vive em Configurações
|
||||
{ label: 'Meu link de cadastro', icon: 'pi pi-link', to: '/therapist/patients/link-externo' },
|
||||
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos', badgeKey: 'cadastrosRecebidos' }
|
||||
]
|
||||
|
||||
@@ -161,12 +161,8 @@ export default {
|
||||
// ======================================================
|
||||
// 📄 DOCUMENTOS
|
||||
// ======================================================
|
||||
{
|
||||
path: 'documents/templates',
|
||||
name: 'admin-documents-templates',
|
||||
component: () => import('@/features/documents/DocumentTemplatesPage.vue'),
|
||||
meta: { feature: 'documents.templates' }
|
||||
},
|
||||
// /documents/templates removido — mudou pra /configuracoes/documentos/templates
|
||||
// (setup/config, não operação clínica).
|
||||
|
||||
// ======================================================
|
||||
// 🔐 SEGURANÇA
|
||||
|
||||
@@ -167,6 +167,17 @@ export default {
|
||||
path: 'creditos-whatsapp',
|
||||
name: 'ConfiguracoesCreditosWhatsapp',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesCreditosWhatsappPage.vue')
|
||||
},
|
||||
|
||||
// ── Documentos & Templates ────────────────────────────────────
|
||||
// Templates de documento (recibo, atestado, laudo, TCLE, LGPD etc).
|
||||
// Antes vivia em /therapist/documents/templates — movido pra
|
||||
// Configurações por ser setup, não operação clínica.
|
||||
{
|
||||
path: 'documentos/templates',
|
||||
name: 'ConfiguracoesDocumentosTemplates',
|
||||
component: () => import('@/features/documents/DocumentTemplatesPage.vue'),
|
||||
meta: { feature: 'documents.templates' }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -147,12 +147,8 @@ export default {
|
||||
component: () => import('@/features/documents/DocumentsListPage.vue'),
|
||||
meta: { feature: 'documents.upload' }
|
||||
},
|
||||
{
|
||||
path: 'documents/templates',
|
||||
name: 'therapist-documents-templates',
|
||||
component: () => import('@/features/documents/DocumentTemplatesPage.vue'),
|
||||
meta: { feature: 'documents.templates' }
|
||||
},
|
||||
// /documents/templates removido — mudou pra /configuracoes/documentos/templates
|
||||
// (setup/config, não operação clínica).
|
||||
{
|
||||
path: 'patients/:id/documents',
|
||||
name: 'therapist-patient-documents',
|
||||
|
||||
@@ -148,7 +148,7 @@ export async function loadTherapistData() {
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('full_name, phone, professional_registration_type, professional_registration_number, professional_registration_uf')
|
||||
.select('full_name, phone, professional_registration_type, professional_registration_type_other, professional_registration_number, professional_registration_uf')
|
||||
.eq('id', ownerId)
|
||||
.single();
|
||||
|
||||
@@ -156,7 +156,10 @@ export async function loadTherapistData() {
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
const email = userData?.user?.email || '';
|
||||
|
||||
const tipo = profile?.professional_registration_type || '';
|
||||
const tipoRaw = profile?.professional_registration_type || '';
|
||||
const tipoOther = profile?.professional_registration_type_other || '';
|
||||
// Quando type='outro', usa o nome livre do conselho/instituição
|
||||
const tipo = tipoRaw === 'outro' ? tipoOther : tipoRaw;
|
||||
const numero = profile?.professional_registration_number || '';
|
||||
const uf = profile?.professional_registration_uf || '';
|
||||
const registro = formatRegistroProfissional({ tipo, numero, uf });
|
||||
@@ -173,7 +176,7 @@ export async function loadTherapistData() {
|
||||
// o número/UF (sem prefixo) pra não duplicar com o "CRP" já no HTML.
|
||||
// Quando o registro não é CRP, retorna vazio (template visualmente errado
|
||||
// pede pra usar {{terapeuta_registro}}).
|
||||
terapeuta_crp: tipo === 'CRP' ? (uf ? `${numero}/${uf}` : numero) : ''
|
||||
terapeuta_crp: tipoRaw === 'CRP' ? (uf ? `${numero}/${uf}` : numero) : ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,23 +243,43 @@ export async function loadAllVariables(patientId, agendaEventoId = null, extras
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
const [patient, session, therapist, clinic] = await Promise.all([
|
||||
// Promise.allSettled pra nao mascarar falha individual: se uma source
|
||||
// falhar, as outras ainda preenchem e a gente loga qual quebrou.
|
||||
const results = await Promise.allSettled([
|
||||
loadPatientData(patientId),
|
||||
loadSessionData(agendaEventoId),
|
||||
loadTherapistData(),
|
||||
loadClinicData(tenantId)
|
||||
]);
|
||||
const labels = ['patient', 'session', 'therapist', 'clinic'];
|
||||
const errors = [];
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
errors.push({ source: labels[i], err: r.reason });
|
||||
console.error(`[loadAllVariables] falha em ${labels[i]}:`, r.reason);
|
||||
}
|
||||
});
|
||||
const [patient, session, therapist, clinic] = results.map(r => r.status === 'fulfilled' ? r.value : {});
|
||||
if (import.meta?.env?.DEV) {
|
||||
console.log('[loadAllVariables] resultados:', {
|
||||
patient, session, therapist, clinic,
|
||||
ownerId, tenantId, patientId, agendaEventoId,
|
||||
errors
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve valor numérico (extras tem prioridade sobre session)
|
||||
// Resolve valor numérico (extras tem prioridade sobre session).
|
||||
// Number.isFinite (strict) em vez de isFinite global: este último coerce
|
||||
// null pra 0 e retorna true, fazendo null.toFixed crashar logo abaixo.
|
||||
const valorNum = extras.valor != null
|
||||
? Number(extras.valor)
|
||||
: (session.valor ? Number(String(session.valor).replace(/[R$\s.]/g, '').replace(',', '.')) : null);
|
||||
|
||||
const valorFormatted = isFinite(valorNum)
|
||||
const valorFormatted = Number.isFinite(valorNum)
|
||||
? `R$ ${valorNum.toFixed(2).replace('.', ',')}`
|
||||
: (session.valor || '');
|
||||
|
||||
const valorExtensoStr = isFinite(valorNum) ? valorExtenso(valorNum) : '';
|
||||
const valorExtensoStr = Number.isFinite(valorNum) ? valorExtenso(valorNum) : '';
|
||||
|
||||
const merged = {
|
||||
...patient,
|
||||
|
||||
@@ -44,49 +44,56 @@ async function getActiveTenantId(uid) {
|
||||
|
||||
/**
|
||||
* Variaveis que podem ser usadas nos templates.
|
||||
* Cada variavel tem: key, label (pt-BR), grupo.
|
||||
* - key: chave usada em {{variavel}} no HTML do template
|
||||
* - label: rótulo amigável (pt-BR)
|
||||
* - grupo: agrupamento visual no editor
|
||||
* - source: descrição de ONDE o dado é cadastrado (pra exibir como
|
||||
* hint no dialog "Gerar documento" quando o campo vier vazio).
|
||||
* É só texto explicativo — o map real de carregamento vive em
|
||||
* DocumentGenerate.service.js (loadPatientData / loadTherapistData /
|
||||
* loadClinicData / loadSessionData).
|
||||
*/
|
||||
export const TEMPLATE_VARIABLES = [
|
||||
// Paciente
|
||||
{ key: 'paciente_nome', label: 'Nome do paciente', grupo: 'Paciente' },
|
||||
{ key: 'paciente_nome_social', label: 'Nome social', grupo: 'Paciente' },
|
||||
{ key: 'paciente_cpf', label: 'CPF do paciente', grupo: 'Paciente' },
|
||||
{ key: 'paciente_data_nascimento', label: 'Data de nascimento', grupo: 'Paciente' },
|
||||
{ key: 'paciente_telefone', label: 'Telefone do paciente', grupo: 'Paciente' },
|
||||
{ key: 'paciente_email', label: 'E-mail do paciente', grupo: 'Paciente' },
|
||||
{ key: 'paciente_endereco', label: 'Endereço do paciente', grupo: 'Paciente' },
|
||||
// Paciente — fonte: tabela patients
|
||||
{ key: 'paciente_nome', label: 'Nome do paciente', grupo: 'Paciente', source: 'Paciente → nome completo' },
|
||||
{ key: 'paciente_nome_social', label: 'Nome social', grupo: 'Paciente', source: 'Paciente → nome social' },
|
||||
{ key: 'paciente_cpf', label: 'CPF do paciente', grupo: 'Paciente', source: 'Paciente → CPF' },
|
||||
{ key: 'paciente_data_nascimento', label: 'Data de nascimento', grupo: 'Paciente', source: 'Paciente → data de nascimento' },
|
||||
{ key: 'paciente_telefone', label: 'Telefone do paciente', grupo: 'Paciente', source: 'Paciente → telefone' },
|
||||
{ key: 'paciente_email', label: 'E-mail do paciente', grupo: 'Paciente', source: 'Paciente → e-mail principal' },
|
||||
{ key: 'paciente_endereco', label: 'Endereço do paciente', grupo: 'Paciente', source: 'Paciente → endereço/número/bairro/cidade/UF' },
|
||||
|
||||
// Sessao
|
||||
{ key: 'data_sessao', label: 'Data da sessão', grupo: 'Sessão' },
|
||||
{ key: 'hora_inicio', label: 'Hora início', grupo: 'Sessão' },
|
||||
{ key: 'hora_fim', label: 'Hora fim', grupo: 'Sessão' },
|
||||
{ key: 'modalidade', label: 'Modalidade (presencial/online)', grupo: 'Sessão' },
|
||||
// Sessao — fonte: agenda_eventos (só preenche se houver sessao vinculada)
|
||||
{ key: 'data_sessao', label: 'Data da sessão', grupo: 'Sessão', source: 'Agenda → sessão selecionada (data de início)' },
|
||||
{ key: 'hora_inicio', label: 'Hora início', grupo: 'Sessão', source: 'Agenda → sessão selecionada (hora de início)' },
|
||||
{ key: 'hora_fim', label: 'Hora fim', grupo: 'Sessão', source: 'Agenda → sessão selecionada (hora de fim)' },
|
||||
{ key: 'modalidade', label: 'Modalidade (presencial/online)', grupo: 'Sessão', source: 'Agenda → sessão selecionada (modalidade)' },
|
||||
|
||||
// Terapeuta
|
||||
{ key: 'terapeuta_nome', label: 'Nome do terapeuta', grupo: 'Terapeuta' },
|
||||
{ key: 'terapeuta_registro', label: 'Registro profissional (ex: CRP 12345/SP)', grupo: 'Terapeuta' },
|
||||
{ key: 'terapeuta_registro_tipo', label: 'Tipo do registro (CRP/CRM/CRFa…)', grupo: 'Terapeuta' },
|
||||
{ key: 'terapeuta_registro_numero', label: 'Número do registro', grupo: 'Terapeuta' },
|
||||
{ key: 'terapeuta_registro_uf', label: 'UF do registro', grupo: 'Terapeuta' },
|
||||
{ key: 'terapeuta_crp', label: 'CRP (legacy — prefira terapeuta_registro)', grupo: 'Terapeuta' },
|
||||
{ key: 'terapeuta_email', label: 'E-mail do terapeuta', grupo: 'Terapeuta' },
|
||||
{ key: 'terapeuta_telefone', label: 'Telefone do terapeuta', grupo: 'Terapeuta' },
|
||||
// Terapeuta — fonte: profiles (usuário logado) + auth.users
|
||||
{ key: 'terapeuta_nome', label: 'Nome do terapeuta', grupo: 'Terapeuta', source: 'Perfil → nome completo' },
|
||||
{ key: 'terapeuta_registro', label: 'Registro profissional (ex: CRP 12345/SP)', grupo: 'Terapeuta', source: 'Perfil → Registro Profissional (tipo + número/UF)' },
|
||||
{ key: 'terapeuta_registro_tipo', label: 'Tipo do registro (CRP/CRM/CRFa…)', grupo: 'Terapeuta', source: 'Perfil → Registro Profissional → tipo' },
|
||||
{ key: 'terapeuta_registro_numero', label: 'Número do registro', grupo: 'Terapeuta', source: 'Perfil → Registro Profissional → número' },
|
||||
{ key: 'terapeuta_registro_uf', label: 'UF do registro', grupo: 'Terapeuta', source: 'Perfil → Registro Profissional → UF' },
|
||||
{ key: 'terapeuta_crp', label: 'CRP (legacy — prefira terapeuta_registro)', grupo: 'Terapeuta', source: 'Perfil → só preenche se o tipo for "CRP"' },
|
||||
{ key: 'terapeuta_email', label: 'E-mail do terapeuta', grupo: 'Terapeuta', source: 'Conta → e-mail de login' },
|
||||
{ key: 'terapeuta_telefone', label: 'Telefone do terapeuta', grupo: 'Terapeuta', source: 'Perfil → telefone' },
|
||||
|
||||
// Clinica
|
||||
{ key: 'clinica_nome', label: 'Nome da clínica', grupo: 'Clínica' },
|
||||
{ key: 'clinica_endereco', label: 'Endereço da clínica', grupo: 'Clínica' },
|
||||
{ key: 'clinica_telefone', label: 'Telefone da clínica', grupo: 'Clínica' },
|
||||
{ key: 'clinica_cnpj', label: 'CNPJ da clínica', grupo: 'Clínica' },
|
||||
// Clinica — fonte: tabela tenants (clinica ativa do usuário)
|
||||
{ key: 'clinica_nome', label: 'Nome da clínica', grupo: 'Clínica', source: 'Configurações → Clínica → nome' },
|
||||
{ key: 'clinica_endereco', label: 'Endereço da clínica', grupo: 'Clínica', source: 'Configurações → Clínica → logradouro/número/bairro/cidade/UF' },
|
||||
{ key: 'clinica_telefone', label: 'Telefone da clínica', grupo: 'Clínica', source: 'Configurações → Clínica → telefone' },
|
||||
{ key: 'clinica_cnpj', label: 'CNPJ da clínica', grupo: 'Clínica', source: 'Configurações → Clínica → CPF/CNPJ (preenche só se tiver 14 dígitos)' },
|
||||
|
||||
// Financeiro
|
||||
{ key: 'valor', label: 'Valor (R$)', grupo: 'Financeiro' },
|
||||
{ key: 'valor_extenso', label: 'Valor por extenso', grupo: 'Financeiro' },
|
||||
{ key: 'forma_pagamento', label: 'Forma de pagamento', grupo: 'Financeiro' },
|
||||
// Financeiro — fonte: sessão OU extras (passados pelo chamador)
|
||||
{ key: 'valor', label: 'Valor (R$)', grupo: 'Financeiro', source: 'Sessão (preço) ou informe manualmente' },
|
||||
{ key: 'valor_extenso', label: 'Valor por extenso', grupo: 'Financeiro', source: 'Calculado a partir do valor' },
|
||||
{ key: 'forma_pagamento', label: 'Forma de pagamento', grupo: 'Financeiro', source: 'Informe manualmente (PIX, dinheiro, cartão, etc)' },
|
||||
|
||||
// Datas
|
||||
{ key: 'data_atual', label: 'Data atual', grupo: 'Datas' },
|
||||
{ key: 'data_atual_extenso', label: 'Data atual por extenso', grupo: 'Datas' },
|
||||
{ key: 'cidade_estado', label: 'Cidade/UF', grupo: 'Datas' }
|
||||
// Datas — fonte: clock do sistema / endereço da clínica
|
||||
{ key: 'data_atual', label: 'Data atual', grupo: 'Datas', source: 'Hoje (preenchido automaticamente)' },
|
||||
{ key: 'data_atual_extenso', label: 'Data atual por extenso', grupo: 'Datas', source: 'Hoje (preenchido automaticamente)' },
|
||||
{ key: 'cidade_estado', label: 'Cidade/UF', grupo: 'Datas', source: 'Configurações → Clínica → cidade/UF (últimos 2 elementos do endereço)' }
|
||||
];
|
||||
|
||||
// ── List ────────────────────────────────────────────────────
|
||||
|
||||
@@ -103,6 +103,12 @@ const form = reactive({
|
||||
bio: '',
|
||||
phone: '',
|
||||
|
||||
// Registro profissional (CFP #5 — exigido pra emissão de recibos/laudos)
|
||||
professional_registration_type: '',
|
||||
professional_registration_type_other: '',
|
||||
professional_registration_number: '',
|
||||
professional_registration_uf: '',
|
||||
|
||||
site_url: '',
|
||||
social_instagram: '',
|
||||
social_youtube: '',
|
||||
@@ -117,6 +123,24 @@ const form = reactive({
|
||||
notify_news: false
|
||||
});
|
||||
|
||||
// Opções do CHECK constraint da migration 20260521000003
|
||||
const REGISTRATION_TYPE_OPTIONS = [
|
||||
{ value: '', label: '— Não informado —' },
|
||||
{ value: 'CRP', label: 'CRP — Psicólogo(a)' },
|
||||
{ value: 'CRM', label: 'CRM — Médico(a)' },
|
||||
{ value: 'CRFa', label: 'CRFa — Fonoaudiólogo(a)' },
|
||||
{ value: 'CREFITO', label: 'CREFITO — Fisioterapeuta / T.O.' },
|
||||
{ value: 'CRESS', label: 'CRESS — Assistente Social' },
|
||||
{ value: 'CRN', label: 'CRN — Nutricionista' },
|
||||
{ value: 'RMS', label: 'RMS — Residência Multiprofissional' },
|
||||
{ value: 'outro', label: 'Outro' }
|
||||
];
|
||||
|
||||
const UF_OPTIONS = [
|
||||
'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'
|
||||
].map(uf => ({ value: uf, label: uf }));
|
||||
|
||||
const customSocials = ref([]);
|
||||
|
||||
function addCustomSocial() {
|
||||
@@ -611,7 +635,7 @@ async function loadProfile() {
|
||||
const { data: prof, error: pErr } = await supabase
|
||||
.from('profiles')
|
||||
.select(
|
||||
'role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news'
|
||||
'role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news, professional_registration_type, professional_registration_type_other, professional_registration_number, professional_registration_uf'
|
||||
)
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
@@ -631,6 +655,11 @@ async function loadProfile() {
|
||||
form.social_facebook = prof.social_facebook ?? '';
|
||||
form.social_x = prof.social_x ?? '';
|
||||
|
||||
form.professional_registration_type = prof.professional_registration_type ?? '';
|
||||
form.professional_registration_type_other = prof.professional_registration_type_other ?? '';
|
||||
form.professional_registration_number = prof.professional_registration_number ?? '';
|
||||
form.professional_registration_uf = prof.professional_registration_uf ?? '';
|
||||
|
||||
if (Array.isArray(prof.social_custom)) {
|
||||
customSocials.value = prof.social_custom;
|
||||
}
|
||||
@@ -707,7 +736,17 @@ async function saveAll() {
|
||||
|
||||
notify_system_email: !!form.notify_system_email,
|
||||
notify_reminders: !!form.notify_reminders,
|
||||
notify_news: !!form.notify_news
|
||||
notify_news: !!form.notify_news,
|
||||
|
||||
// Registro profissional (CFP) — null se vazio
|
||||
professional_registration_type: String(form.professional_registration_type || '').trim() || null,
|
||||
// type_other só preenchido quando type === 'outro' (limpa quando muda)
|
||||
professional_registration_type_other:
|
||||
form.professional_registration_type === 'outro'
|
||||
? (String(form.professional_registration_type_other || '').trim() || null)
|
||||
: null,
|
||||
professional_registration_number: String(form.professional_registration_number || '').trim() || null,
|
||||
professional_registration_uf: String(form.professional_registration_uf || '').trim() || null
|
||||
};
|
||||
|
||||
const { data: updatedProfile, error: pErr2 } = await supabase
|
||||
@@ -715,7 +754,7 @@ async function saveAll() {
|
||||
.update(profilePayload)
|
||||
.eq('id', userId.value)
|
||||
.select(
|
||||
'id, role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news, updated_at'
|
||||
'id, role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news, professional_registration_type, professional_registration_type_other, professional_registration_number, professional_registration_uf, updated_at'
|
||||
)
|
||||
.single();
|
||||
|
||||
@@ -1105,6 +1144,105 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── REGISTRO PROFISSIONAL (CFP #5) ─────────────────────── -->
|
||||
<div
|
||||
id="registro-profissional"
|
||||
class="pcard relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 scroll-mt-20 transition-[border-color] duration-[220ms]"
|
||||
style="--c: #0ea5e9; --c-dim: rgba(14, 165, 233, 0.08); --c-border: rgba(14, 165, 233, 0.2)"
|
||||
>
|
||||
<div class="pcard__shine" />
|
||||
|
||||
<div class="flex items-center gap-2.5 mb-3.5">
|
||||
<div class="w-9 h-9 rounded-[0.625rem] shrink-0 flex items-center justify-center" style="background: var(--c-dim); border: 1px solid var(--c-border); color: var(--c)"><i class="pi pi-id-card" /></div>
|
||||
<span class="text-[1rem] font-extrabold uppercase tracking-[0.14em] opacity-75" style="color: var(--c)">Registro Profissional</span>
|
||||
</div>
|
||||
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] mb-1">Conselho regional</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Exigido para emissão de recibos, atestados e laudos. Aparecerá no rodapé dos documentos.</div>
|
||||
|
||||
<div class="h-px bg-[var(--surface-border)] my-5" />
|
||||
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<!-- Tipo de conselho -->
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<FloatLabel variant="on">
|
||||
<Select
|
||||
id="prof_registration_type"
|
||||
v-model="form.professional_registration_type"
|
||||
:options="REGISTRATION_TYPE_OPTIONS"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
@update:modelValue="markDirty"
|
||||
/>
|
||||
<label for="prof_registration_type">Tipo de registro</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Conselho profissional ao qual você é vinculado.</div>
|
||||
</div>
|
||||
|
||||
<!-- Campo livre quando tipo='outro' -->
|
||||
<div v-if="form.professional_registration_type === 'outro'" class="col-span-12 md:col-span-6">
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
id="prof_registration_type_other"
|
||||
v-model="form.professional_registration_type_other"
|
||||
class="w-full"
|
||||
@input="markDirty"
|
||||
/>
|
||||
<label for="prof_registration_type_other">Nome do conselho/instituição <span class="text-red-400">*</span></label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Ex: APM (Associação Paulista de Medicina), ABRAP, etc.</div>
|
||||
</div>
|
||||
|
||||
<!-- Número -->
|
||||
<div class="col-span-7 md:col-span-3">
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
id="prof_registration_number"
|
||||
v-model="form.professional_registration_number"
|
||||
class="w-full"
|
||||
:disabled="!form.professional_registration_type"
|
||||
@input="markDirty"
|
||||
/>
|
||||
<label for="prof_registration_number">Número</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Ex: 06/12345</div>
|
||||
</div>
|
||||
|
||||
<!-- UF -->
|
||||
<div class="col-span-5 md:col-span-3">
|
||||
<FloatLabel variant="on">
|
||||
<Select
|
||||
id="prof_registration_uf"
|
||||
v-model="form.professional_registration_uf"
|
||||
:options="UF_OPTIONS"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:disabled="!form.professional_registration_type"
|
||||
class="w-full"
|
||||
:filter="true"
|
||||
@update:modelValue="markDirty"
|
||||
/>
|
||||
<label for="prof_registration_uf">UF</label>
|
||||
</FloatLabel>
|
||||
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Estado do conselho.</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-if="form.professional_registration_type && form.professional_registration_number" class="col-span-12">
|
||||
<div class="rounded-md border border-[var(--c-border)] bg-[var(--c-dim)] p-3 text-[0.9rem]">
|
||||
<span class="text-[var(--text-color-secondary)] mr-2">Aparecerá nos documentos como:</span>
|
||||
<strong class="text-[var(--text-color)]">
|
||||
{{ form.professional_registration_type === 'outro'
|
||||
? (form.professional_registration_type_other || 'Conselho não informado')
|
||||
: form.professional_registration_type }}
|
||||
{{ form.professional_registration_number }}{{ form.professional_registration_uf ? '/' + form.professional_registration_uf : '' }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 02 SITES E REDES SOCIAIS ─────────────────────── -->
|
||||
<div
|
||||
id="redes-sociais"
|
||||
|
||||
Reference in New Issue
Block a user