agenda: Fase 5 (status change/edit cobrada) + indicadores visuais + UX convenio
DB
- drop agenda_excecoes (substituida por financial_exceptions + lock-edit
baseado em financial_records)
- financial_records.payment_link (Asaas + link compartilhavel)
- financial_exceptions.consume_on_miss (rotular nao-show consome ou nao)
- billing_contracts.charging_style (upfront/saldo/per_session)
Payment refactor
- paymentSettlement -> paymentMethod (string) + markPaidNow (bool).
Handler aplica payment_method sempre; status='paid'+paid_at apenas
quando markPaidNow=true && method != 'link'. Asaas (link) sempre
liquida via webhook, nunca nasce paid.
- create_financial_record_for_session com pos-RPC patch pra payment_method
e (opcional) status='paid' quando user marca "ja recebi".
Indicadores visuais (3 canais distintos por estado)
- Paid: barra esquerda emerald-500 4px na agenda (MelissaAgenda),
pi-check-circle no popover/Resumo.
- Pending: badge \$ amber canto direito (mantido); linha amber no popover/
Resumo "A receber R\$ X (cobranca pendente)".
- Neutro: sem badge nem barra (compromisso pessoal, bloqueio, ou
ocorrencia virtual de pacote upfront/saldo).
- Bulk-load de paymentState em _reloadRange etapa 4 (1 query unica em
financial_records mapeada por agenda_evento_id).
- AgendaEventDialog Resumo lateral ganha linha entre pi-clock e
pi-map-marker via novo sessionPaymentRecord (sem guard de
occurrenceMode, contrario ao occFinancialRecord que continua so pra
Rail/Clinica). 5 estados: paid+paid_at, overdue+venceu, pending+vence,
sem cobranca c/ valor, sem cobranca s/ valor.
UX de convenio
- InsurancePlanServiceQuickCreateDialog novo: cadastra procedimento
POR CIMA do AgendaEventDialog sem sair da agenda. Auto-seleciona
novo procedimento so quando nada estava selecionado antes.
- Caixa cinza "Cadastrar procedimento" sempre visivel quando convenio
selecionado, com copy variavel (0 procedimentos: chamada urgente;
1+: "se quiser adicionar mais").
- "+ Novo convenio" toolbar em ConfiguracoesConveniosPage (botao
estava faltando, empty state mandava clicar em botao inexistente).
- Hint contextual abaixo do card Sessao/Honorarios: convenio = "N da
guia eh opcional", gratuito = "sem cobranca", particular = sem hint.
Label "N da Guia" tambem ganhou "(opcional)" no service-picker dialog.
Bug fixes
- pickDbFields whitelist faltando 'modalidade' (useMelissaAgenda.js:74)
— sessoes avulsas eram salvas como presencial independente da
escolha visual. Adicionado.
- goToConveniosConfig removida — fazia router.push("/therapist/
configuracoes/convenios") mas /configuracoes/* eh rota raiz, nao
filha. Substituida pelo quick-create inline (#1).
- bloqueioCobrindo + dialogBlockOverlap passados via deps em
_buildHandlers (refs do useMelissaAgenda nao sao acessiveis no
escopo de _buildHandlers).
Fase 5 (status change + edit cobrada)
- AgendaStatusChangeConfirmDialog: confirm dialog quando user muda
status pra realizada/faltou/cancelado, com opcoes de markPaid ou
gerar cobranca conforme o caso.
- useAgendaBloqueios novo composable: extrai logica de bloqueios
cinza (background events) do MelissaAgenda.
Doc viva
- src/docs/agenda-compromisso-financeiro-cenarios.html: 13 cenarios
de teste manual. C1-C4 ja validados. Cada teste validado vira parte
da doc final pra area de ajuda (pos-Fase 9).
Wiki/handoff
- agenda-compromisso-fluxo e agenda-billing-pesquisa-mercado (decisoes
arquiteturais sobre billing).
- HANDOFF.md atualizado.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,474 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/agenda/components/AgendaStatusChangeConfirmDialog.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
/*
|
||||
* AgendaStatusChangeConfirmDialog — Dialog que aparece ao mudar status
|
||||
* de uma sessão (realizado/faltou/cancelado). Mostra defaults vindos da
|
||||
* config (financial_exceptions, billing_contracts) e permite override
|
||||
* caso a caso pelo terapeuta/clínica antes de aplicar.
|
||||
*
|
||||
* 5 variantes de renderização baseadas em (novoStatus, eventoContext):
|
||||
* - faltou/cancelado + avulsa → bloco multa
|
||||
* - faltou/cancelado + pacote saldo → bloco saldo + bloco multa
|
||||
* - faltou/cancelado + pacote upfront → bloco multa (saldo n/a)
|
||||
* - realizado + avulsa pendente → bloco "registrar pagamento"
|
||||
* - realizado + pacote saldo → bloco "gerar cobrança no pacote"
|
||||
*
|
||||
* Emit 'confirm' com objeto descrevendo o que o handler deve fazer:
|
||||
* {
|
||||
* consumeSaldo: bool, // só relevante em pacote saldo + faltou/cancelado
|
||||
* applyFine: bool, // se vai cobrar multa
|
||||
* fineAmount: number|null, // valor da multa (editavel)
|
||||
* markPaid: bool, // realizado avulsa → marcar paga
|
||||
* paymentMethod: string, // método se markPaid ou gerar cobrança
|
||||
* generatePackageCharge: bool // realizado em pacote saldo → criar record
|
||||
* }
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
evento: { type: Object, default: null },
|
||||
novoStatus: { type: String, default: '' }, // 'realizado' | 'faltou' | 'cancelado'
|
||||
regraExcecao: { type: Object, default: null }, // row de financial_exceptions ou null
|
||||
billingContract: { type: Object, default: null }, // row de billing_contracts ou null
|
||||
billingContractStyle: { type: String, default: null }, // 'upfront' | 'saldo' | null
|
||||
// Quando avulsa+pendente e novoStatus='realizado': financial_record relacionado
|
||||
pendingRecord: { type: Object, default: null },
|
||||
// Preço da sessão (pra calcular multa percentual e cobrança de pacote saldo)
|
||||
sessionPrice: { type: Number, default: 0 }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm']);
|
||||
|
||||
// ─── State interno (refs editáveis) ─────────────────────────────────────
|
||||
const consumeSaldo = ref(false);
|
||||
const applyFine = ref(false);
|
||||
const fineAmount = ref(0);
|
||||
const markPaid = ref(true); // default em realizado: já recebeu
|
||||
const paymentMethod = ref('pix'); // método em "como recebeu" ou "como cobrar"
|
||||
const generatePackageCharge = ref(true); // realizado em pacote saldo: gera por padrão
|
||||
|
||||
// Reset/init ao abrir
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
// Defaults baseados em context
|
||||
consumeSaldo.value = !!props.regraExcecao?.default_consume_on_miss;
|
||||
applyFine.value = _calcInitialFineApply();
|
||||
fineAmount.value = _calcInitialFineAmount();
|
||||
markPaid.value = true;
|
||||
paymentMethod.value = 'pix';
|
||||
generatePackageCharge.value = true;
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Computeds: o que renderizar ────────────────────────────────────────
|
||||
const isFaltouOrCancelado = computed(() => props.novoStatus === 'faltou' || props.novoStatus === 'cancelado');
|
||||
const isRealizado = computed(() => props.novoStatus === 'realizado');
|
||||
const isAvulsa = computed(() => !props.billingContract);
|
||||
const isPacoteSaldo = computed(() => props.billingContract?.type === 'package' && props.billingContractStyle === 'saldo');
|
||||
const isPacoteUpfront = computed(() => props.billingContract?.type === 'package' && props.billingContractStyle === 'upfront');
|
||||
|
||||
// Mostrar bloco multa: faltou/cancelado + regra existe + charge_mode != 'none'
|
||||
const showFineBlock = computed(() => isFaltouOrCancelado.value && props.regraExcecao && props.regraExcecao.charge_mode !== 'none');
|
||||
|
||||
// Mostrar bloco saldo: faltou/cancelado + pacote saldo
|
||||
const showSaldoBlock = computed(() => isFaltouOrCancelado.value && isPacoteSaldo.value);
|
||||
|
||||
// Mostrar bloco "registrar pagamento": realizado + avulsa pendente
|
||||
const showRegistrarPagto = computed(() => isRealizado.value && isAvulsa.value && props.pendingRecord && props.pendingRecord.status === 'pending');
|
||||
|
||||
// Mostrar bloco "cobrança no pacote": realizado + pacote saldo
|
||||
const showCobrancaPacote = computed(() => isRealizado.value && isPacoteSaldo.value);
|
||||
|
||||
// ─── Header ────────────────────────────────────────────────────────────
|
||||
const headerTitle = computed(() => {
|
||||
const labels = { realizado: '✓ Marcar como Realizado', faltou: '⚠ Marcar como Faltou', cancelado: '✕ Marcar como Cancelado' };
|
||||
return labels[props.novoStatus] || 'Atualizar status';
|
||||
});
|
||||
|
||||
// ─── Sub-info do evento ────────────────────────────────────────────────
|
||||
const pacienteNome = computed(() => props.evento?.paciente_nome || props.evento?.patients?.nome_completo || 'Paciente');
|
||||
const dataHora = computed(() => {
|
||||
const ini = props.evento?.inicio_em;
|
||||
if (!ini) return '';
|
||||
try {
|
||||
const d = new Date(ini);
|
||||
return d.toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const isSerieSemPacote = computed(() => {
|
||||
// Série recorrente que não criou billing_contract (chargeMode='none' no save).
|
||||
// Detecta via recurrence_id/serie_id na row sem contract carregado.
|
||||
if (props.billingContract) return false;
|
||||
const e = props.evento;
|
||||
return !!(e?.recurrence_id || e?.serie_id || e?.is_occurrence);
|
||||
});
|
||||
|
||||
const tipoTag = computed(() => {
|
||||
if (isPacoteSaldo.value) return `Pacote saldo: ${props.billingContract.sessions_used ?? 0} de ${props.billingContract.total_sessions ?? '?'} usadas`;
|
||||
if (isPacoteUpfront.value) return `Pacote upfront: R$ ${_fmtBRL(props.billingContract.package_price)} pago`;
|
||||
if (isSerieSemPacote.value) return 'Série recorrente (sem pacote)';
|
||||
if (props.pendingRecord) return `Avulsa: R$ ${_fmtBRL(props.pendingRecord.final_amount || props.pendingRecord.amount)} pendente`;
|
||||
return 'Avulsa';
|
||||
});
|
||||
|
||||
// ─── Labels e options ──────────────────────────────────────────────────
|
||||
const paymentMethodOptions = [
|
||||
{ value: 'pix', label: 'PIX' },
|
||||
{ value: 'dinheiro', label: 'Dinheiro' },
|
||||
{ value: 'deposito', label: 'Depósito' },
|
||||
{ value: 'cartao_maquininha', label: 'Cartão (maquininha)' }
|
||||
];
|
||||
const paymentMethodOptionsCobranca = [
|
||||
{ value: 'link', label: 'Enviar link de pagamento (Asaas)' },
|
||||
{ value: 'pix', label: 'Já recebi — PIX' },
|
||||
{ value: 'dinheiro', label: 'Já recebi — Dinheiro' },
|
||||
{ value: 'deposito', label: 'Já recebi — Depósito' },
|
||||
{ value: 'cartao_maquininha', label: 'Já recebi — Cartão (maquininha)' }
|
||||
];
|
||||
|
||||
const regraResumo = computed(() => {
|
||||
const r = props.regraExcecao;
|
||||
if (!r) return '';
|
||||
if (r.charge_mode === 'full') return `Sessão completa = R$ ${_fmtBRL(props.sessionPrice)}`;
|
||||
if (r.charge_mode === 'fixed_fee') return `Taxa fixa: R$ ${_fmtBRL(r.charge_value)}`;
|
||||
if (r.charge_mode === 'percentage') return `${r.charge_pct}% da sessão = R$ ${_fmtBRL((props.sessionPrice * (r.charge_pct ?? 0)) / 100)}`;
|
||||
return 'Sem cobrança';
|
||||
});
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────
|
||||
function _fmtBRL(v) {
|
||||
return Number(v ?? 0).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function _calcInitialFineAmount() {
|
||||
const r = props.regraExcecao;
|
||||
if (!r) return 0;
|
||||
if (r.charge_mode === 'full') return Number(props.sessionPrice) || 0;
|
||||
if (r.charge_mode === 'fixed_fee') return Number(r.charge_value) || 0;
|
||||
if (r.charge_mode === 'percentage') return parseFloat((((Number(props.sessionPrice) || 0) * (Number(r.charge_pct) || 0)) / 100).toFixed(2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
function _calcInitialFineApply() {
|
||||
// Se regra existe e charge_mode != 'none': default true
|
||||
// Exceção: cancelado fora da janela min_hours_notice → default false (paciente cancelou dentro do prazo)
|
||||
const r = props.regraExcecao;
|
||||
if (!r || r.charge_mode === 'none') return false;
|
||||
if (props.novoStatus === 'cancelado' && r.min_hours_notice != null && props.evento?.inicio_em) {
|
||||
const horasAteSessao = (new Date(props.evento.inicio_em).getTime() - Date.now()) / (1000 * 60 * 60);
|
||||
// Se cancelou COM MAIS antecedência que min → sem multa por padrão
|
||||
if (horasAteSessao >= Number(r.min_hours_notice)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Actions ───────────────────────────────────────────────────────────
|
||||
function onConfirm() {
|
||||
emit('confirm', {
|
||||
consumeSaldo: showSaldoBlock.value ? consumeSaldo.value : false,
|
||||
applyFine: showFineBlock.value ? applyFine.value : false,
|
||||
fineAmount: showFineBlock.value && applyFine.value ? Number(fineAmount.value) || 0 : 0,
|
||||
markPaid: showRegistrarPagto.value ? markPaid.value : false,
|
||||
paymentMethod: paymentMethod.value,
|
||||
generatePackageCharge: showCobrancaPacote.value ? generatePackageCharge.value : false
|
||||
});
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="modelValue"
|
||||
modal
|
||||
:draggable="false"
|
||||
:style="{ width: '480px', maxWidth: '96vw' }"
|
||||
:breakpoints="{ '640px': '98vw' }"
|
||||
@update:visible="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="asccd-head">
|
||||
<div class="asccd-head__title">{{ headerTitle }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Resumo do evento -->
|
||||
<div class="asccd-summary">
|
||||
<div class="asccd-summary__name">{{ pacienteNome }}</div>
|
||||
<div class="asccd-summary__meta">
|
||||
<span v-if="dataHora">{{ dataHora }}</span>
|
||||
<span class="asccd-summary__tag">{{ tipoTag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Bloco SALDO (pacote saldo + faltou/cancelado) ────────── -->
|
||||
<div v-if="showSaldoBlock" class="asccd-block">
|
||||
<div class="asccd-block__title">
|
||||
<i class="pi pi-wallet" />
|
||||
O que fazer com a vaga do pacote?
|
||||
</div>
|
||||
<div class="asccd-radio-group">
|
||||
<label class="asccd-radio">
|
||||
<input type="radio" :value="false" v-model="consumeSaldo" />
|
||||
<span><b>Remarcar</b> — não consome saldo (paciente pode remarcar)</span>
|
||||
</label>
|
||||
<label class="asccd-radio">
|
||||
<input type="radio" :value="true" v-model="consumeSaldo" />
|
||||
<span><b>Descontar</b> — sessão perdida ({{ billingContract?.sessions_used ?? 0 }} → {{ (billingContract?.sessions_used ?? 0) + 1 }})</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Bloco MULTA (faltou/cancelado com regra configurada) ── -->
|
||||
<div v-if="showFineBlock" class="asccd-block">
|
||||
<div class="asccd-block__title">
|
||||
<i class="pi pi-money-bill" />
|
||||
Aplicar multa?
|
||||
</div>
|
||||
<div class="asccd-fine-rule">Regra atual: {{ regraResumo }}</div>
|
||||
<div class="asccd-fine-row">
|
||||
<Checkbox v-model="applyFine" inputId="asccd-apply-fine" binary />
|
||||
<label for="asccd-apply-fine" class="cursor-pointer">Aplicar multa</label>
|
||||
<InputNumber
|
||||
v-model="fineAmount"
|
||||
:disabled="!applyFine"
|
||||
mode="currency"
|
||||
currency="BRL"
|
||||
locale="pt-BR"
|
||||
:min="0"
|
||||
size="small"
|
||||
class="asccd-fine-input"
|
||||
/>
|
||||
</div>
|
||||
<small v-if="isPacoteUpfront" class="asccd-hint">
|
||||
ℹ Pacote já pago; multa entra como cobrança adicional avulsa.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Pacote upfront sem multa configurada: aviso -->
|
||||
<div v-if="isFaltouOrCancelado && isPacoteUpfront && !showFineBlock" class="asccd-info">
|
||||
<i class="pi pi-info-circle" />
|
||||
Pacote já foi pago. Nenhuma multa configurada — só atualiza o status.
|
||||
</div>
|
||||
|
||||
<!-- ─── Bloco REGISTRAR PAGAMENTO (realizado + avulsa pendente) ── -->
|
||||
<div v-if="showRegistrarPagto" class="asccd-block">
|
||||
<div class="asccd-block__title">
|
||||
<i class="pi pi-check-circle" />
|
||||
A sessão foi paga?
|
||||
</div>
|
||||
<div class="asccd-radio-group">
|
||||
<label class="asccd-radio">
|
||||
<input type="radio" :value="false" v-model="markPaid" />
|
||||
<span>Não, manter cobrança pendente</span>
|
||||
</label>
|
||||
<label class="asccd-radio">
|
||||
<input type="radio" :value="true" v-model="markPaid" />
|
||||
<span>Sim, registrar pagamento</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="markPaid" class="asccd-method-row">
|
||||
<label class="asccd-method-label">Como recebeu?</label>
|
||||
<Select
|
||||
v-model="paymentMethod"
|
||||
:options="paymentMethodOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
size="small"
|
||||
class="asccd-method-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Bloco COBRANÇA NO PACOTE (realizado + pacote saldo) ──── -->
|
||||
<div v-if="showCobrancaPacote" class="asccd-block">
|
||||
<div class="asccd-block__title">
|
||||
<i class="pi pi-money-bill" />
|
||||
Gerar cobrança no pacote?
|
||||
</div>
|
||||
<div class="asccd-fine-rule">Valor da sessão: R$ {{ _fmtBRL(sessionPrice) }}</div>
|
||||
<div class="asccd-fine-row">
|
||||
<Checkbox v-model="generatePackageCharge" inputId="asccd-gen-charge" binary />
|
||||
<label for="asccd-gen-charge" class="cursor-pointer">Gerar cobrança e consumir 1 sessão</label>
|
||||
</div>
|
||||
<div v-if="generatePackageCharge" class="asccd-method-row">
|
||||
<label class="asccd-method-label">Como cobrar?</label>
|
||||
<Select
|
||||
v-model="paymentMethod"
|
||||
:options="paymentMethodOptionsCobranca"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
size="small"
|
||||
class="asccd-method-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" outlined size="small" @click="onCancel" />
|
||||
<Button label="Confirmar" icon="pi pi-check" size="small" @click="onConfirm" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.asccd-head__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.asccd-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--p-primary-500) 4%, var(--surface-card));
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
.asccd-summary__name {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.asccd-summary__meta {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.asccd-summary__tag {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--p-primary-500) 12%, transparent);
|
||||
color: var(--p-primary-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.asccd-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.asccd-block__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.asccd-block__title i {
|
||||
color: var(--p-primary-color);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.asccd-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.asccd-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.asccd-radio input[type='radio'] {
|
||||
accent-color: var(--p-primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.asccd-fine-rule {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-color-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
.asccd-fine-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.asccd-fine-input {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.asccd-method-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.asccd-method-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.asccd-method-select {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.asccd-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--blue-400, #60a5fa) 8%, var(--surface-card));
|
||||
border: 1px solid color-mix(in srgb, var(--blue-400, #60a5fa) 30%, transparent);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.asccd-info i {
|
||||
color: var(--blue-600, #2563eb);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asccd-hint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -93,7 +93,6 @@ async function onSave() {
|
||||
:closable="!saving"
|
||||
header="Novo convênio"
|
||||
class="w-[94vw] max-w-md"
|
||||
pt:mask:class="backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-3 pt-1">
|
||||
<FloatLabel variant="on">
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/agenda/components/InsurancePlanServiceQuickCreateDialog.vue
|
||||
| Data: 2026-05-18
|
||||
|
|
||||
| Mini-dialog pra cadastrar procedimento dentro de um convênio SEM sair
|
||||
| do AgendaEventDialog. Mesmo pattern do InsurancePlanQuickCreateDialog —
|
||||
| emite `created` com a row inserida; o parent recarrega os planos e
|
||||
| (opcionalmente) auto-seleciona o novo procedimento.
|
||||
|
|
||||
| Campos:
|
||||
| name * — Ex: "Consulta psicológica", "Avaliação", "Sessão online"
|
||||
| value * — Valor que o convênio paga pra clínica nesse procedimento
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
insurancePlanId: { type: String, default: '' },
|
||||
insurancePlanName: { type: String, default: '' }
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue', 'created']);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const visible = ref(props.modelValue);
|
||||
watch(() => props.modelValue, (v) => { visible.value = v; });
|
||||
watch(visible, (v) => emit('update:modelValue', v));
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
value: null
|
||||
});
|
||||
const saving = ref(false);
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v) {
|
||||
form.value = { name: '', value: null };
|
||||
}
|
||||
});
|
||||
|
||||
const canSave = () => !!form.value.name?.trim() && form.value.value != null && form.value.value > 0;
|
||||
|
||||
async function onSave() {
|
||||
if (!canSave()) return;
|
||||
if (!props.insurancePlanId) {
|
||||
toast.add({ severity: 'error', summary: 'Convênio ausente', detail: 'Selecione um convênio antes de cadastrar procedimento.', life: 3500 });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
insurance_plan_id: props.insurancePlanId,
|
||||
name: form.value.name.trim().slice(0, 120),
|
||||
value: Number(form.value.value),
|
||||
active: true
|
||||
};
|
||||
const { data, error } = await supabase.from('insurance_plan_services').insert(payload).select().single();
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Procedimento cadastrado', life: 2200 });
|
||||
emit('created', data);
|
||||
visible.value = false;
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Falha ao criar procedimento', detail: e?.message || 'Erro inesperado', life: 4000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!saving"
|
||||
class="w-[94vw] max-w-md"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base font-semibold">Novo procedimento</span>
|
||||
<span v-if="insurancePlanName" class="text-xs text-color-secondary">{{ insurancePlanName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-3 pt-1">
|
||||
<FloatLabel variant="on">
|
||||
<InputText id="ips-name" v-model="form.name" class="w-full" autofocus maxlength="120" />
|
||||
<label for="ips-name">Nome do procedimento *</label>
|
||||
</FloatLabel>
|
||||
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
id="ips-value"
|
||||
v-model="form.value"
|
||||
mode="currency"
|
||||
currency="BRL"
|
||||
locale="pt-BR"
|
||||
:min="0"
|
||||
:max="999999"
|
||||
class="w-full"
|
||||
/>
|
||||
<label for="ips-value">Valor que o convênio paga *</label>
|
||||
</FloatLabel>
|
||||
|
||||
<p class="text-xs text-color-secondary -mt-1">
|
||||
<i class="pi pi-info-circle mr-1" />
|
||||
Esse é o valor que a clínica recebe — não o que o paciente paga.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="visible = false" />
|
||||
<Button
|
||||
label="Cadastrar"
|
||||
icon="pi pi-check"
|
||||
:loading="saving"
|
||||
:disabled="!canSave()"
|
||||
class="rounded-full"
|
||||
@click="onSave"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -110,7 +110,6 @@ async function onSave() {
|
||||
:closable="!saving"
|
||||
header="Novo serviço"
|
||||
class="w-[94vw] max-w-md"
|
||||
pt:mask:class="backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-3 pt-1">
|
||||
<FloatLabel variant="on">
|
||||
|
||||
@@ -159,9 +159,10 @@ describe('isFirstOccurrence', () => {
|
||||
});
|
||||
|
||||
describe('editScopeOptions', () => {
|
||||
it('retorna 4 opções', () => {
|
||||
it('retorna 3 opções (todos_sem_excecao removido da UI em 2026-05-12)', () => {
|
||||
const { composer } = setup();
|
||||
expect(composer.editScopeOptions.value).toHaveLength(4);
|
||||
expect(composer.editScopeOptions.value).toHaveLength(3);
|
||||
expect(composer.editScopeOptions.value.map((o) => o.value)).toEqual(['somente_este', 'este_e_seguintes', 'todos']);
|
||||
});
|
||||
it('"este_e_seguintes" disabled quando isFirstOccurrence', () => {
|
||||
const serieEvents = ref([{ recurrence_date: '2026-05-15' }, { recurrence_date: '2026-05-22' }]);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/agenda/composables/useAgendaBloqueios.js
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
// useAgendaBloqueios
|
||||
// Carrega e expõe rows de public.agenda_bloqueios + computed de events
|
||||
// background pra renderizar no FullCalendar (cinza). Usado pelos 3 layouts
|
||||
// da agenda (Melissa, Terapeuta/Rail, Clínica).
|
||||
//
|
||||
// Contrato:
|
||||
// - load(ownerId, rangeStart, rangeEnd)
|
||||
// carrega bloqueios cujo data_inicio esteja dentro do range OU que
|
||||
// sejam recorrentes (data_inicio pode estar em qualquer ponto, mas
|
||||
// o build de events filtra pra emitir só dentro do range visível)
|
||||
// - bloqueios ref(Array)
|
||||
// - loading ref(Bool)
|
||||
// - error ref(String)
|
||||
// - buildEventsForRange(rangeStart, rangeEnd)
|
||||
// wrapper sobre agendaMappers.buildBloqueioBackgroundEvents
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { buildBloqueioBackgroundEvents } from '@/features/agenda/services/agendaMappers';
|
||||
|
||||
export function useAgendaBloqueios() {
|
||||
const bloqueios = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
// ownerIdOrIds: string (1 owner) ou Array<string> (multi-owner, Clínica).
|
||||
async function load(ownerIdOrIds, rangeStart, rangeEnd) {
|
||||
if (!ownerIdOrIds) return;
|
||||
const ids = Array.isArray(ownerIdOrIds)
|
||||
? ownerIdOrIds.filter(Boolean)
|
||||
: [ownerIdOrIds];
|
||||
if (!ids.length) return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const isoStart = _toISODate(rangeStart);
|
||||
const isoEnd = _toISODate(rangeEnd);
|
||||
|
||||
// Query: recorrentes (qualquer data) OU não-recorrentes com
|
||||
// data_inicio <= isoEnd e (data_fim ?? data_inicio) >= isoStart.
|
||||
// 2 queries simples + merge pra evitar string-building frágil.
|
||||
const baseNonRec = supabase
|
||||
.from('agenda_bloqueios')
|
||||
.select('*')
|
||||
.eq('recorrente', false)
|
||||
.lte('data_inicio', isoEnd)
|
||||
.or(`data_fim.gte.${isoStart},and(data_fim.is.null,data_inicio.gte.${isoStart})`);
|
||||
const baseRec = supabase
|
||||
.from('agenda_bloqueios')
|
||||
.select('*')
|
||||
.eq('recorrente', true);
|
||||
|
||||
const [{ data: nonRec, error: e1 }, { data: rec, error: e2 }] = await Promise.all([
|
||||
ids.length === 1 ? baseNonRec.eq('owner_id', ids[0]) : baseNonRec.in('owner_id', ids),
|
||||
ids.length === 1 ? baseRec.eq('owner_id', ids[0]) : baseRec.in('owner_id', ids)
|
||||
]);
|
||||
if (e1) throw e1;
|
||||
if (e2) throw e2;
|
||||
bloqueios.value = [...(nonRec || []), ...(rec || [])];
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar bloqueios.';
|
||||
bloqueios.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildEventsForRange(rangeStart, rangeEnd) {
|
||||
return buildBloqueioBackgroundEvents(bloqueios.value, rangeStart, rangeEnd);
|
||||
}
|
||||
|
||||
return { bloqueios, loading, error, load, buildEventsForRange };
|
||||
}
|
||||
|
||||
function _toISODate(d) {
|
||||
if (!d) return null;
|
||||
const dt = d instanceof Date ? d : new Date(d);
|
||||
const y = dt.getFullYear();
|
||||
const m = String(dt.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(dt.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
@@ -48,6 +48,25 @@ export function useAgendaEventActions({
|
||||
servicePickerSel,
|
||||
selectedPlanService,
|
||||
saveCommitmentItems,
|
||||
// chargeMode (Opção C1, 2026-05-13): ref string com modo de cobrança.
|
||||
// Valores: 'none' | 'session' (avulsa) | 'package' | 'per_session' (recorrente).
|
||||
// O emit('save') leva chargeMode no payload; handler em useMelissaAgenda
|
||||
// decide o que criar (financial_record | billing_contract | N events+records).
|
||||
// Substituiu o boolean gerarCobrancaAoSalvar.
|
||||
chargeMode,
|
||||
// packageStyle (2026-05-14): só relevante em chargeMode='package'.
|
||||
// Valores: 'upfront' (cria 1 financial_record total + materializa 1ª ocorrência)
|
||||
// | 'saldo' (só billing_contract, sem financial_record imediato — Cliniko).
|
||||
packageStyle,
|
||||
// paymentMethod (refatorado 2026-05-16): forma de recebimento quando
|
||||
// avulsa+session OU pacote+upfront. Valores: 'link' (Asaas, status pending)
|
||||
// | 'pix' | 'dinheiro' | 'deposito' | 'cartao_maquininha'. Status do
|
||||
// record é controlado pelo markPaidNow abaixo, não pela forma.
|
||||
paymentMethod,
|
||||
// markPaidNow (refatorado 2026-05-16): boolean. Quando true E método !== 'link',
|
||||
// handler marca o financial_record como paid (paciente pagou na hora).
|
||||
// Quando false, record nasce pending independente do método.
|
||||
markPaidNow,
|
||||
props,
|
||||
emit
|
||||
}) {
|
||||
@@ -62,88 +81,88 @@ export function useAgendaEventActions({
|
||||
const samePatientConflict = ref(null);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// 1. Watcher do form.status — confirma cancelar/remarcar via dialog
|
||||
// e persiste no banco IMEDIATAMENTE. Reverte se cancelar.
|
||||
// Antes vivia no .vue; testado em isolamento agora.
|
||||
// 1. Watcher do form.status
|
||||
// Fase 5 (2026-05-14): pra realizado/faltou/cancelado, emit
|
||||
// `updateSeriesEvent` pro parent abrir o AgendaStatusChangeConfirmDialog
|
||||
// (com regras de exceção, saldo de pacote, etc). Sem confirm.require
|
||||
// aqui — o dialog do parent é a fonte canônica.
|
||||
// Pra remarcado mantém path antigo (confirm.require simples).
|
||||
// Se user cancelar o dialog: parent chama onReject pra reverter o form.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
watch(
|
||||
() => composer.form.value?.status,
|
||||
async (newVal, oldVal) => {
|
||||
if (_skipStatusWatch.value) return;
|
||||
if (!composer.isEdit.value || !composer.form.value?.id) return;
|
||||
if (newVal !== 'cancelado' && newVal !== 'remarcado') return;
|
||||
|
||||
const isStatusComDialog = ['realizado', 'faltou', 'cancelado'].includes(newVal);
|
||||
const isRemarcado = newVal === 'remarcado';
|
||||
if (!isStatusComDialog && !isRemarcado) return;
|
||||
|
||||
_prevStatus.value = oldVal;
|
||||
const isCancelar = newVal === 'cancelado';
|
||||
|
||||
// Fase 5: emit pro parent abrir AgendaStatusChangeConfirmDialog.
|
||||
// Parent decide o que fazer e chama onReject() se user cancelar.
|
||||
if (isStatusComDialog) {
|
||||
const formId = composer.form.value.id;
|
||||
const isVirtual = !!composer.form.value.is_occurrence || (typeof formId === 'string' && formId.startsWith('rec::'));
|
||||
emit('updateSeriesEvent', {
|
||||
id: isVirtual ? null : formId,
|
||||
status: newVal,
|
||||
recurrence_date:
|
||||
composer.form.value.recurrence_date ||
|
||||
composer.form.value.original_date ||
|
||||
String(composer.form.value.inicio_em || '').slice(0, 10),
|
||||
inicio_em: composer.form.value.inicio_em,
|
||||
fim_em: composer.form.value.fim_em,
|
||||
is_virtual: isVirtual,
|
||||
// Form completo — handler usa pra resolver recurrence_id, billing_contract_id, etc
|
||||
row: { ...composer.form.value },
|
||||
// Callback pra reverter status no form se user cancelar o dialog do parent.
|
||||
// _skipStatusWatch evita loop recursivo no watcher.
|
||||
onReject: () => {
|
||||
_skipStatusWatch.value = true;
|
||||
composer.form.value.status = _prevStatus.value;
|
||||
Promise.resolve().then(() => {
|
||||
_skipStatusWatch.value = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Path legacy pra 'remarcado': confirm.require simples + UPDATE direto.
|
||||
confirm.require({
|
||||
header: isCancelar ? 'Cancelar sessão' : 'Remarcar sessão',
|
||||
message: isCancelar
|
||||
? 'Tem certeza que deseja cancelar esta sessão? O status será salvo imediatamente.'
|
||||
: 'Tem certeza que deseja marcar esta sessão para remarcação? O status será salvo imediatamente.',
|
||||
icon: isCancelar ? 'pi pi-times-circle' : 'pi pi-refresh',
|
||||
header: 'Remarcar sessão',
|
||||
message: 'Tem certeza que deseja marcar esta sessão para remarcação? O status será salvo imediatamente.',
|
||||
icon: 'pi pi-refresh',
|
||||
acceptLabel: 'Sim, confirmar',
|
||||
rejectLabel: 'Não',
|
||||
acceptSeverity: isCancelar ? 'danger' : 'warn',
|
||||
acceptSeverity: 'warn',
|
||||
accept: async () => {
|
||||
try {
|
||||
// Se o evento é ocorrência VIRTUAL de recorrência
|
||||
// (id "rec::..." sem row real em agenda_eventos),
|
||||
// delega pro parent — useMelissaAgenda.onUpdateSeriesEvent
|
||||
// e AgendaTerapeutaPage.onUpdateSeriesEvent materializam
|
||||
// a linha antes de aplicar status. Sem essa delegação,
|
||||
// UPDATE direto em id virtual quebra com PostgreSQL
|
||||
// "invalid input syntax for type uuid".
|
||||
const formId = composer.form.value.id;
|
||||
const isVirtual =
|
||||
!!composer.form.value.is_occurrence ||
|
||||
(typeof formId === 'string' && formId.startsWith('rec::'));
|
||||
const isVirtual = !!composer.form.value.is_occurrence || (typeof formId === 'string' && formId.startsWith('rec::'));
|
||||
|
||||
if (isVirtual) {
|
||||
emit('updateSeriesEvent', {
|
||||
id: null, // sem row real
|
||||
id: null,
|
||||
status: newVal,
|
||||
recurrence_date:
|
||||
composer.form.value.recurrence_date ||
|
||||
composer.form.value.original_date ||
|
||||
String(composer.form.value.inicio_em || '').slice(0, 10),
|
||||
recurrence_date: composer.form.value.recurrence_date || composer.form.value.original_date || String(composer.form.value.inicio_em || '').slice(0, 10),
|
||||
inicio_em: composer.form.value.inicio_em,
|
||||
fim_em: composer.form.value.fim_em,
|
||||
is_virtual: true,
|
||||
// Form completo do dialog — handler usa pra resolver
|
||||
// recurrence_id/patient_id sem depender de dialogEventRow.
|
||||
row: { ...composer.form.value }
|
||||
});
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Status atualizado',
|
||||
detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`,
|
||||
life: 3000
|
||||
});
|
||||
toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.update({ status: newVal })
|
||||
.eq('id', formId)
|
||||
.select()
|
||||
.single();
|
||||
const { data, error } = await supabase.from('agenda_eventos').update({ status: newVal }).eq('id', formId).select().single();
|
||||
if (error) throw error;
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Status atualizado',
|
||||
detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`,
|
||||
life: 3000
|
||||
});
|
||||
toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
|
||||
emit('updated', data);
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro',
|
||||
detail: e?.message || 'Não foi possível atualizar o status.',
|
||||
life: 4000
|
||||
});
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível atualizar o status.', life: 4000 });
|
||||
composer.form.value.status = _prevStatus.value;
|
||||
}
|
||||
},
|
||||
@@ -327,6 +346,24 @@ export function useAgendaEventActions({
|
||||
editMode: emitEditMode,
|
||||
recurrence_id: emitRecurrenceId,
|
||||
original_date: emitOriginalDate,
|
||||
// _occurrenceMode: flag pra distinguir save do 2o dialog empilhado
|
||||
// (editar UMA ocorrencia) do save do dialog pai. Handler decide qual
|
||||
// dialog fechar — sem isso, fechava sempre o pai. 2026-05-12.
|
||||
_occurrenceMode: !!props.occurrenceMode,
|
||||
// chargeMode (Opção C1, 2026-05-13): handler decide entre criar
|
||||
// financial_record (avulsa+session), billing_contract (recorrente+package)
|
||||
// ou materializar N ocorrências + N records (recorrente+per_session).
|
||||
// UI no .vue garante valores válidos por modo.
|
||||
chargeMode: chargeMode?.value ?? 'none',
|
||||
// packageStyle (2026-05-14): handler em useMelissaAgenda usa pra
|
||||
// decidir entre upfront (1 record total + materializa 1ª) ou
|
||||
// saldo (só contrato).
|
||||
packageStyle: packageStyle?.value ?? 'upfront',
|
||||
// paymentMethod + markPaidNow (refatorado 2026-05-16): substituem
|
||||
// o antigo paymentSettlement. Handler aplica payment_method (sempre)
|
||||
// e status=paid+paid_at apenas quando markPaidNow=true && method!='link'.
|
||||
paymentMethod: paymentMethod?.value ?? 'link',
|
||||
markPaidNow: markPaidNow?.value === true,
|
||||
// legado — mantido para compatibilidade
|
||||
serie_id: props.eventRow?.serie_id ?? null,
|
||||
serviceItems: composer.isSessionEvent.value ? commitmentItems.value.slice() : null,
|
||||
|
||||
@@ -88,11 +88,14 @@ export function useAgendaEventComposer(props, emit, extras = {}) {
|
||||
return false;
|
||||
});
|
||||
|
||||
// 'todos_sem_excecao' removido da UI em 2026-05-12 — padrao SimplePractice
|
||||
// nao expoe override de customizacoes (e destrutivo e raro). Backend ainda
|
||||
// suporta caso outro fluxo precise, mas dialog so oferece os 3 escopos
|
||||
// padrao do mercado.
|
||||
const editScopeOptions = computed(() => [
|
||||
{ value: 'somente_este', label: 'Somente esta sessão' },
|
||||
{ value: 'este_e_seguintes', label: 'Esta e as seguintes', disabled: isFirstOccurrence.value },
|
||||
{ value: 'todos', label: 'Todas da série' },
|
||||
{ value: 'todos_sem_excecao', label: 'Todas sem exceção' }
|
||||
{ value: 'todos', label: 'Todas da série' }
|
||||
]);
|
||||
|
||||
// ── 4. Recorrência (criação) ───────────────────────────────────
|
||||
|
||||
@@ -96,6 +96,21 @@ export function useAgendaEventLifecycle({
|
||||
const sendingReminder = ref(false);
|
||||
const serviceQuickDlgOpen = ref(false);
|
||||
const insuranceQuickDlgOpen = ref(false);
|
||||
const planServiceQuickDlgOpen = ref(false);
|
||||
|
||||
// occurrenceMode: financial_record da ocorrencia atual (se existir).
|
||||
// Usado pra travar edicao de tipo/servicos quando ja ha cobranca emitida
|
||||
// (padrao SimplePractice — cobranca emitida e imutavel; ajustes via fluxo
|
||||
// do Financeiro, nao via dialog). 2026-05-12.
|
||||
const occFinancialRecord = ref(null);
|
||||
const occFinancialLoading = ref(false);
|
||||
|
||||
// sessionPaymentRecord (2026-05-18): financial_record da sessão (mesmo
|
||||
// shape do occFinancialRecord) mas SEM o guard de occurrenceMode.
|
||||
// Carregado em qualquer edit de sessão pra alimentar a linha "Cobrança"
|
||||
// do Resumo lateral do AgendaEventDialog. Não dispara lock — esse
|
||||
// continua via occFinancialRecord (território da Fase 6/C13).
|
||||
const sessionPaymentRecord = ref(null);
|
||||
|
||||
// ── computeds locais ───────────────────────────────────────
|
||||
const serieCountByStatus = computed(() => {
|
||||
@@ -192,6 +207,56 @@ export function useAgendaEventLifecycle({
|
||||
}
|
||||
}
|
||||
|
||||
// ── occurrence financial record loader ────────────────────
|
||||
async function loadOccFinancialRecord() {
|
||||
occFinancialRecord.value = null;
|
||||
if (!props.occurrenceMode) return;
|
||||
const evId = props.eventRow?.id;
|
||||
if (!evId) return;
|
||||
occFinancialLoading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
|
||||
.eq('agenda_evento_id', evId)
|
||||
.in('status', ['pending', 'paid', 'overdue'])
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
occFinancialRecord.value = data ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[occurrence] erro ao carregar financial_record:', e?.message);
|
||||
occFinancialRecord.value = null;
|
||||
} finally {
|
||||
occFinancialLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// sessionPaymentRecord loader (2026-05-18): mesma query, sem guard
|
||||
// de occurrenceMode. Alimenta a linha "Cobrança" do Resumo do dialog
|
||||
// em qualquer edit de sessão (Melissa/Rail/Clínica) com eventRow.id.
|
||||
async function loadSessionPaymentRecord() {
|
||||
sessionPaymentRecord.value = null;
|
||||
const evId = props.eventRow?.id;
|
||||
if (!evId) return;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
|
||||
.eq('agenda_evento_id', evId)
|
||||
.in('status', ['pending', 'paid', 'overdue'])
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
sessionPaymentRecord.value = data ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[session-payment] erro ao carregar financial_record:', e?.message);
|
||||
sessionPaymentRecord.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onPillEditClick(ev) {
|
||||
emit('editSeriesOccurrence', {
|
||||
id: ev.id,
|
||||
@@ -278,6 +343,26 @@ export function useAgendaEventLifecycle({
|
||||
}
|
||||
}
|
||||
|
||||
// Quick-create de procedimento (insurance_plan_services) — inline,
|
||||
// sem sair do dialog. Trigger no card Sessao/Honorarios quando o
|
||||
// convenio selecionado nao tem procedimentos ou quando user quer
|
||||
// adicionar mais. Apos criar, recarrega os planos pra refletir no
|
||||
// computed planServices.
|
||||
function openPlanServiceQuickCreate() {
|
||||
if (!composer.form.value.insurance_plan_id) return;
|
||||
planServiceQuickDlgOpen.value = true;
|
||||
}
|
||||
async function onPlanServiceCreated(service) {
|
||||
await loadInsurancePlans(props.planOwnerId || props.ownerId);
|
||||
// Auto-seleciona o procedimento recem-criado se o user nao
|
||||
// tinha nenhum selecionado ainda (caso comum: convenio sem
|
||||
// procedimentos -> cadastra o primeiro -> ja entra selecionado).
|
||||
if (service?.id && !pickerBilling.selectedPlanService.value) {
|
||||
pickerBilling.selectedPlanService.value = service.id;
|
||||
pickerBilling.onProcedureSelect(service.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── lembrete WhatsApp manual (8.2) ─────────────────────────
|
||||
async function onSendManualReminder() {
|
||||
if (!composer.form.value?.id) return;
|
||||
@@ -349,7 +434,21 @@ export function useAgendaEventLifecycle({
|
||||
if (composer.hasSerie.value) loadSerieEvents();
|
||||
else serieEvents.value = [];
|
||||
|
||||
if (composer.isEdit.value) {
|
||||
// occurrenceMode: carrega financial_record desta ocorrencia
|
||||
// pra decidir se o card Sessao/Honorarios fica locked (cobranca
|
||||
// ja emitida) ou unlocked (sem cobranca, edicao livre).
|
||||
loadOccFinancialRecord();
|
||||
|
||||
// sessionPaymentRecord: carrega em qualquer edit (Melissa
|
||||
// tambem) pra alimentar a linha "Cobrança" do Resumo lateral.
|
||||
loadSessionPaymentRecord();
|
||||
|
||||
// occurrenceMode: editando UMA ocorrencia de serie ja existente —
|
||||
// tipo de compromisso ja foi escolhido (paciente + sessao). Pular
|
||||
// step 1 incondicionalmente. Defesa em camadas: useMelissaAgenda
|
||||
// ja seta is_occurrence=true na row (faz isEdit=true), mas se outro
|
||||
// call site esquecer essa flag o guard aqui salva.
|
||||
if (props.occurrenceMode || composer.isEdit.value) {
|
||||
composer.step.value = 2;
|
||||
} else {
|
||||
const preset = props.presetCommitmentId;
|
||||
@@ -452,11 +551,17 @@ export function useAgendaEventLifecycle({
|
||||
sendingReminder,
|
||||
serviceQuickDlgOpen,
|
||||
insuranceQuickDlgOpen,
|
||||
planServiceQuickDlgOpen,
|
||||
occFinancialRecord,
|
||||
occFinancialLoading,
|
||||
sessionPaymentRecord,
|
||||
// computeds
|
||||
serieCountByStatus,
|
||||
pillDeleteMenuItems,
|
||||
// series
|
||||
loadSerieEvents,
|
||||
loadOccFinancialRecord,
|
||||
loadSessionPaymentRecord,
|
||||
onPillEditClick,
|
||||
onPillStatusChange,
|
||||
onPillDeleteClick,
|
||||
@@ -468,6 +573,8 @@ export function useAgendaEventLifecycle({
|
||||
onServiceCreated,
|
||||
openInsuranceQuickCreate,
|
||||
onInsuranceCreated,
|
||||
openPlanServiceQuickCreate,
|
||||
onPlanServiceCreated,
|
||||
// reminder
|
||||
onSendManualReminder
|
||||
};
|
||||
|
||||
@@ -157,8 +157,16 @@ export function useCommitmentServices() {
|
||||
// Atualiza commitment_services nos agenda_eventos com recurrence_id = ruleId
|
||||
// onde services_customized = false (não foram editados individualmente).
|
||||
//
|
||||
// Invariante adicional (2026-05-12, padrão SimplePractice): NUNCA propaga
|
||||
// para eventos que já têm financial_record emitido (status pending/paid/
|
||||
// overdue). Cobranças emitidas são imutáveis — ajustes só via fluxo do
|
||||
// Financeiro. Sem isso, mudar o template da regra mudaria silenciosamente
|
||||
// o valor referenciado por uma cobrança já entregue ao paciente.
|
||||
//
|
||||
// opts.fromDate: string ISO 'YYYY-MM-DD' — limita a ocorrências a partir
|
||||
// dessa data inclusive (escopo 'este_e_seguintes'). null = todas da série.
|
||||
// opts.ignoreCustomized: bypass do filtro services_customized=false
|
||||
// (escopo 'todos_sem_excecao' operacional — NÃO afeta filtro financeiro).
|
||||
async function propagateToSerie(ruleId, items, { fromDate = null, ignoreCustomized = false } = {}) {
|
||||
if (!ruleId) return;
|
||||
|
||||
@@ -177,8 +185,23 @@ export function useCommitmentServices() {
|
||||
if (queryError) throw queryError;
|
||||
if (!events?.length) return;
|
||||
|
||||
// Filtra OUT eventos que já têm financial_record emitido. Uma query
|
||||
// em batch evita N round-trips. Status considerados imutáveis: pending,
|
||||
// paid, overdue. cancelled é ok propagar (record foi descartado).
|
||||
const eventIds = events.map((e) => e.id);
|
||||
const { data: lockedEvents, error: frErr } = await supabase
|
||||
.from('financial_records')
|
||||
.select('agenda_evento_id')
|
||||
.in('agenda_evento_id', eventIds)
|
||||
.in('status', ['pending', 'paid', 'overdue']);
|
||||
if (frErr) throw frErr;
|
||||
const lockedSet = new Set((lockedEvents || []).map((r) => r.agenda_evento_id));
|
||||
const eligibleEvents = events.filter((ev) => !lockedSet.has(ev.id));
|
||||
|
||||
if (!eligibleEvents.length) return;
|
||||
|
||||
// Para cada evento elegível: delete + insert (padrão idempotente)
|
||||
for (const ev of events) {
|
||||
for (const ev of eligibleEvents) {
|
||||
const { error: delErr } = await supabase.from('commitment_services').delete().eq('commitment_id', ev.id);
|
||||
if (delErr) throw delErr;
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ export function useFinancialExceptions() {
|
||||
charge_mode: payload.charge_mode,
|
||||
charge_value: payload.charge_value ?? null,
|
||||
charge_pct: payload.charge_pct ?? null,
|
||||
min_hours_notice: payload.min_hours_notice ?? null
|
||||
min_hours_notice: payload.min_hours_notice ?? null,
|
||||
default_consume_on_miss: !!payload.default_consume_on_miss
|
||||
})
|
||||
.eq('id', payload.id);
|
||||
if (err) throw err;
|
||||
@@ -78,7 +79,8 @@ export function useFinancialExceptions() {
|
||||
charge_mode: payload.charge_mode,
|
||||
charge_value: payload.charge_value ?? null,
|
||||
charge_pct: payload.charge_pct ?? null,
|
||||
min_hours_notice: payload.min_hours_notice ?? null
|
||||
min_hours_notice: payload.min_hours_notice ?? null,
|
||||
default_consume_on_miss: !!payload.default_consume_on_miss
|
||||
});
|
||||
if (err) throw err;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
||||
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
|
||||
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
|
||||
|
||||
import { mapAgendaEventosToCalendarEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
|
||||
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
|
||||
@@ -52,6 +53,50 @@ const _queryDate = route.query.date ? new Date(route.query.date + 'T12:00:00') :
|
||||
// -------------------- feriados --------------------
|
||||
const { fcEvents: feriadoFcEvents, load: loadFeriados } = useFeriados();
|
||||
|
||||
// -------------------- bloqueios (background events cinza) --------------------
|
||||
const { bloqueios: bloqueioRows, load: loadBloqueios, buildEventsForRange: buildBloqueioEvents } = useAgendaBloqueios();
|
||||
|
||||
// Detecta se um range [start, end] cai dentro de algum bloqueio carregado.
|
||||
// Cobre dia-inteiro, janela horária e recorrência semanal. Não veta criação
|
||||
// (só agendador público veta), mas sinaliza pro user via toast.
|
||||
function bloqueioCobrindo(start, end) {
|
||||
const arr = bloqueioRows?.value || [];
|
||||
if (!arr.length || !start) return null;
|
||||
const dStart = start instanceof Date ? start : new Date(start);
|
||||
const dEnd = end instanceof Date ? end : new Date(end || start);
|
||||
const isoDay = `${dStart.getFullYear()}-${String(dStart.getMonth() + 1).padStart(2, '0')}-${String(dStart.getDate()).padStart(2, '0')}`;
|
||||
const dow = dStart.getDay();
|
||||
const hhmmStart = dStart.getHours() * 60 + dStart.getMinutes();
|
||||
const hhmmEnd = dEnd.getHours() * 60 + dEnd.getMinutes();
|
||||
const parseHM = (s) => {
|
||||
if (!s) return null;
|
||||
const [h, m] = String(s).split(':').map(Number);
|
||||
return Number.isFinite(h) ? h * 60 + (m || 0) : null;
|
||||
};
|
||||
for (const b of arr) {
|
||||
if (!b) continue;
|
||||
if (b.recorrente && b.dia_semana != null) {
|
||||
if (Number(b.dia_semana) !== dow) continue;
|
||||
} else {
|
||||
const di = b.data_inicio;
|
||||
const df = b.data_fim || b.data_inicio;
|
||||
if (!di) continue;
|
||||
if (isoDay < di || isoDay > df) continue;
|
||||
}
|
||||
const bhi = parseHM(b.hora_inicio);
|
||||
const bhf = parseHM(b.hora_fim);
|
||||
if (bhi == null || bhf == null) return b;
|
||||
if (hhmmStart < bhf && hhmmEnd > bhi) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const bloqueioFcEvents = computed(() => {
|
||||
const s = currentRange.value.start;
|
||||
const e = currentRange.value.end;
|
||||
if (!s || !e) return [];
|
||||
return buildBloqueioEvents(s, e);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||||
if (tid) loadFeriados(tid);
|
||||
@@ -535,7 +580,7 @@ const allEvents = computed(() => {
|
||||
.filter(Boolean);
|
||||
const occEvents = mapAgendaEventosToCalendarEvents(occRows);
|
||||
|
||||
return [...base, ...occEvents, ...feriadoFcEvents.value];
|
||||
return [...base, ...occEvents, ...feriadoFcEvents.value, ...bloqueioFcEvents.value];
|
||||
});
|
||||
|
||||
// -------------------- eventos fora da jornada --------------------
|
||||
@@ -925,6 +970,15 @@ async function openDialogCreate({ ownerId, start, end }) {
|
||||
}
|
||||
|
||||
async function onSlotSelect({ ownerId, start, end }) {
|
||||
const bloqHit = bloqueioCobrindo(start, end);
|
||||
if (bloqHit) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Horário bloqueado',
|
||||
detail: `Este horário está dentro do bloqueio "${bloqHit.titulo || 'Bloqueio'}". A sessão será criada mesmo assim.`,
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
await openDialogCreate({ ownerId, start, end });
|
||||
}
|
||||
|
||||
@@ -937,6 +991,10 @@ async function onEventClick(info) {
|
||||
if (!ev) return;
|
||||
|
||||
const ep = ev.extendedProps || {};
|
||||
|
||||
// Bloqueios/pausas são background events — ignorar click.
|
||||
if (ep.kind === 'bloqueio' || ep.kind === 'break') return;
|
||||
|
||||
dialogEventRow.value = {
|
||||
id: ep.isOccurrence ? null : ev.id || null,
|
||||
owner_id: ep.owner_id,
|
||||
@@ -1686,6 +1744,10 @@ async function _reloadRange() {
|
||||
allMerged.push(...merged.filter((r) => r.is_occurrence));
|
||||
}
|
||||
_occurrenceRows.value = allMerged;
|
||||
|
||||
// Bloqueios (background events) — load assíncrono em paralelo. Recebe
|
||||
// o array de owners da clínica pra agregar todos numa query batch.
|
||||
loadBloqueios(ownerIds.value, start, end);
|
||||
}
|
||||
|
||||
// Ocorrências virtuais geradas pelo useRecurrence
|
||||
|
||||
@@ -47,6 +47,7 @@ import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
||||
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
|
||||
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
|
||||
|
||||
import { mapAgendaEventosToCalendarEvents, buildWeeklyBreakBackgroundEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
|
||||
|
||||
@@ -129,6 +130,57 @@ const ownerId = computed(() => settings.value?.owner_id || '');
|
||||
// -----------------------------
|
||||
const { fcEvents: feriadoFcEvents, load: loadFeriados } = useFeriados();
|
||||
|
||||
// -----------------------------
|
||||
// Bloqueios (background events cinza no FC)
|
||||
// -----------------------------
|
||||
const { bloqueios: bloqueioRows, load: loadBloqueios, buildEventsForRange: buildBloqueioEvents } = useAgendaBloqueios();
|
||||
const bloqueioFcEvents = computed(() => {
|
||||
const s = currentRange.value.start;
|
||||
const e = currentRange.value.end;
|
||||
if (!s || !e) return [];
|
||||
return buildBloqueioEvents(s, e);
|
||||
});
|
||||
|
||||
// Detecta se um range [start, end] cai dentro de algum bloqueio carregado.
|
||||
// Cobre bloqueios dia-inteiro (sem hora) e bloqueios com janela horária.
|
||||
// Recorrentes semanais cobertos via dia_semana.
|
||||
function bloqueioCobrindo(start, end) {
|
||||
const arr = bloqueioRows?.value || [];
|
||||
if (!arr.length || !start) return null;
|
||||
const dStart = start instanceof Date ? start : new Date(start);
|
||||
const dEnd = end instanceof Date ? end : new Date(end || start);
|
||||
const isoDay = `${dStart.getFullYear()}-${String(dStart.getMonth() + 1).padStart(2, '0')}-${String(dStart.getDate()).padStart(2, '0')}`;
|
||||
const dow = dStart.getDay();
|
||||
const hhmmStart = dStart.getHours() * 60 + dStart.getMinutes();
|
||||
const hhmmEnd = dEnd.getHours() * 60 + dEnd.getMinutes();
|
||||
|
||||
const parseHM = (s) => {
|
||||
if (!s) return null;
|
||||
const [h, m] = String(s).split(':').map(Number);
|
||||
return Number.isFinite(h) ? h * 60 + (m || 0) : null;
|
||||
};
|
||||
|
||||
for (const b of arr) {
|
||||
if (!b) continue;
|
||||
// Recorrente semanal
|
||||
if (b.recorrente && b.dia_semana != null) {
|
||||
if (Number(b.dia_semana) !== dow) continue;
|
||||
} else {
|
||||
const di = b.data_inicio;
|
||||
const df = b.data_fim || b.data_inicio;
|
||||
if (!di) continue;
|
||||
if (isoDay < di || isoDay > df) continue;
|
||||
}
|
||||
// Bloqueio sem hora = dia inteiro
|
||||
const bhi = parseHM(b.hora_inicio);
|
||||
const bhf = parseHM(b.hora_fim);
|
||||
if (bhi == null || bhf == null) return b;
|
||||
// Sobreposição de janela
|
||||
if (hhmmStart < bhf && hhmmEnd > bhi) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||||
if (tid) loadFeriados(tid);
|
||||
@@ -599,7 +651,7 @@ const calendarEvents = computed(() => {
|
||||
|
||||
const breaks = settings.value && currentRange.value.start && currentRange.value.end ? buildWeeklyBreakBackgroundEvents(settings.value.pausas_semanais, currentRange.value.start, currentRange.value.end) : [];
|
||||
|
||||
return [...base, ...occEvents, ...breaks, ...feriadoFcEvents.value];
|
||||
return [...base, ...occEvents, ...breaks, ...feriadoFcEvents.value, ...bloqueioFcEvents.value];
|
||||
});
|
||||
|
||||
const visibleTitle = computed(() => {
|
||||
@@ -1460,9 +1512,23 @@ function onCreateFromButton() {
|
||||
function onSelectTime(selection) {
|
||||
const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50;
|
||||
const rawStart = selection.start instanceof Date ? selection.start : new Date(selection.start);
|
||||
const rawEnd = selection.end instanceof Date ? selection.end : (selection.end ? new Date(selection.end) : new Date(rawStart.getTime() + durMin * 60000));
|
||||
const startISO = rawStart.toISOString();
|
||||
const endISO = new Date(rawStart.getTime() + durMin * 60000).toISOString();
|
||||
|
||||
// Aviso de bloqueio — não impede criação (regra: só agendador público
|
||||
// veta), mas sinaliza pro terapeuta que ele tá agendando em cima de
|
||||
// um bloqueio próprio.
|
||||
const bloqHit = bloqueioCobrindo(rawStart, rawEnd);
|
||||
if (bloqHit) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Horário bloqueado',
|
||||
detail: `Este horário está dentro do bloqueio "${bloqHit.titulo || 'Bloqueio'}". A sessão será criada mesmo assim.`,
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
|
||||
dialogEventRow.value = {
|
||||
owner_id: ownerId.value,
|
||||
terapeuta_id: null,
|
||||
@@ -1485,6 +1551,10 @@ function onEventClick(info) {
|
||||
if (!ev) return;
|
||||
const ep = ev.extendedProps || {};
|
||||
|
||||
// Bloqueios/pausas são background events — ignorar click pra não abrir
|
||||
// dialog de compromisso em cima de bloqueio (visual cinza ≠ compromisso).
|
||||
if (ep.kind === 'bloqueio' || ep.kind === 'break') return;
|
||||
|
||||
dialogEventRow.value = {
|
||||
id: ep.isOccurrence ? null : ev.id || null,
|
||||
owner_id: ep.owner_id,
|
||||
@@ -2243,6 +2313,10 @@ async function _reloadRange() {
|
||||
// 3. separa ocorrências virtuais (eventos reais já estão em rows.value)
|
||||
_occurrenceRows.value = merged.filter((r) => r.is_occurrence);
|
||||
logEvent('AgendaTerapeutaPage', '_reloadRange: ocorrências virtuais', { count: _occurrenceRows.value.length });
|
||||
|
||||
// 4. bloqueios (background events). Async em paralelo — não bloqueia
|
||||
// render do calendário; bloqueioFcEvents é computed e atualiza sozinho.
|
||||
loadBloqueios(ownerId.value, start, end);
|
||||
}
|
||||
|
||||
// Ref auxiliar para ocorrências virtuais geradas pelo useRecurrence
|
||||
@@ -3383,19 +3457,13 @@ onBeforeUnmount(() => {
|
||||
.fc-event.evt-session.evt-has-color {
|
||||
color: #fff !important;
|
||||
}
|
||||
.fc-event.evt-block {
|
||||
background-color: #ef4444 !important;
|
||||
border-color: #dc2626 !important;
|
||||
color: #fff !important;
|
||||
opacity: 0.75;
|
||||
}
|
||||
/* Bloqueios são background events (cinza ~20%, inline em _makeBloqueioEvent).
|
||||
A regra .fc-event.evt-block antiga pintava de vermelho — removida.
|
||||
Deixar o backgroundColor inline (#6b728033) vencer. */
|
||||
/* dayGridMonth: o dot também precisa de cor */
|
||||
.fc-daygrid-event.evt-session .fc-event-main {
|
||||
color: #fff;
|
||||
}
|
||||
.fc-daygrid-event.evt-block .fc-event-main {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Evento customizado — fora do scoped pois é HTML injetado pelo FullCalendar */
|
||||
.ev-custom {
|
||||
|
||||
@@ -214,6 +214,109 @@ export function buildWeeklyBreakBackgroundEvents(pausas, rangeStart, rangeEnd) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// buildBloqueioBackgroundEvents
|
||||
// Renderiza rows de public.agenda_bloqueios como background events cinza
|
||||
// no FullCalendar. Suporta:
|
||||
// - Bloqueio de dia inteiro (hora_inicio/fim NULL) → background do dia todo
|
||||
// - Bloqueio com janela horária → background no intervalo
|
||||
// - Bloqueio recorrente semanal (recorrente=true + dia_semana 0-6) →
|
||||
// repetido em todas as ocorrências do dow no range
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildBloqueioBackgroundEvents(bloqueios, rangeStart, rangeEnd) {
|
||||
if (!Array.isArray(bloqueios) || bloqueios.length === 0) return [];
|
||||
if (!rangeStart || !rangeEnd) return [];
|
||||
|
||||
const out = [];
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const startMs = startOfDay(rangeStart).getTime();
|
||||
const endMs = rangeEnd.getTime();
|
||||
|
||||
for (const b of bloqueios) {
|
||||
if (!b) continue;
|
||||
const titulo = b.titulo || 'Bloqueio';
|
||||
|
||||
// Recorrente semanal: itera o range varrendo dias-da-semana iguais
|
||||
if (b.recorrente && b.dia_semana != null) {
|
||||
const dow = Number(b.dia_semana);
|
||||
if (!Number.isFinite(dow) || dow < 0 || dow > 6) continue;
|
||||
const hi = asTime(b.hora_inicio);
|
||||
const hf = asTime(b.hora_fim);
|
||||
for (let ts = startMs; ts < endMs; ts += dayMs) {
|
||||
const d = new Date(ts);
|
||||
if (d.getDay() !== dow) continue;
|
||||
if (hi && hf) {
|
||||
out.push(_makeBloqueioEvent(b.id ?? null, d, hi, hf, titulo));
|
||||
} else {
|
||||
out.push(_makeBloqueioDayEvent(b.id ?? null, d, titulo));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Não recorrente: usa data_inicio e (opcional) data_fim
|
||||
const di = _parseISODate(b.data_inicio);
|
||||
if (!di) continue;
|
||||
const df = _parseISODate(b.data_fim) ?? di;
|
||||
|
||||
// Range de dias do bloqueio (inclusive)
|
||||
for (let cur = di.getTime(); cur <= df.getTime(); cur += dayMs) {
|
||||
const d = new Date(cur);
|
||||
// Filtra fora do range visível pra evitar lixo
|
||||
if (d.getTime() + dayMs < startMs || d.getTime() > endMs) continue;
|
||||
const hi = asTime(b.hora_inicio);
|
||||
const hf = asTime(b.hora_fim);
|
||||
if (hi && hf) {
|
||||
out.push(_makeBloqueioEvent(b.id ?? null, d, hi, hf, titulo));
|
||||
} else {
|
||||
out.push(_makeBloqueioDayEvent(b.id ?? null, d, titulo));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function _makeBloqueioEvent(id, date, timeStart, timeEnd, titulo) {
|
||||
const tag = id ?? `blq-${date.getTime()}-${timeStart}-${timeEnd}`;
|
||||
return {
|
||||
id: `blq-${tag}`,
|
||||
title: titulo,
|
||||
start: combineDateTimeISO(date, timeStart),
|
||||
end: combineDateTimeISO(date, timeEnd),
|
||||
display: 'background',
|
||||
overlap: false,
|
||||
backgroundColor: '#6b728033', // cinza ~20%
|
||||
extendedProps: { kind: 'bloqueio', bloqueioId: id, label: titulo }
|
||||
};
|
||||
}
|
||||
|
||||
function _makeBloqueioDayEvent(id, date, titulo) {
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
const dateStr = `${yyyy}-${mm}-${dd}`;
|
||||
const tag = id ?? `blq-${dateStr}`;
|
||||
return {
|
||||
id: `blq-day-${tag}`,
|
||||
title: titulo,
|
||||
start: dateStr,
|
||||
allDay: true,
|
||||
display: 'background',
|
||||
overlap: false,
|
||||
backgroundColor: '#6b728033',
|
||||
extendedProps: { kind: 'bloqueio', bloqueioId: id, label: titulo }
|
||||
};
|
||||
}
|
||||
|
||||
function _parseISODate(s) {
|
||||
if (!s) return null;
|
||||
const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!m) return null;
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// minutesToDuration / tituloFallback
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user