A66 WIP: AgendaEventDialog quebrado em 5 composables + 265 specs + V2 esqueleto
Sub-sessao 1 entregue (composables): - agendaEventHelpers (262L) — utilitarios puros (date, format, parse) - useAgendaEventComposer (485L) — montagem do form + validacao - useAgendaEventActions (387L) — save/delete/cancel/move actions - useAgendaEventPickerBilling (378L) — pickers (terapeuta, servico, convenio) + calculo de billing - useAgendaEventLifecycle (474L) — open/close/dirty state + autosave - 5 specs em __tests__/ (75+76+28+43+43 = 265 testes), 495/495 passing AgendaEventDialog: 3522 -> 2632 linhas (-25%) consumindo os composables. Backup byte-identico em AgendaEventDialog.vue.bak pra rollback. Sub-sessao 2 entregue (esqueleto, NAO TESTADO): - AgendaEventDialogV2 (~1100L, 3 zonas: PACIENTE/QUANDO/O QUE) - Preview em /preview/agenda-dialog-v2 com 5 cenarios - Rota em routes.misc.js - User testou e nao gostou do design — aguarda feedback especifico pra iteracao na sub-sessao 3 (migracao nos 9 consumers). Dialogs auxiliares novos pro AgendaEventDialog: - InsurancePlanQuickCreateDialog (criar convenio inline) - ServiceQuickCreateDialog (criar tipo de sessao inline) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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;
|
||||
composer.form.value.paciente_id = p.id;
|
||||
composer.form.value.paciente_nome = p.nome || '';
|
||||
composer.form.value.paciente_avatar = p.avatar_url || '';
|
||||
pacientePickerOpen.value = false;
|
||||
}
|
||||
|
||||
function clearPaciente() {
|
||||
composer.form.value.paciente_id = null;
|
||||
composer.form.value.paciente_nome = '';
|
||||
composer.form.value.paciente_avatar = '';
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user