agenda: pacientes arquivados/inativos visiveis e bloqueados no picker

AgendaEventDialogV2.filteredPatients agora mostra TODOS os pacientes
(antes filtrava status='Ativo' silenciosamente), ordenados Ativo > Inativo
> Arquivado. Items nao-Ativo vem com Tag colorida + disabled + tooltip
explicativo — UX clara: o paciente aparece (user nao "perde" no search)
mas nao da pra agendar.

selectPaciente bloqueia non-Ativo (defesa em camadas: template ja marca
disabled, mas se alguem chamar a funcao programaticamente por cache stale
etc, a regra continua valendo). Copia status pro form pra canSave aplicar
getPatientAgendaPermissions corretamente.

3 specs novas em useAgendaEventPickerBilling.spec cobrem o bloqueio +
copia do status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-11 10:45:57 -03:00
parent 8e3c09d1b1
commit 8f4e6679eb
3 changed files with 62 additions and 8 deletions
@@ -270,16 +270,22 @@ function onPatientCreatedRapido(p) {
cadRapidoOpen.value = false; cadRapidoOpen.value = false;
} }
// Mostra TODOS os pacientes (inclusive Inativo/Arquivado) — UX intencional:
// nao-Ativos vem com badge + disabled no template, ordenados pro final.
// selectPaciente bloqueia o clique se status !== 'Ativo'.
const _aev2StatusRank = { Ativo: 0, Inativo: 1, Arquivado: 2 };
const filteredPatients = computed(() => { const filteredPatients = computed(() => {
const q = String(pacienteSearch.value || '').trim().toLowerCase(); const q = String(pacienteSearch.value || '').trim().toLowerCase();
const list = (patients.value || []).filter((p) => p.status === 'Ativo'); const list = patients.value || [];
if (!q) return list; const matched = !q
return list.filter((p) => { ? [...list]
const nome = String(p.nome || '').toLowerCase(); : list.filter((p) => {
const email = String(p.email || '').toLowerCase(); const nome = String(p.nome || '').toLowerCase();
const tel = String(p.telefone || '').toLowerCase(); const email = String(p.email || '').toLowerCase();
return nome.includes(q) || email.includes(q) || tel.includes(q); const tel = String(p.telefone || '').toLowerCase();
}); return nome.includes(q) || email.includes(q) || tel.includes(q);
});
return matched.sort((a, b) => (_aev2StatusRank[a.status] ?? 3) - (_aev2StatusRank[b.status] ?? 3));
}); });
function goToAgendamentosRecebidos() { function goToAgendamentosRecebidos() {
@@ -924,6 +930,9 @@ const heroDateText = computed(() => {
:key="p.id" :key="p.id"
type="button" type="button"
class="aev2-picker-item" class="aev2-picker-item"
:class="{ 'aev2-picker-item--blocked': p.status && p.status !== 'Ativo' }"
:disabled="p.status && p.status !== 'Ativo'"
:title="p.status === 'Arquivado' ? 'Paciente arquivado — não é possível agendar' : p.status === 'Inativo' ? 'Paciente inativo — não é possível agendar' : ''"
@click="selectPaciente(p)" @click="selectPaciente(p)"
> >
<Avatar v-if="p.avatar_url" :image="p.avatar_url" shape="circle" size="normal" /> <Avatar v-if="p.avatar_url" :image="p.avatar_url" shape="circle" size="normal" />
@@ -932,6 +941,8 @@ const heroDateText = computed(() => {
<div class="font-semibold truncate">{{ p.nome }}</div> <div class="font-semibold truncate">{{ p.nome }}</div>
<div class="text-xs opacity-60 truncate">{{ p.email || p.telefone }}</div> <div class="text-xs opacity-60 truncate">{{ p.email || p.telefone }}</div>
</div> </div>
<Tag v-if="p.status === 'Arquivado'" value="Arquivado" severity="danger" class="shrink-0" />
<Tag v-else-if="p.status === 'Inativo'" value="Inativo" severity="warn" class="shrink-0" />
</button> </button>
</div> </div>
</div> </div>
@@ -946,6 +957,7 @@ const heroDateText = computed(() => {
email-field="email_principal" email-field="email_principal"
phone-field="telefone" phone-field="telefone"
:extra-payload="{ status: 'Ativo' }" :extra-payload="{ status: 'Ativo' }"
hide-view-list-button
@created="onPatientCreatedRapido" @created="onPatientCreatedRapido"
/> />
@@ -1477,4 +1489,9 @@ const heroDateText = computed(() => {
transition: background .15s; transition: background .15s;
} }
.aev2-picker-item:hover { background: var(--aev2-pill-bg); } .aev2-picker-item:hover { background: var(--aev2-pill-bg); }
.aev2-picker-item--blocked {
opacity: .6;
cursor: not-allowed;
}
.aev2-picker-item--blocked:hover { background: transparent; }
</style> </style>
@@ -333,6 +333,33 @@ describe('selectPaciente / clearPaciente', () => {
expect(composer.form.value.paciente_id).toBe(null); expect(composer.form.value.paciente_id).toBe(null);
}); });
it('selectPaciente copia status pro form quando Ativo', () => {
const composer = makeComposer();
const { selectPaciente } = setup({ composer });
selectPaciente({ id: 'p-1', nome: 'Ana', status: 'Ativo' });
expect(composer.form.value.paciente_id).toBe('p-1');
expect(composer.form.value.paciente_status).toBe('Ativo');
});
it('selectPaciente bloqueia paciente Arquivado (defesa em camadas)', () => {
const composer = makeComposer();
const { selectPaciente, pacientePickerOpen } = setup({ composer });
pacientePickerOpen.value = true;
selectPaciente({ id: 'p-arq', nome: 'Ana', status: 'Arquivado' });
// Form nao deve ter sido tocado
expect(composer.form.value.paciente_id).toBe(null);
expect(composer.form.value.paciente_status).toBeFalsy();
// Picker permanece aberto pro user escolher outro
expect(pacientePickerOpen.value).toBe(true);
});
it('selectPaciente bloqueia paciente Inativo', () => {
const composer = makeComposer();
const { selectPaciente } = setup({ composer });
selectPaciente({ id: 'p-ina', nome: 'Bruno', status: 'Inativo' });
expect(composer.form.value.paciente_id).toBe(null);
});
it('clearPaciente limpa form + samePatientConflict', () => { it('clearPaciente limpa form + samePatientConflict', () => {
const composer = makeComposer({ const composer = makeComposer({
formExtra: { paciente_id: 'p-1', paciente_nome: 'Ana', paciente_avatar: 'url' } formExtra: { paciente_id: 'p-1', paciente_nome: 'Ana', paciente_avatar: 'url' }
@@ -286,9 +286,18 @@ export function useAgendaEventPickerBilling({
function selectPaciente(p) { function selectPaciente(p) {
if (!p?.id) return; if (!p?.id) return;
// Bloqueia clique em paciente arquivado/inativo — defesa em camadas:
// o template do picker ja marca esses items como disabled, mas se
// alguem chamar selectPaciente programaticamente (cache stale, etc),
// a regra precisa valer.
if (p.status && p.status !== 'Ativo') return;
composer.form.value.paciente_id = p.id; composer.form.value.paciente_id = p.id;
composer.form.value.paciente_nome = p.nome || ''; composer.form.value.paciente_nome = p.nome || '';
composer.form.value.paciente_avatar = p.avatar_url || ''; composer.form.value.paciente_avatar = p.avatar_url || '';
// Sem isso, form.paciente_status fica '' e canSave nao consegue
// aplicar getPatientAgendaPermissions — qualquer falha do filtro
// acima vira sessao criavel com paciente fora do escopo.
composer.form.value.paciente_status = p.status || '';
pacientePickerOpen.value = false; pacientePickerOpen.value = false;
} }
@@ -296,6 +305,7 @@ export function useAgendaEventPickerBilling({
composer.form.value.paciente_id = null; composer.form.value.paciente_id = null;
composer.form.value.paciente_nome = ''; composer.form.value.paciente_nome = '';
composer.form.value.paciente_avatar = ''; composer.form.value.paciente_avatar = '';
composer.form.value.paciente_status = '';
actions.samePatientConflict.value = null; actions.samePatientConflict.value = null;
} }