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:
@@ -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) — só 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) — só 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 (só 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>••••456••••90</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: 08h–18h"/></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>08h–18h</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 & 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' } },
|
||||
}"
|
||||
|
||||
Reference in New Issue
Block a user