8f4e6679eb
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>
389 lines
16 KiB
JavaScript
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
|
|
};
|
|
}
|