Melissa: hub Configuracoes + Embed + 9 Pages novas + dialog blueprint dark

Sprints 04-29 + 04-30 acumuladas.

- MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/
  Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed.
- MelissaEmbed: wrapper generico que injeta layout-variant=melissa
  e remove cromos pra reaproveitar Pages tradicionais.
- 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes,
  Conversas, Embed, Grupos, Medicos, Recorrencias, Tags.
- Dialog blueprint atualizado: bg-gray-100 (hardcoded light) ->
  bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em
  9 arquivos. Anti-pattern documentado.
- PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name),
  toggle vertical/abas com persist localStorage, sticky margin-top.
- Surface picker no popover do MelissaLayout (8 swatches).
- useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos.
- Migration: status agenda remarcado/confirmado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-04 11:41:19 -03:00
parent 269c380d9c
commit 86311ef305
52 changed files with 16214 additions and 1027 deletions
@@ -310,6 +310,16 @@ const navPopover = ref(null)
const isCompact = ref(false)
let mql = null, mqlCb = null
// View mode: 'vertical' (Accordion) | 'horizontal' (Tabs)
const VIEW_MODE_KEY = 'pcd.viewMode.v1'
const viewMode = ref('vertical')
try {
const saved = localStorage.getItem(VIEW_MODE_KEY)
if (saved === 'vertical' || saved === 'horizontal') viewMode.value = saved
} catch (_) {}
watch(viewMode, (v) => { try { localStorage.setItem(VIEW_MODE_KEY, v) } catch (_) {} })
function setViewMode (m) { if (m === 'vertical' || m === 'horizontal') viewMode.value = m }
function syncCompact () { isCompact.value = !!mql?.matches }
function toggleNav (e) { navPopover.value?.toggle(e) }
function selectNav (s) { openPanel(Number(s.value)); navPopover.value?.hide() }
@@ -563,6 +573,19 @@ async function onCepBlur () {
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
// `submitted` é true depois da primeira tentativa de salvar — usado pra
// mostrar borda vermelha + msg "Campo obrigatório" embaixo dos inputs
// sem incomodar o usuário antes da primeira interação.
const submitted = ref(false)
// Counts dos editores polimórficos (telefones/emails) — atualizados via
// @change. Telefone e email são obrigatórios: pelo menos 1 cada.
const phonesCount = ref(0)
const emailsCount = ref(0)
// Refs pros editores — usados pra chamar `flushPending` depois que o
// paciente é criado (telefones/emails inseridos antes do save ficam
// em modo pendente até a entidade existir no DB).
const phonesEditorRef = ref(null)
const emailsEditorRef = ref(null)
async function fetchAll () {
loading.value = true
@@ -600,6 +623,10 @@ watch(patientId, fetchAll, { immediate:true })
// Submit
// ─────────────────────────────────────────────────────────
async function onSubmit () {
// Marca pra que :invalid + mensagens de erro fiquem visíveis nos inputs
// exigidos. Reseta no sucesso (logo abaixo) ou na próxima edição válida
// (não reseta automaticamente — só atrapalharia o feedback visual).
submitted.value = true
saving.value = true
try {
const ownerId = await getOwnerId()
@@ -609,6 +636,16 @@ async function onSubmit () {
toast.add({ severity:'warn', summary:'Nome obrigatório', detail:'Preencha o nome completo.', life:3500 })
await openPanel(0); return
}
// Telefone e email são obrigatórios: pelo menos 1 cada. Toast aponta
// pro campo faltando + abre a seção Identidade (onde os editores ficam).
if (phonesCount.value === 0) {
toast.add({ severity:'warn', summary:'Telefone obrigatório', detail:'Adicione pelo menos um telefone.', life:3500 })
await openPanel(0); return
}
if (emailsCount.value === 0) {
toast.add({ severity:'warn', summary:'E-mail obrigatório', detail:'Adicione pelo menos um e-mail.', life:3500 })
await openPanel(0); return
}
const payload = sanitizePayload(form.value, ownerId)
payload.tenant_id = tenantId; payload.responsible_member_id = memberId
if (isEdit.value) {
@@ -618,15 +655,23 @@ async function onSubmit () {
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
await saveContatosSuporte(patientId.value, tenantId, ownerId)
toast.add({ severity:'success', summary:'Salvo', detail:'Paciente atualizado.', life:2500 })
submitted.value = false
if (props.dialogMode) { emit('created', { id:patientId.value }); return }
return
}
const created = await createPatient(payload)
// Telefones/emails podem ter sido adicionados ANTES do paciente existir
// (modo pendente — id 'pending_*' em memória). Agora que temos `created.id`,
// gravamos tudo em lote no DB. Roda antes de avatar/grupos/tags pra que
// qualquer falha aqui aborte o resto do fluxo.
await phonesEditorRef.value?.flushPending('patient', created.id)
await emailsEditorRef.value?.flushPending('patient', created.id)
await maybeUploadAvatar(ownerId, created.id)
await replacePatientGroups(created.id, grupoIdSelecionado.value)
await replacePatientTags(created.id, tagIdsSelecionadas.value)
await saveContatosSuporte(created.id, tenantId, ownerId)
toast.add({ severity:'success', summary:'Salvo', detail:'Paciente cadastrado.', life:2500 })
submitted.value = false
if (props.dialogMode) { emit('created', created); return }
form.value=resetForm(); grupoIdSelecionado.value=null; tagIdsSelecionadas.value=[]
contatosSuporte.value=[]; avatarFile.value=null; revokePreview(); avatarPreviewUrl.value=''
@@ -1014,7 +1059,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
<i class="pi pi-spin pi-spinner text-xl" /> Carregando
</div>
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-[220px_1fr] max-w-[1040px] mx-auto">
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-[220px_1fr] xl:items-start max-w-[1040px] mx-auto">
<!-- SIDEBAR -->
<aside
@@ -1044,8 +1089,32 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</div>
</div>
<!-- Nav desktop ( xl) -->
<div v-if="!isCompact" class="flex flex-col gap-0.5">
<!-- Toggle layout vertical/horizontal -->
<div class="flex items-center gap-1 mb-3 p-0.5 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<button
type="button"
class="flex-1 flex items-center justify-center gap-1.5 px-2 py-1 rounded-md text-[0.68rem] font-medium transition-all duration-150"
:class="viewMode === 'vertical' ? 'bg-[var(--surface-card)] text-[var(--text-color)] shadow-sm' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
title="Layout vertical (acordeão)"
@click="setViewMode('vertical')"
>
<i class="pi pi-bars text-[0.68rem]" />
<span>Vertical</span>
</button>
<button
type="button"
class="flex-1 flex items-center justify-center gap-1.5 px-2 py-1 rounded-md text-[0.68rem] font-medium transition-all duration-150"
:class="viewMode === 'horizontal' ? 'bg-[var(--surface-card)] text-[var(--text-color)] shadow-sm' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
title="Layout horizontal (abas)"
@click="setViewMode('horizontal')"
>
<i class="pi pi-th-large text-[0.68rem] rotate-90" />
<span>Abas</span>
</button>
</div>
<!-- Nav desktop ( xl) em vertical (em horizontal as tabs ficam acima do form) -->
<div v-if="!isCompact && viewMode === 'vertical'" class="flex flex-col gap-0.5">
<div class="text-[0.62rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-40 px-2 mb-1">Seções</div>
<button
v-for="s in sections" :key="s.value" type="button"
@@ -1080,8 +1149,8 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</div>
</div>
<!-- Nav compacto (< xl) -->
<div v-if="isCompact">
<!-- Nav compacto (< xl) em vertical -->
<div v-if="isCompact && viewMode === 'vertical'">
<Button
type="button" class="w-full !rounded-full"
icon="pi pi-list" iconPos="right"
@@ -1155,7 +1224,39 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</aside>
<!-- MAIN -->
<main class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
<main
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm"
:class="{ 'pcd-horizontal': viewMode === 'horizontal' }"
>
<!-- Tab list ( em horizontal) -->
<div
v-if="viewMode === 'horizontal'"
class="flex gap-0.5 overflow-x-auto px-2 pt-2 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]/40"
role="tablist"
>
<button
v-for="s in sections" :key="s.value"
type="button" role="tab"
:aria-selected="activeValue === s.value"
class="flex items-center gap-1.5 px-3 py-2 rounded-t-lg text-[0.78rem] font-medium border-b-2 transition-all duration-150 shrink-0 whitespace-nowrap"
:class="activeValue === s.value
? `${pal[s.accent].activeBtn} !rounded-b-none`
: 'text-[var(--text-color-secondary)] border-transparent hover:bg-[var(--surface-card)]/60 hover:text-[var(--text-color)]'"
@click="activeValue = s.value"
>
<span class="flex items-center justify-center w-5 h-5 rounded-md text-[0.62rem] shrink-0" :class="pal[s.accent].iconBox">
<i :class="s.icon"/>
</span>
<span>{{ s.label }}</span>
<i v-if="p(s.value).filled === p(s.value).total"
class="pi pi-check-circle text-emerald-500 text-[0.7rem] shrink-0" />
<span v-else-if="p(s.value).filled > 0"
class="text-[0.6rem] text-amber-600 font-bold shrink-0">
{{ p(s.value).filled }}/{{ p(s.value).total }}
</span>
</button>
</div>
<Accordion :multiple="false" v-model:value="activeValue">
<!--
@@ -1177,22 +1278,37 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</div>
</AccordionHeader>
<AccordionContent>
<div class="p-4">
<div class="p-5">
<!-- Nome & identidade -->
<div class="flex items-center gap-2 mb-4">
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Nome & identidade</span>
<div class="flex items-center gap-2 mb-5">
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Nome & identidade</span>
<div class="flex-1 h-px" :class="pal.indigo.divLine"/>
</div>
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2 mb-6">
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2 mb-7">
<!-- Nome completo full width -->
<div class="xl:col-span-2">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-user"/><InputText id="f_nome" v-model="form.nome_completo" class="w-full" variant="filled"/></IconField>
<IconField>
<InputIcon class="pi pi-user"/>
<InputText
id="f_nome"
v-model="form.nome_completo"
class="w-full"
variant="filled"
:invalid="submitted && !String(form.nome_completo || '').trim()"
/>
</IconField>
<label for="f_nome">Nome completo *</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Exibido no header do perfil do paciente.</div>
<small
v-if="submitted && !String(form.nome_completo || '').trim()"
class="mt-2 text-[0.85rem] text-red-500 flex items-center gap-1.5"
>
<i class="pi pi-exclamation-circle text-[0.78rem]"/>
<span>Campo obrigatório.</span>
</small>
</div>
<!-- Nome social -->
@@ -1201,7 +1317,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
<InputText id="f_nome_social" v-model="form.nome_social" class="w-full" variant="filled"/>
<label for="f_nome_social">Nome social</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" como prefere ser chamado(a).</div>
</div>
<!-- Pronomes -->
@@ -1210,16 +1325,18 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
<Select id="f_pronomes" v-model="form.pronomes" :options="pronounsOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_pronomes">Pronomes</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Header: <em>"32 anos · <strong>ela/dela</strong> · São Carlos, SP"</em></div>
</div>
<!-- Data de nascimento -->
<!-- Data de nascimento InputGroup com idade calculada como addon à direita -->
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-calendar"/><InputMask id="f_nasc" v-model="form.data_nascimento" mask="99-99-9999" :unmask="false" class="w-full" variant="filled"/></IconField>
<InputGroup>
<InputGroupAddon><i class="pi pi-calendar"/></InputGroupAddon>
<InputMask id="f_nasc" v-model="form.data_nascimento" mask="99-99-9999" :unmask="false" variant="filled"/>
<InputGroupAddon v-if="ageLabel !== '—'" class="font-semibold text-[var(--primary-color)]">{{ ageLabel }}</InputGroupAddon>
</InputGroup>
<label for="f_nasc">Data de nascimento</label>
</FloatLabel>
<div v-if="ageLabel!=='—'" class="mt-1 text-[0.63rem] text-indigo-600 font-semibold"><i class="pi pi-info-circle mr-1"/>{{ ageLabel }}</div>
</div>
<!-- Gênero -->
@@ -1244,7 +1361,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
<IconField><InputIcon class="pi pi-id-card"/><InputMask id="f_cpf" v-model="form.cpf" mask="999.999.999-99" :unmask="false" class="w-full" variant="filled"/></IconField>
<label for="f_cpf">CPF</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Exibido mascarado: <em>45690</em></div>
</div>
<!-- RG -->
@@ -1261,7 +1377,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
<Select id="f_etnia" v-model="form.etnia" :options="etniaOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_etnia">Etnia / raça</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" linha "Etnia".</div>
</div>
<!-- Naturalidade -->
@@ -1278,7 +1393,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
<IconField><InputIcon class="pi pi-briefcase"/><InputText id="f_prof" v-model="form.profissao" class="w-full" variant="filled"/></IconField>
<label for="f_prof">Profissão</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" "Desenvolvedora".</div>
</div>
<!-- Escolaridade -->
@@ -1287,41 +1401,91 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
<Select id="f_esc" v-model="form.escolaridade" :options="escolaridadeOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_esc">Escolaridade</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card "Dados pessoais" "Superior completo".</div>
</div>
</div>
<!-- Contato alimenta card "Contato" -->
<div class="flex items-center gap-2 mb-4">
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Contato</span>
<div class="flex items-center gap-2 mb-5">
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.indigo.divTxt">Contato</span>
<div class="flex-1 h-px" :class="pal.indigo.divLine"/>
<span class="text-[0.6rem]" :class="pal.indigo.hint">Card "Contato" no detalhe</span>
</div>
<!-- Telefones (polimórfico tipo/número/principal/vinculado) -->
<div class="col-span-full">
<div class="text-xs font-semibold text-[var(--text-color-secondary)] mb-1.5 flex items-center gap-1.5">
<i class="pi pi-phone text-[var(--primary-color)]" />
Telefones
<div class="col-span-full mb-7">
<div
class="flex items-start gap-3 p-4 mb-5 rounded-xl border transition-colors"
:class="submitted && phonesCount === 0
? 'border-red-300 bg-red-50/60 text-red-700'
: pal.indigo.infoBox"
>
<span
class="flex items-center justify-center w-9 h-9 rounded-lg shrink-0"
:class="submitted && phonesCount === 0
? 'bg-red-100 text-red-600'
: pal.indigo.iconBox"
>
<i class="pi pi-phone text-[0.95rem]"/>
</span>
<div class="flex-1 min-w-0">
<div class="text-[0.95rem] font-semibold leading-tight">Telefones *</div>
<div class="text-[0.85rem] mt-1 leading-snug opacity-90">
Marque um telefone como <strong>principal</strong> ele é usado pra cobranças, lembretes automáticos e contato padrão. Número vindo do CRM WhatsApp recebe a etiqueta <strong>"vinculado"</strong>.
</div>
<div
v-if="submitted && phonesCount === 0"
class="mt-2 text-[0.85rem] flex items-center gap-1.5 font-semibold"
>
<i class="pi pi-exclamation-circle text-[0.78rem]"/>
<span>Adicione pelo menos um telefone.</span>
</div>
</div>
</div>
<ContactPhonesEditor
ref="phonesEditorRef"
entity-type="patient"
:entity-id="patientId || null"
@change="(arr) => phonesCount = (arr || []).length"
/>
</div>
<!-- Emails (polimórfico tipo/endereço/principal) -->
<div class="col-span-full">
<div class="text-xs font-semibold text-[var(--text-color-secondary)] mb-1.5 flex items-center gap-1.5">
<i class="pi pi-envelope text-[var(--primary-color)]" />
Emails
<div class="col-span-full mb-7">
<div
class="flex items-start gap-3 p-4 mb-5 rounded-xl border transition-colors"
:class="submitted && emailsCount === 0
? 'border-red-300 bg-red-50/60 text-red-700'
: pal.indigo.infoBox"
>
<span
class="flex items-center justify-center w-9 h-9 rounded-lg shrink-0"
:class="submitted && emailsCount === 0
? 'bg-red-100 text-red-600'
: pal.indigo.iconBox"
>
<i class="pi pi-envelope text-[0.95rem]"/>
</span>
<div class="flex-1 min-w-0">
<div class="text-[0.95rem] font-semibold leading-tight">E-mails *</div>
<div class="text-[0.85rem] mt-1 leading-snug opacity-90">
Marque um e-mail como <strong>principal</strong> ele é usado pra envio de recibos, comprovantes e comunicações oficiais.
</div>
<div
v-if="submitted && emailsCount === 0"
class="mt-2 text-[0.85rem] flex items-center gap-1.5 font-semibold"
>
<i class="pi pi-exclamation-circle text-[0.78rem]"/>
<span>Adicione pelo menos um e-mail.</span>
</div>
</div>
</div>
<ContactEmailsEditor
ref="emailsEditorRef"
entity-type="patient"
:entity-id="patientId || null"
@change="(arr) => emailsCount = (arr || []).length"
/>
</div>
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<!-- Canal preferido -->
<div>
@@ -1329,7 +1493,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
<Select id="f_canal" v-model="form.canal_preferido" :options="canalOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_canal">Canal preferido de contato</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card Contato "Canal preferido: <strong>WhatsApp</strong>".</div>
</div>
<!-- Horário de contato -->
@@ -1338,7 +1501,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
<IconField><InputIcon class="pi pi-clock"/><InputText id="f_horario" v-model="form.horario_contato" class="w-full" variant="filled" placeholder="Ex: 08h18h"/></IconField>
<label for="f_horario">Horário de contato</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Card Contato "Horário: <strong>08h18h</strong>".</div>
</div>
<!-- Observações de endereço -->
@@ -1347,7 +1509,10 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
<Textarea id="f_obs" v-model="form.observacoes" rows="2" class="w-full" variant="filled"/>
<label for="f_obs">Observações de endereço</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.indigo.hint">Ex: Próximo ao posto, portão azul, sem interfone.</div>
<div class="mt-2 text-[0.85rem] text-[var(--primary-color)] flex items-center gap-1.5">
<i class="pi pi-info-circle text-[0.78rem]"/>
<span>Ex: Próximo ao posto, portão azul, sem interfone.</span>
</div>
</div>
</div>
</div>
@@ -1372,18 +1537,17 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</div>
</AccordionHeader>
<AccordionContent>
<div class="p-4">
<div class="p-5">
<div :class="`flex items-start gap-2 mb-4 p-2.5 rounded-lg border text-[0.75rem] ${pal.teal.infoBox}`">
<i class="pi pi-lightbulb mt-0.5 shrink-0"/>
<span>Digite o CEP e cidade, estado, bairro e logradouro são preenchidos automaticamente via <strong>ViaCEP</strong>.</span>
</div>
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-map-marker"/><InputText id="f_cep" v-model="form.cep" class="w-full" @blur="onCepBlur" variant="filled" placeholder="00000-000"/></IconField>
<label for="f_cep">CEP</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.teal.hint">Card Contato <em>"13560-000 · São Carlos"</em></div>
</div>
<div>
<FloatLabel variant="on">
@@ -1396,14 +1560,12 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
<IconField><InputIcon class="pi pi-building"/><InputText id="f_city" v-model="form.cidade" class="w-full" variant="filled"/></IconField>
<label for="f_city">Cidade</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.teal.hint">Header <em>"<strong>São Carlos</strong>, SP"</em></div>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-compass"/><InputText id="f_uf" v-model="form.estado" class="w-full" variant="filled"/></IconField>
<label for="f_uf">Estado (UF)</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.teal.hint">Header "São Carlos, <strong>SP</strong>"</div>
</div>
<div class="xl:col-span-2">
<FloatLabel variant="on">
@@ -1453,7 +1615,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</div>
</AccordionHeader>
<AccordionContent>
<div class="p-4">
<div class="p-5">
<!-- Preview dos badges ao vivo -->
<div v-if="form.status||convenioNome||form.patient_scope||tagIdsSelecionadas.length"
@@ -1472,18 +1634,16 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</div>
<!-- Situação clínica -->
<div class="flex items-center gap-2 mb-4">
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Situação clínica</span>
<div class="flex items-center gap-2 mb-5">
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Situação clínica</span>
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
<span class="text-[0.6rem]" :class="pal.violet.hint">Badges no header do perfil</span>
</div>
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-3 mb-6">
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3 mb-7">
<div>
<FloatLabel variant="on">
<Select id="f_status" v-model="form.status" :options="statusOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_status">Status</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Badge <span class="font-bold text-green-600">verde</span> no header.</div>
</div>
<div>
<!-- CONVÊNIO seleciona de insurance_plans, máx 1 -->
@@ -1517,33 +1677,33 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
@click="showConvenioDlg = true"
/>
</div>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Badge <span class="font-bold text-blue-500">azul</span> no header · máx 1 convênio.</div>
</div>
<div>
<FloatLabel variant="on">
<Select id="f_scope" v-model="form.patient_scope" :options="scopeOpts" optionLabel="label" optionValue="value" class="w-full" variant="filled"/>
<label for="f_scope">Escopo de atendimento</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Badge <span class="font-bold text-gray-500">cinza</span> no header.</div>
</div>
</div>
<!-- Organização: grupo + tags -->
<div class="flex items-center gap-2 mb-4">
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Organização</span>
<div class="flex items-center gap-2 mb-5">
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Organização</span>
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
<span class="text-[0.6rem]" :class="pal.violet.hint">Chips coloridos no header</span>
</div>
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2 mb-6">
<div class="grid grid-cols-1 gap-6 xl:grid-cols-2 mb-7">
<div class="flex gap-2">
<div class="flex-1 min-w-0">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-folder-open"/>
<Select id="f_grupo" v-model="grupoIdSelecionado" :options="groups" optionLabel="nome" optionValue="id" class="w-full pl-[25px]" showClear filter variant="filled"/>
<Select id="f_grupo" v-model="grupoIdSelecionado" :options="groups" optionLabel="name" optionValue="id" class="w-full pl-[25px]" showClear filter variant="filled"/>
</IconField>
<label for="f_grupo">Grupo</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Define o modelo de anamnese.</div>
<div class="mt-2 text-[0.85rem] text-[var(--primary-color)] flex items-center gap-1.5">
<i class="pi pi-info-circle text-[0.78rem]"/>
<span>Define o modelo de anamnese aplicado ao paciente.</span>
</div>
</div>
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0 h-[42px] mt-[1px]" title="Criar grupo" @click="openGroupDlg"/>
</div>
@@ -1555,25 +1715,22 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</IconField>
<label for="f_tags">Tags clínicas</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Aparecem como chips coloridos no header do perfil.</div>
</div>
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0 h-[42px] mt-[1px]" title="Criar tag" @click="openTagDlg"/>
</div>
</div>
<!-- Origem alimenta card "Origem" do detalhe -->
<div class="flex items-center gap-2 mb-4">
<span class="text-[0.63rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Origem</span>
<div class="flex items-center gap-2 mb-5">
<span class="text-[0.7rem] font-bold uppercase tracking-widest" :class="pal.violet.divTxt">Origem</span>
<div class="flex-1 h-px" :class="pal.violet.divLine"/>
<span class="text-[0.6rem]" :class="pal.violet.hint">Card "Origem" no perfil</span>
</div>
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-megaphone"/><InputText id="f_lead" v-model="form.onde_nos_conheceu" class="w-full" variant="filled"/></IconField>
<label for="f_lead">Como chegou até mim?</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Origem "Como chegou: <strong>Indicação</strong>".</div>
</div>
<div>
<!-- ENCAMINHADO POR múltiplos médicos -->
@@ -1623,14 +1780,16 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
class="rounded-full w-full"
@click="showMedicoDlg = true"
/>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Pode adicionar mais de um profissional de referência.</div>
<div class="mt-2 text-[0.85rem] text-[var(--primary-color)] flex items-center gap-1.5">
<i class="pi pi-info-circle text-[0.78rem]"/>
<span>Você pode adicionar mais de um profissional de referência.</span>
</div>
</div>
<div>
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-sign-out"/><InputText id="f_saida" v-model="form.motivo_saida" class="w-full" variant="filled" placeholder="Se aplicável"/></IconField>
<label for="f_saida">Motivo de saída</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.violet.hint">Origem "Motivo de saída" quando preenchido.</div>
</div>
</div>
</div>
@@ -1659,7 +1818,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</div>
</AccordionHeader>
<AccordionContent>
<div class="p-4">
<div class="p-5">
<div :class="`flex items-start gap-2 mb-4 p-2.5 rounded-lg border text-[0.75rem] ${pal.amber.infoBox}`">
<i class="pi pi-info-circle mt-0.5 shrink-0"/>
<span>Cada contato aqui aparece no card <strong>"Contatos &amp; rede de suporte"</strong> do perfil. O marcado como <strong>emergência primária</strong> recebe badge vermelho.</span>
@@ -1699,7 +1858,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
<InputText :id="`cr_${idx}`" v-model="c.relacao" class="w-full" variant="filled" placeholder="Ex: mãe, psiquiatra"/>
<label :for="`cr_${idx}`">Relação / papel</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.amber.hint">Subtítulo no card: "Maria Lima · <strong>mãe</strong>".</div>
</div>
<div>
<FloatLabel variant="on">
@@ -1714,7 +1872,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</IconField>
<label :for="`ctel_${idx}`">Telefone</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.amber.hint">Exibido abaixo do nome no card.</div>
</div>
<div>
<FloatLabel variant="on">
@@ -1723,7 +1880,6 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</IconField>
<label :for="`cemail_${idx}`">E-mail</label>
</FloatLabel>
<div class="mt-1 text-[0.63rem]" :class="pal.amber.hint">Exibido ao lado do telefone.</div>
</div>
<!-- Emergência primária -->
<div class="xl:col-span-2">
@@ -1767,8 +1923,8 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</div>
</AccordionHeader>
<AccordionContent>
<div class="p-4">
<div class="grid grid-cols-1 gap-3.5 xl:grid-cols-2">
<div class="p-5">
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div class="xl:col-span-2">
<FloatLabel variant="on">
<IconField><InputIcon class="pi pi-user"/><InputText id="f_rn" v-model="form.nome_responsavel" class="w-full" variant="filled"/></IconField>
@@ -1826,7 +1982,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</div>
</AccordionHeader>
<AccordionContent>
<div class="p-4">
<div class="p-5">
<div :class="`flex items-start gap-2 mb-4 p-2.5 rounded-lg border text-[0.75rem] ${pal.rose.infoBox}`">
<i class="pi pi-shield mt-0.5 shrink-0"/>
<span>Campo interno: <strong>não aparece</strong> no cadastro externo nem é compartilhado com o paciente.</span>
@@ -1861,9 +2017,9 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
class="dc-dialog w-[36rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
}"
@@ -1918,9 +2074,9 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
class="dc-dialog w-[36rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
}"
@@ -2015,3 +2171,24 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
</template>
</Dialog>
</template>
<style scoped>
/* Em modo horizontal, esconde os headers do Accordion (a navegação fica nas tabs em cima do main) */
.pcd-horizontal :deep(.p-accordionheader) {
display: none !important;
}
.pcd-horizontal :deep(.p-accordion-header) {
display: none !important;
}
.pcd-horizontal :deep(.p-accordioncontent),
.pcd-horizontal :deep(.p-accordion-content) {
border: none !important;
}
/* Tira o padding interno do wrapper do AccordionContent — o conteúdo já tem
o próprio padding (.p-5) por seção, então o do PrimeVue duplicava o
espaçamento e dava sensação de elementos descolados. */
:deep(.p-accordioncontent-content) {
padding: 0 !important;
}
</style>
@@ -688,9 +688,9 @@ function isRecent(row) {
maximizable
class="w-[96vw] max-w-2xl"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
content: { class: '!p-4' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } },
}"