Files
agenciapsilmno/src/features/agenda/composables/useAgendaEventPickerBilling.js
T
Leonardo 8f4e6679eb 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>
2026-05-11 10:45:57 -03:00

389 lines
16 KiB
JavaScript

/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/composables/useAgendaEventPickerBilling.js
| Data: 2026-05-04
|
| A66 sub-sessão 1C-ii-a — handlers de patient picker + billing items +
| 2 watchers (commitment_id auto-fill price, insurance_plan_id limpa
| items + reset campos).
|
| Não inclui (vai pra 1C-ii-b):
| - Watcher props.modelValue (init form ao abrir — tem dependências
| em loadPatients/ensureServicesLoaded/loadInsurancePlans/
| _loadCommitmentItemsForEvent + supabase pra buscar nome do paciente)
| - Watchers de online slots, solicitações pendentes
| - Series pills handlers
| - Slot selection
| - Quick-creates wiring
| - onSendManualReminder
|
| Recebe via argumento:
| composer — refs + computeds do composer (1B)
| _actions — refs internos do actions (1C-i): _restoringConvenio
| commitmentItems — ref<Item[]>
| servicePickerSel — ref do select picker (limpado ao trocar convenio)
| selectedPlanService — ref do procedure de convênio
| services — ref<Service[]> do useServices
| loadServices — fn(ownerId) do useServices
| getDefaultPrice — fn() do useServices (preço default sugerido)
| planServices — computed<PlanService[]> (de useInsurancePlans + form)
| loadActiveDiscount — fn(ownerId, patientId) do usePatientDiscounts
| _csLoadItems — fn(eventId) do useCommitmentServices
| _csLoadItemsOrTemplate — fn(eventId, ruleId, opts) do useCommitmentServices
| isDynamic — computed<boolean>
| props — props do componente parent
|--------------------------------------------------------------------------
*/
import { ref, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { calcFinalPrice } from './agendaEventHelpers';
export function useAgendaEventPickerBilling({
composer,
actions,
commitmentItems,
servicePickerSel,
selectedPlanService,
services,
loadServices,
getDefaultPrice,
planServices,
loadActiveDiscount,
_csLoadItems,
_csLoadItemsOrTemplate,
isDynamic,
props
}) {
// ── Patient picker state ───────────────────────────────────────
const pacientePickerOpen = ref(false);
const pacienteSearch = ref('');
const pacientesLoading = ref(false);
const pacientesError = ref('');
const patients = ref([]);
// ── Cadastro rápido ────────────────────────────────────────────
const cadRapidoOpen = ref(false);
// ── Services lazy-load gate ────────────────────────────────────
let _servicesLoaded = false;
// ────────────────────────────────────────────────────────────────
// Services pré-carga (lazy: só uma vez por sessão do dialog)
// ────────────────────────────────────────────────────────────────
async function ensureServicesLoaded() {
if (_servicesLoaded || !props.ownerId) return;
_servicesLoaded = true;
await loadServices(props.ownerId);
}
// Reseta o gate (chamado pelo watcher de open na 1C-ii-b)
function resetServicesGate() {
_servicesLoaded = false;
}
function applyDefaultPrice() {
// Skip particular: preço vem dos commitmentItems
if (composer.billingType.value === 'particular') return;
// Só auto-preenche em criação (edição preserva o valor salvo)
if (!composer.isEdit.value) {
const suggested = getDefaultPrice();
if (suggested != null) composer.form.value.price = suggested;
}
}
// ────────────────────────────────────────────────────────────────
// Billing items (commitment_services)
// ────────────────────────────────────────────────────────────────
/**
* Adiciona um serviço ao billing. Regras:
* - Não duplica: se já existe item do mesmo service_id, incrementa quantity
* - Aplica desconto ativo do paciente (se houver)
* - Recalcula final_price via helper puro
*/
async function addItem(svc) {
if (!svc?.id) return;
const existing = commitmentItems.value.find((i) => i.service_id === svc.id);
if (existing) {
existing.quantity++;
existing.final_price = calcFinalPrice(existing.unit_price, existing.quantity, existing.discount_pct, existing.discount_flat);
return;
}
const unit_price = Number(svc.price);
const patientId = composer.form.value.patient_id ?? composer.form.value.paciente_id ?? null;
let discount_pct = 0;
let discount_flat = 0;
if (patientId && props.ownerId) {
const discount = await loadActiveDiscount(props.ownerId, patientId);
if (discount) {
discount_pct = Number(discount.discount_pct ?? 0);
discount_flat = Number(discount.discount_flat ?? 0);
}
}
commitmentItems.value.push({
service_id: svc.id,
service_name: svc.name,
quantity: 1,
unit_price,
discount_pct,
discount_flat,
final_price: calcFinalPrice(unit_price, 1, discount_pct, discount_flat)
});
}
function removeItem(index) {
commitmentItems.value.splice(index, 1);
// Lista vazia em modo dinâmico → restaura duração padrão
if (commitmentItems.value.length === 0 && isDynamic.value) {
composer.form.value.duracaoMin = props.agendaSettings?.session_duration_min ?? 50;
}
}
function onItemChange(item) {
item.final_price = calcFinalPrice(item.unit_price, item.quantity, item.discount_pct, item.discount_flat);
}
/**
* Carrega items do evento (ou template da série) e detecta billingType.
* Heurística: se eventRow tem insurance_plan_id → 'convenio'; senão,
* presença de items → 'particular', vazio → 'gratuito'.
*/
async function _loadCommitmentItemsForEvent(eventId) {
const ruleId = props.eventRow?.recurrence_id ?? null;
const isCustomized = props.eventRow?.services_customized ?? false;
const origPlanId = props.eventRow?.insurance_plan_id ?? null;
const origGuide = props.eventRow?.insurance_guide_number ?? null;
const origInsValue = props.eventRow?.insurance_value != null ? Number(props.eventRow.insurance_value) : null;
function applyConvenio() {
const origPsId = props.eventRow?.insurance_plan_service_id ?? null;
actions._restoringConvenio.value = true;
composer.form.value.insurance_plan_id = origPlanId;
composer.form.value.insurance_guide_number = origGuide;
composer.form.value.insurance_value = origInsValue;
composer.form.value.insurance_plan_service_id = origPsId;
composer.billingType.value = 'convenio';
nextTick(() => {
if (origPsId && planServices.value.find((s) => s.id === origPsId)) {
selectedPlanService.value = origPsId;
} else {
selectedPlanService.value = null;
}
actions._restoringConvenio.value = false;
});
}
if (!eventId && !ruleId) {
commitmentItems.value = [];
if (origPlanId) applyConvenio();
else composer.billingType.value = 'particular';
return;
}
try {
commitmentItems.value = ruleId
? await _csLoadItemsOrTemplate(eventId, ruleId, { allowEmpty: isCustomized })
: await _csLoadItems(eventId);
if (origPlanId) applyConvenio();
else composer.billingType.value = commitmentItems.value.length > 0 ? 'particular' : 'gratuito';
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[useAgendaEventPickerBilling] commitment_services load error:', e?.message);
commitmentItems.value = [];
if (origPlanId) applyConvenio();
else composer.billingType.value = 'gratuito';
}
}
/**
* Setter do procedimento de convênio (form.insurance_plan_service_id).
* Quando muda, atualiza form.insurance_value baseado no value do procedimento.
*/
function onProcedureSelect(psId) {
composer.form.value.insurance_plan_service_id = psId ?? null;
if (!psId) {
composer.form.value.insurance_value = null;
return;
}
const ps = planServices.value.find((s) => s.id === psId);
composer.form.value.insurance_value = ps?.value != null ? Number(ps.value) : null;
}
// ────────────────────────────────────────────────────────────────
// Patient picker
// ────────────────────────────────────────────────────────────────
function selectCommitment(c) {
if (!c?.id) return;
composer.form.value.commitment_id = c.id;
composer.form.value.extra_fields = {};
if (Array.isArray(c.fields)) {
for (const f of c.fields) composer.form.value.extra_fields[f.key] = '';
}
composer.step.value = 2;
if (composer.requiresPatient.value) loadPatients(true);
}
function goBack() {
if (composer.isEdit.value || !composer.allowBack.value) return;
composer.step.value = 1;
composer.form.value.commitment_id = null;
composer.form.value.paciente_id = null;
composer.form.value.paciente_nome = '';
}
function openPacientePicker() {
if (!composer.requiresPatient.value) return;
pacientePickerOpen.value = true;
loadPatients(false);
}
function clearPatientsCache() {
patients.value = [];
pacientesError.value = '';
pacienteSearch.value = '';
}
async function loadPatients(force = false) {
try {
if (pacientesLoading.value) return;
if (!force && patients.value?.length) return;
pacientesError.value = '';
pacientesLoading.value = true;
let q = supabase
.from('patients')
.select('id,nome_completo,email_principal,telefone,status,avatar_url,tenant_id,responsible_member_id,created_at')
.order('created_at', { ascending: false })
.limit(500);
if (props.tenantId) q = q.eq('tenant_id', props.tenantId);
if (props.restrictPatientsToOwner && props.patientScopeOwnerId) {
q = q.eq('responsible_member_id', props.patientScopeOwnerId);
}
const { data, error } = await q;
if (error) throw error;
patients.value = (data || []).map((r) => ({
id: r.id,
nome: r.nome_completo ?? '',
email: r.email_principal ?? '',
telefone: r.telefone ?? '',
status: r.status ?? '',
avatar_url: r.avatar_url ?? ''
}));
} catch (e) {
pacientesError.value = e?.message || 'Falha ao carregar pacientes.';
patients.value = [];
} finally {
pacientesLoading.value = false;
}
}
function selectPaciente(p) {
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_nome = p.nome || '';
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;
}
function clearPaciente() {
composer.form.value.paciente_id = null;
composer.form.value.paciente_nome = '';
composer.form.value.paciente_avatar = '';
composer.form.value.paciente_status = '';
actions.samePatientConflict.value = null;
}
function openCadastroRapido() {
cadRapidoOpen.value = true;
}
function abrirCadastroCompleto() {
if (!props.newPatientRoute) return;
// Abre em nova aba pra o user voltar pro dialog depois
window.open(props.newPatientRoute, '_blank', 'noopener');
}
// ────────────────────────────────────────────────────────────────
// Watchers
// ────────────────────────────────────────────────────────────────
/**
* Auto-fill de price quando o user troca commitment em criação.
* Ignorado em edição pra preservar o valor salvo.
*/
watch(
() => composer.form.value.commitment_id,
async (newId) => {
if (!newId || composer.isEdit.value || !composer.visible.value) return;
await ensureServicesLoaded();
applyDefaultPrice();
}
);
/**
* Limpa procedure + campos de convênio quando muda plano.
* Quando seleciona convênio: zera items (exclusividade convênio vs serviços).
* `_restoringConvenio` ignora o watch durante restauração de eventRow editado.
*/
watch(
() => composer.form.value.insurance_plan_id,
(planId) => {
if (actions._restoringConvenio.value) return;
selectedPlanService.value = null;
composer.form.value.insurance_plan_service_id = null;
if (!planId) {
composer.form.value.insurance_value = null;
composer.form.value.insurance_guide_number = null;
return;
}
commitmentItems.value = [];
servicePickerSel.value = null;
}
);
return {
// refs do picker
pacientePickerOpen,
pacienteSearch,
pacientesLoading,
pacientesError,
patients,
cadRapidoOpen,
// billing/services
ensureServicesLoaded,
resetServicesGate,
applyDefaultPrice,
addItem,
removeItem,
onItemChange,
_loadCommitmentItemsForEvent,
onProcedureSelect,
// picker
selectCommitment,
goBack,
openPacientePicker,
clearPatientsCache,
loadPatients,
selectPaciente,
clearPaciente,
openCadastroRapido,
abrirCadastroCompleto
};
}