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:
Leonardo
2026-05-19 08:31:18 -03:00
parent 41c44272a3
commit e95ed9b585
41 changed files with 8715 additions and 852 deletions
+3 -3
View File
@@ -39,9 +39,9 @@ async function getUid() {
const BASE_SELECT = `
id, tenant_id, owner_id, patient_id, agenda_evento_id,
amount, discount_amount, final_amount,
status, due_date, paid_at, payment_method,
notes, created_at, updated_at,
type, amount, discount_amount, final_amount,
status, due_date, paid_at, payment_method, payment_link,
description, notes, created_at, updated_at,
patients!patient_id (
id, nome_completo, identification_color
),
File diff suppressed because it is too large Load Diff
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 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 foi pago. Nenhuma multa configurada 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
// ─────────────────────────────────────────────────────────────────────────────
@@ -34,7 +34,7 @@ const entitlements = useEntitlementsStore();
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
const pagamentoPath = computed(() => (inMelissa.value ? '/melissa/pagamento' : '/configuracoes/pagamento'));
const hasAgendador = computed(() => entitlements.can('agendador.online'));
const hasAgendador = computed(() => entitlements.can('online_scheduling.manage'));
const hasLinkPersonalizado = computed(() => entitlements.can('agendador.link_personalizado'));
// ── Estado ─────────────────────────────────────────────────────
@@ -268,6 +268,19 @@ onMounted(async () => {
</template>
<template v-else>
<!-- Toolbar topo: botão "+ Novo convênio". aparece quando
não está no modo de cadastro inline (senão fica visualmente
confuso ter botão + form abertos juntos). -->
<div v-if="!addingNew" class="flex items-center justify-end gap-2">
<Button
label="Novo convênio"
icon="pi pi-plus"
size="small"
class="rounded-full"
@click="addingNew = true"
/>
</div>
<!-- Form novo convênio -->
<div v-if="addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
@@ -309,7 +322,7 @@ onMounted(async () => {
<div v-if="!plans.length && !addingNew" class="cfg-empty">
<i class="pi pi-id-card text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum convênio cadastrado</div>
<div class="text-xs opacity-70">Clique em "Novo convênio" para começar.</div>
<div class="text-xs opacity-70">Use o botão <b>Novo convênio</b> acima pra começar.</div>
</div>
<!-- Lista de convênios -->
@@ -105,7 +105,8 @@ function startEdit(type) {
charge_mode: rec?.charge_mode ?? 'none',
charge_value: rec?.charge_value != null ? Number(rec.charge_value) : null,
charge_pct: rec?.charge_pct != null ? Number(rec.charge_pct) : null,
min_hours_notice: rec?.min_hours_notice != null ? Number(rec.min_hours_notice) : null
min_hours_notice: rec?.min_hours_notice != null ? Number(rec.min_hours_notice) : null,
default_consume_on_miss: !!rec?.default_consume_on_miss
};
}
@@ -125,7 +126,8 @@ async function saveEdit() {
charge_mode: editForm.value.charge_mode,
charge_value: editForm.value.charge_mode === 'fixed_fee' ? (editForm.value.charge_value ?? null) : null,
charge_pct: editForm.value.charge_mode === 'percentage' ? (editForm.value.charge_pct ?? null) : null,
min_hours_notice: editForm.value.exception_type === 'patient_cancellation' ? (editForm.value.min_hours_notice ?? null) : null
min_hours_notice: editForm.value.exception_type === 'patient_cancellation' ? (editForm.value.min_hours_notice ?? null) : null,
default_consume_on_miss: !!editForm.value.default_consume_on_miss
});
await load(ownerId.value);
cancelEdit();
@@ -239,6 +241,18 @@ onMounted(async () => {
</div>
</div>
<!-- Default consume_on_miss (2026-05-14): toggle pra padrão do
"Descontar do saldo" no dialog de status change. -->
<div class="flex items-start gap-2.5 mt-1">
<Checkbox v-model="editForm.default_consume_on_miss" inputId="edit-consume-default" binary />
<label for="edit-consume-default" class="cursor-pointer flex flex-col gap-0.5">
<span class="text-sm font-medium">Descontar do saldo do pacote por padrão</span>
<small class="text-[var(--text-color-secondary)] opacity-70 text-xs">
Quando esta exceção ocorre em sessão de pacote saldo, o dialog vem com "Descontar" marcado. Terapeuta pode override caso a caso.
</small>
</label>
</div>
<!-- Botões na linha separada -->
<div class="flex gap-2 justify-end mt-1">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
+88 -5
View File
@@ -394,6 +394,18 @@ const fcEvents = computed(() => {
// abaixo. Mantem a cor do commitment pra nao perder contexto.
const pStatus = ev.paciente_status;
const isInactivePatient = pStatus === 'Arquivado' || pStatus === 'Inativo';
// Sessão paga barra esquerda verde (override do border-left que o
// FC pinta com a cor do commitment). Espelha as mesmas condições do
// badge $ amber: sessão + paciente + não-virtual; aqui inverte pra
// paymentState === 'paid'.
const isPaidSession =
String(ev.tipo || '').toLowerCase() === 'sessao' &&
(ev.patient_id || ev.paciente_id) &&
!ev.is_occurrence &&
ev.paymentState === 'paid';
const cls = [];
if (isInactivePatient) cls.push('ma-evt--inactive-patient');
if (isPaidSession) cls.push('ma-evt--paid');
out.push({
id: ev.id,
title: ev.label,
@@ -402,7 +414,7 @@ const fcEvents = computed(() => {
backgroundColor: `${ev.color}26`, // ~15% opacity
borderColor: ev.color,
textColor: 'white',
classNames: isInactivePatient ? ['ma-evt--inactive-patient'] : undefined,
classNames: cls.length ? cls : undefined,
extendedProps: ev
});
}
@@ -411,6 +423,11 @@ const fcEvents = computed(() => {
if (feriados.length) {
for (const f of feriados) out.push(f);
}
// Bloqueios (background events cinza) concat direto sem filtro.
const blqs = bloqueioFcEvents.value;
if (blqs.length) {
for (const b of blqs) out.push(b);
}
return out;
});
@@ -568,7 +585,11 @@ const fcOptions = computed(() => ({
},
eventClick: (info) => {
const ev = info.event.extendedProps;
if (ev) emit('select-evento', ev);
if (!ev) return;
// Bloqueios e pausas semanais são background events não-clicáveis
// o painel lateral é só pra sessões/compromissos reais.
if (ev.kind === 'bloqueio' || ev.kind === 'break') return;
emit('select-evento', ev);
},
// Drag reagenda evento (mesmo dia, hora diferente OU outro dia)
eventDrop: (info) => {
@@ -621,6 +642,18 @@ const fcOptions = computed(() => ({
</div>
`;
}
// Badge "$ a receber" sessão com paciente, ainda não paga.
// Cobre Cenário 2 (sem cobrança, sem record) e Cenário 3 (cobrança
// pendente). Esconde quando paid ou quando não é sessão com paciente.
// Ocorrências virtuais sempre 'none' até serem materializadas pra
// não poluir séries recorrentes com pacote upfront/saldo (cobertas
// pelo contrato, não por record-por-sessão).
let payBadgeHtml = '';
if (isSessao && ext.patient_id && !ext.is_occurrence && ext.paymentState !== 'paid') {
payBadgeHtml = `<span class="mc-fc-event__paybadge" title="Cobrança pendente"><i class="pi pi-dollar"></i></span>`;
}
// Pra eventos não-sessão (compromisso, bloqueio etc.) mantém o
// antigo `__meta` com modalidade ou título secundário.
const metaFallback = !isSessao ? (ext.modalidade || ext.titulo || '') : '';
@@ -628,6 +661,7 @@ const fcOptions = computed(() => ({
return {
html: `
<div class="mc-fc-event">
${payBadgeHtml}
${titleLine}
${badgesHtml}
${metaFallback ? `<div class="mc-fc-event__meta">${escHtml(metaFallback)}</div>` : ''}
@@ -729,15 +763,23 @@ function goNext() { fcApi()?.next(); }
function goToday() { fcApi()?.today(); }
function setView(v) {
// Detecta saida da view 'lista' antes de trocar se o user veio de
// lista, o refDate atual ta em (hoje - 1 ano) e ao mudar pra week/month
// o FullCalendar mantem esse refDate, fazendo a agenda parecer estar
// no ano passado. Snap pra hoje resolve. 2026-05-12.
const leavingLista = calendarView.value === 'lista' && v !== 'lista';
calendarView.value = v;
fcApi()?.changeView(VIEW_MAP[v]);
// Lista cobre 2 anos abrimos centrado: pula pra (hoje - 1 ano) pra
// mostrar passado + presente + futuro de uma vez. Outras views mantém
// o refDate atual (datesSet sincroniza viewStart/End normalmente).
if (v === 'lista') {
// Lista cobre 2 anos abrimos centrado: pula pra (hoje - 1 ano) pra
// mostrar passado + presente + futuro de uma vez.
const umAnoAtras = new Date();
umAnoAtras.setFullYear(umAnoAtras.getFullYear() - 1);
fcApi()?.gotoDate(umAnoAtras);
} else if (leavingLista) {
fcApi()?.today();
}
}
@@ -779,10 +821,12 @@ const tenantStore = useTenantStore();
// acessos diretos a M.x dispararam TypeError ao montar fora do layout.
const _feriadosFallback = ref([]);
const _feriadoFcEventsFallback = ref([]);
const _bloqueioFcEventsFallback = ref([]);
const _feriadosAnoFallback = ref(new Date().getFullYear());
const _workRulesFallback = ref([]);
const feriadosTodos = M?.feriados ?? _feriadosFallback;
const feriadoFcEvents = M?.feriadoFcEvents ?? _feriadoFcEventsFallback;
const bloqueioFcEvents = M?.bloqueioFcEvents ?? _bloqueioFcEventsFallback;
const feriadosAno = M?.feriadosAno ?? _feriadosAnoFallback;
const loadFeriados = M?.loadFeriadosBase ?? (async () => {});
const workRules = M?.workRules ?? _workRulesFallback;
@@ -2291,10 +2335,49 @@ defineExpose({
.ma-cal__fc :deep(.fc-list-event.ma-evt--inactive-patient td) {
font-style: italic;
}
/* Sessão paga barra esquerda verde no lugar da cor do commitment.
Espelho positivo do badge $ amber: pago = canal visual esquerdo,
pendente = canal direito, sem cobrança = neutro. !important porque
o FC seta border-color inline a partir do borderColor do evento. */
.ma-cal__fc :deep(.fc-event.ma-evt--paid) {
border-left-color: #10b981 !important; /* emerald-500 */
border-left-width: 4px !important;
}
.ma-cal__fc :deep(.fc-list-event.ma-evt--paid .fc-list-event-dot) {
border-color: #10b981 !important;
}
.ma-cal__fc :deep(.mc-fc-event) {
padding: 4px 6px;
color: var(--m-text);
font-family: inherit;
position: relative;
}
/* Badge "$ a receber" canto superior direito do evento. Amarelo
amber, pequeno, sinaliza cobrança pendente sem competir com os
badges de status/modalidade. Renderizado pra sessão+paciente
com paymentState !== 'paid'. */
.ma-cal__fc :deep(.mc-fc-event__paybadge) {
position: absolute;
top: 2px;
right: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 9999px;
background: #f59e0b;
color: #fff;
font-size: 0.6rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
z-index: 2;
}
.ma-cal__fc :deep(.mc-fc-event__paybadge .pi) {
font-size: 0.62rem;
font-weight: 700;
}
.ma-cal__fc :deep(.mc-fc-event__title) {
/* Mesmo tamanho/peso da .ma-pat__name (lista de pacientes) pra
+2 -2
View File
@@ -34,7 +34,7 @@ const toast = useToast();
const tenantStore = useTenantStore();
const entitlements = useEntitlementsStore();
const hasAgendador = computed(() => entitlements.can('agendador.online'));
const hasAgendador = computed(() => entitlements.can('online_scheduling.manage'));
const hasLinkPersonalizado = computed(() => entitlements.can('agendador.link_personalizado'));
const AGENDADOR_BUCKET = 'agendador';
@@ -697,7 +697,7 @@ const summaryItems = computed(() => [
</div>
<!-- Link público -->
<template v-if="cfg.ativo">
<template v-if="cfg.ativo && hasAgendador">
<div v-if="!cfg.link_slug" class="mag-link-loading">
<i class="pi pi-spin pi-spinner" />
<span>Gerando link</span>
+176 -14
View File
@@ -33,9 +33,24 @@ const emit = defineEmits([
'edit-paciente', // botão "Editar" do grupo Outras opções PatientCadastroDialog
'abrir-prontuario',
'whatsapp',
'historico'
'historico',
'delete-sessao', // botão "Excluir sessão" só pra sessões avulsas (sem recorrência)
'ver-lancamentos', // botão "Lançamentos" abre dialog com financial_records vinculados
'antecipar-pagamento' // botão "Antecipar pagamento" paciente quer pagar antes da sessão (pacote saldo)
]);
// Regra atualizada (2026-05-14): botão Excluir aparece SEMPRE pra sessão.
// Handler no parent verifica:
// - virtual sem materialização cria recurrence_exception cancel_session
// - real sem records pagos DELETE (cobranças pendentes vão junto)
// - real com record PAGO bloqueia (estorno pelo Financeiro primeiro)
const canDelete = computed(() => {
const e = ev.value;
if (!e) return false;
// Pra MVP: oculta só em compromisso não-sessão sem id real.
return true;
});
const ev = computed(() => props.evento || {});
const tipoLabel = computed(() => {
@@ -69,6 +84,41 @@ const isSessaoComPaciente = computed(
() => ev.value.tipo === 'sessao' && (ev.value.patient_id || ev.value.pacienteNome)
);
// Estado de pagamento vem anotado pelo useMelissaAgenda via bulk-query
// em financial_records. 'paid' | 'pending' | 'none'. Renderiza linha
// curta abaixo do horário pra sessão com paciente (espelha os 3 canais
// visuais da agenda). Ocorrências virtuais (sem id real) sempre 'none'
// não polui séries com pacote upfront.
const showPaymentRow = computed(() => {
if (!isSessaoComPaciente.value) return false;
if (ev.value.is_occurrence) return false;
return !!ev.value.paymentState;
});
const paymentVariant = computed(() => {
const s = ev.value.paymentState;
if (s === 'paid') return 'paid';
if (s === 'pending') return 'pending';
return 'none';
});
const paymentIcon = computed(() => {
return paymentVariant.value === 'paid' ? 'pi pi-check-circle' : 'pi pi-dollar';
});
const paymentLabel = computed(() => {
const state = ev.value.paymentState;
const valor = ev.value.price;
const valorFmt = (valor != null && !Number.isNaN(Number(valor)))
? Number(valor).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
: null;
if (state === 'paid') {
return valorFmt ? `Pago · ${valorFmt}` : 'Pago';
}
if (state === 'pending') {
return valorFmt ? `A receber ${valorFmt} (cobrança pendente)` : 'Cobrança pendente';
}
// 'none' sessão sem cobrança gerada ainda
return valorFmt ? `A cobrar ${valorFmt}` : 'Cobrança ainda não gerada';
});
function fmtHora(decimal) {
if (decimal === null || decimal === undefined || Number.isNaN(decimal)) return '—';
const h = Math.floor(decimal);
@@ -121,16 +171,34 @@ function modalidadeIcon(mod) {
{{ fmtHora(ev.startH) }} {{ fmtHora(ev.endH) }}
<span v-if="duracaoMin() !== null" class="evento-row__sub">· {{ duracaoMin() }}min</span>
</span>
<button
type="button"
class="evento-row__edit"
:disabled="busy"
v-tooltip.top="'Editar sessão (data, hora, recorrência…)'"
@click="emit('edit-sessao')"
>
<i class="pi pi-pencil" />
<span>Editar sessão</span>
</button>
<div class="evento-row__edit-stack">
<button
type="button"
class="evento-row__edit evento-row__edit--primary"
:disabled="busy"
v-tooltip.top="'Editar sessão (data, hora, recorrência…)'"
@click="emit('edit-sessao')"
>
<i class="pi pi-pencil" />
<span>Editar sessão</span>
</button>
<button
v-if="canDelete"
type="button"
class="evento-row__edit evento-row__edit--danger"
:disabled="busy"
v-tooltip.top="'Excluir esta sessão (permanente)'"
@click="emit('delete-sessao')"
>
<i class="pi pi-trash" />
<span>Excluir sessão</span>
</button>
</div>
</div>
<div v-if="showPaymentRow" class="evento-row evento-row--pay" :class="`evento-row--pay-${paymentVariant}`">
<i :class="paymentIcon" />
<span>{{ paymentLabel }}</span>
</div>
<div v-if="ev.modalidade" class="evento-row">
@@ -239,6 +307,34 @@ function modalidadeIcon(mod) {
</div>
</section>
<!-- Grupo Financeiro abre dialog com todos os lançamentos
vinculados a esta sessão (cobrança original + multas/taxas)
+ antecipar pagamento (paciente paga antes da sessão).
Adicionado 2026-05-14. pra sessão com paciente. -->
<section v-if="isSessaoComPaciente" class="evento-actions__section">
<div class="evento-actions__label">Financeiro:</div>
<div class="evento-actions__group">
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Ver lançamentos vinculados a esta sessão'"
@click="emit('ver-lancamentos')"
>
<i class="pi pi-list" />
<span class="evento-act__label">Lançamentos</span>
</button>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Paciente quer pagar antes da sessão (pacote saldo)'"
@click="emit('antecipar-pagamento')"
>
<i class="pi pi-money-bill" />
<span class="evento-act__label">Antecipar pagamento</span>
</button>
</div>
</section>
<!-- Grupo Geral (não-sessão: bloqueio/compromisso/etc).
Aqui "Editar" abre o evento em si (não tem paciente). -->
<section v-else class="evento-actions__section">
@@ -369,12 +465,56 @@ function modalidadeIcon(mod) {
margin-left: 4px;
font-size: 0.82rem;
}
/* Botão "Editar sessão" inline na linha das horas. Discreto na largura
padrão, ganha destaque no hover. Margin-left auto pra alinhar à direita. */
.evento-row__edit {
/* Linha de cobrança espelha os 3 canais visuais da agenda:
- paid: verde (estado-alvo, sessão quitada)
- pending: amber (cobrança gerada mas não paga)
- none: amber leve (sem cobrança gerada ainda) */
.evento-row--pay {
font-weight: 500;
}
.evento-row--pay-paid {
color: #047857; /* emerald-700 */
}
.evento-row--pay-paid > i {
color: #10b981; /* emerald-500 */
}
html.app-dark .evento-row--pay-paid {
color: #34d399; /* emerald-400 */
}
html.app-dark .evento-row--pay-paid > i {
color: #34d399;
}
.evento-row--pay-pending,
.evento-row--pay-none {
color: #b45309;
}
.evento-row--pay-pending > i,
.evento-row--pay-none > i {
color: #f59e0b;
}
html.app-dark .evento-row--pay-pending,
html.app-dark .evento-row--pay-none {
color: #fbbf24;
}
html.app-dark .evento-row--pay-pending > i,
html.app-dark .evento-row--pay-none > i {
color: #fbbf24;
}
/* Stack de botões "Editar sessão" + "Excluir sessão" (Fase 5, 2026-05-14).
Empilhados verticalmente à direita da linha das horas. */
.evento-row__edit-stack {
margin-left: auto;
display: flex;
flex-direction: column;
gap: 4px;
align-items: stretch;
flex-shrink: 0;
}
.evento-row__edit {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 4px 10px;
background: var(--m-bg-soft);
@@ -397,6 +537,28 @@ function modalidadeIcon(mod) {
cursor: not-allowed;
}
.evento-row__edit i { font-size: 0.65rem; }
/* Variant primary (Editar sessão — ação principal). */
.evento-row__edit--primary {
background: var(--primary-color, #7c6af7);
border-color: var(--primary-color, #7c6af7);
color: var(--primary-color-text, #fff);
}
.evento-row__edit--primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary-color, #7c6af7) 88%, black);
border-color: color-mix(in srgb, var(--primary-color, #7c6af7) 88%, black);
color: var(--primary-color-text, #fff);
}
/* Variant danger (Excluir sessão — destrutivo, outlined). */
.evento-row__edit--danger {
background: transparent;
border-color: color-mix(in srgb, var(--red-500, #ef4444) 50%, var(--m-border));
color: var(--red-400, #f87171);
}
.evento-row__edit--danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--red-500, #ef4444) 12%, transparent);
border-color: var(--red-500, #ef4444);
color: var(--red-300, #fca5a5);
}
.evento-status {
padding: 2px 10px;
border-radius: 999px;
@@ -85,17 +85,26 @@ const TYPE_FILTER_OPTIONS = [
];
const PAYMENT_METHOD_OPTIONS = [
{ label: 'Pix', value: 'pix' },
{ label: 'Depósito', value: 'deposito' },
{ label: 'Dinheiro', value: 'dinheiro' },
{ label: 'Cartão', value: 'cartao' },
{ label: 'Convênio', value: 'convenio' }
{ label: 'Pix', value: 'pix' },
{ label: 'Depósito', value: 'deposito' },
{ label: 'Dinheiro', value: 'dinheiro' },
{ label: 'Cartão', value: 'cartao' },
{ label: 'Cartão (maquininha)', value: 'cartao_maquininha' },
{ label: 'Convênio', value: 'convenio' },
{ label: 'Asaas', value: 'asaas' }
];
function paymentLabel(method) {
return PAYMENT_METHOD_OPTIONS.find((o) => o.value === method)?.label ?? method ?? '—';
}
// Abre link de cobrança externa (Asaas/etc) em nova aba.
// noopener/noreferrer pra segurança (gateway não vira janela parent). 2026-05-14.
function openPaymentLink(url) {
if (!url) return;
window.open(url, '_blank', 'noopener,noreferrer');
}
// Filtros reativos
const filterStatus = ref(null);
const filterType = ref(null);
@@ -123,6 +132,38 @@ function clearAllFilters() {
filterDateRange.value = null;
}
// Aninhamento visual (2026-05-14): records com mesmo agenda_evento_id viram
// "pai + filho(s)" o mais antigo (created_at) é o pai (sessão); demais
// (multa, taxa de cancelamento, etc) aparecem indentados embaixo. Pai
// sempre antes dos filhos na lista. Records sem agenda_evento_id (avulso
// manual) ficam como itens soltos. Não reordena entre grupos só dentro
// de cada grupo, preservando ordem de chegada do servidor.
const recordsGrouped = computed(() => {
const list = records.value || [];
if (list.length === 0) return list;
const groupOrder = [];
const groups = new Map();
for (const r of list) {
const key = r.agenda_evento_id || `solo-${r.id}`;
if (!groups.has(key)) {
groups.set(key, []);
groupOrder.push(key);
}
groups.get(key).push(r);
}
const out = [];
for (const key of groupOrder) {
const group = groups
.get(key)
.slice()
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
group.forEach((r, idx) => {
out.push({ ...r, _isChild: idx > 0 && group.length > 1, _hasChildren: idx === 0 && group.length > 1 });
});
}
return out;
});
// Paginação server-side
const pageFirst = ref(0);
const pageRows = ref(20);
@@ -531,7 +572,7 @@ onBeforeUnmount(() => {
</div>
<DataTable
:value="records"
:value="recordsGrouped"
dataKey="id"
:loading="loading"
lazy
@@ -545,13 +586,20 @@ onBeforeUnmount(() => {
scrollable
scrollHeight="flex"
tableStyle="min-width: 880px"
:rowClass="(r) => (r.status === 'overdue' ? 'mfl-row-overdue' : '')"
:rowClass="(r) => [r.status === 'overdue' ? 'mfl-row-overdue' : '', r._isChild ? 'mfl-row-child' : '', r._hasChildren ? 'mfl-row-parent' : ''].filter(Boolean).join(' ')"
class="mfl-table"
@page="onPageChange"
>
<Column header="Paciente" style="min-width: 13rem">
<template #body="{ data }">
<div class="mfl-row__patient">
<!-- Em records "filhos" (multa, taxa) do mesmo agenda_evento_id,
esconde avatar+nome e mostra "↳ {descrição}" indentado.
Mesmo paciente do pai logo acima reduz ruído visual. -->
<div v-if="data._isChild" class="mfl-row__child">
<i class="pi pi-arrow-right-and-arrow-left-up-down mfl-row__child-icon" />
<span class="mfl-row__child-label">{{ data.description || 'Cobrança extra' }}</span>
</div>
<div v-else class="mfl-row__patient">
<span
class="mfl-row__avatar"
:style="data.patients?.identification_color ? { background: data.patients.identification_color } : null"
@@ -620,7 +668,26 @@ onBeforeUnmount(() => {
<Column header="Ações" style="width: 11rem; min-width: 11rem">
<template #body="{ data }">
<div v-if="data.status === 'pending' || data.status === 'overdue'" class="flex items-center gap-1">
<div v-if="data.status === 'pending' || data.status === 'overdue'" class="flex items-center gap-2">
<!-- Info do método (Asaas/etc): ícone + texto em azul info
na linha de cima; "Ver boleto" como texto-link na linha
de baixo (disabled enquanto integração Asaas não preenche
payment_link, tooltip muda dinâmico). 2026-05-14. -->
<div v-if="data.payment_method === 'asaas'" class="mfl-row__pending-asaas">
<div class="mfl-row__pending-method">
<i class="pi pi-link" />
{{ paymentLabel(data.payment_method) }}
</div>
<button
type="button"
class="mfl-row__pending-link"
:disabled="!data.payment_link"
v-tooltip.top="data.payment_link ? 'Abrir link de pagamento' : 'Aguardando integração Asaas'"
@click="openPaymentLink(data.payment_link)"
>
Ver boleto
</button>
</div>
<Button
label="Receber"
icon="pi pi-check"
@@ -1336,6 +1403,21 @@ onBeforeUnmount(() => {
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-overdue:hover) {
background: rgba(220, 38, 38, 0.08);
}
/* Aninhamento visual (2026-05-14): pai ganha border-bottom mais discreto,
filho herda fundo sutil + sem border-top parece "continuação" do pai. */
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-parent > td) {
border-bottom-style: dashed !important;
border-bottom-color: var(--m-border, rgba(255, 255, 255, 0.08)) !important;
}
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-child) {
background: color-mix(in srgb, var(--p-primary-color) 4%, transparent);
}
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-child > td) {
border-top: none !important;
}
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-child:hover) {
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent);
}
.mfl-table :deep(.p-datatable-loading-overlay) {
background: color-mix(in srgb, var(--m-bg-medium) 70%, transparent);
@@ -1388,6 +1470,28 @@ onBeforeUnmount(() => {
gap: 8px;
min-width: 0;
}
/* Bloco "filho" (multa/taxa do mesmo agenda_evento): indent + ícone setinha. */
.mfl-row__child {
display: flex;
align-items: center;
gap: 8px;
padding-left: 22px;
min-width: 0;
}
.mfl-row__child-icon {
color: var(--m-text-muted);
font-size: 0.65rem;
transform: scaleY(-1);
flex-shrink: 0;
}
.mfl-row__child-label {
font-size: 0.82rem;
font-style: italic;
color: var(--m-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mfl-row__avatar {
width: 28px; height: 28px;
border-radius: 50%;
@@ -1457,6 +1561,40 @@ onBeforeUnmount(() => {
font-size: 0.7rem;
color: var(--m-text-muted);
}
.mfl-row__pending-asaas {
display: flex;
flex-direction: column;
gap: 2px;
}
.mfl-row__pending-method {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.74rem;
color: rgb(37, 99, 235); /* azul info — cobrança aguardando, não paga */
font-weight: 500;
}
/* "Ver boleto" como texto-link (sem botão visual). Habilitado quando
payment_link existe vira underline + cursor pointer. Disabled hoje
enquanto integração Asaas não preenche tooltip explica. 2026-05-14. */
.mfl-row__pending-link {
background: none;
border: none;
padding: 0;
font-size: 0.7rem;
color: rgb(37, 99, 235);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.mfl-row__pending-link:hover:not(:disabled) {
text-decoration: underline;
}
.mfl-row__pending-link:disabled {
color: var(--m-text-muted);
cursor: not-allowed;
opacity: 0.7;
}
.mfl-row__none {
color: var(--m-text-faint);
font-style: italic;
+573 -30
View File
@@ -18,6 +18,7 @@
import { ref, computed, watch, onMounted, onBeforeUnmount, provide, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { useLayout } from '@/layout/composables/layout';
import { applyThemeEngine, surfaces as THEME_SURFACES, presetOptions as THEME_PRESETS } from '@/theme/theme.options';
import { MELISSA_THEME_NAMES, findMelissaTheme } from './melissaThemes';
@@ -95,6 +96,7 @@ import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue';
import AgendaStatusChangeConfirmDialog from '@/features/agenda/components/AgendaStatusChangeConfirmDialog.vue';
// Topbar system actions trazidos do AppTopbar pra Melissa: plan switcher
// (DEV), notificações e ajuda. AppTopbar não monta na rota /melissa
// (fullscreen), então duplicamos os triggers + drawers aqui.
@@ -609,6 +611,7 @@ const eventoSelecionado = ref(null);
const eventoBusy = ref(false); // bloqueia botões enquanto UPDATE roda
const melissaAgendaRef = ref(null); // pra chamar openProntuario + setView
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
const conversationDrawerStore = useConversationDrawerStore();
@@ -630,6 +633,12 @@ const {
dialogEventRow: agendaDialogEventRow,
dialogStartISO: agendaDialogStartISO,
dialogEndISO: agendaDialogEndISO,
dialogBlockOverlap: agendaDialogBlockOverlap,
occDialogOpen: agendaOccDialogOpen,
occDialogEventRow: agendaOccDialogEventRow,
occDialogStartISO: agendaOccDialogStartISO,
occDialogEndISO: agendaOccDialogEndISO,
serieRefreshTick: agendaSerieRefreshTick,
ownerId: agendaOwnerId,
clinicTenantId: agendaClinicTenantId,
commitmentOptions: agendaCommitmentOptions,
@@ -638,7 +647,12 @@ const {
allEventsForDialog: agendaAllEvents,
feriados: agendaFeriados,
bloqueioDialogOpen: agendaBloqueioOpen,
bloqueioMode: agendaBloqueioMode
bloqueioMode: agendaBloqueioMode,
// Status change confirm dialog (Fase 5, 2026-05-14)
statusDialogOpen: agendaStatusDialogOpen,
statusDialogProps: agendaStatusDialogProps,
onStatusDialogConfirm: agendaOnStatusDialogConfirm,
onStatusDialogCancel: agendaOnStatusDialogCancel
} = M;
function abrirEvento(ev) {
@@ -650,12 +664,10 @@ function fecharEvento() {
}
// Actions do MelissaEventoPanel
// updateStatus: muda status no DB e refetcha agenda. Pattern espelha
// AgendaTerapeutaPage (sem optimistic update por simplicidade no MVP).
//
// Quando `ev` é ocorrência VIRTUAL de recorrência (id `rec::...` sem row real),
// delega pro M.onUpdateSeriesEvent que materializa antes do UPDATE sem isso
// PostgreSQL recusa o UPDATE com "invalid input syntax for type uuid".
// Fase 5 (2026-05-14): TODOS os status (realizado/faltou/cancelado/etc)
// passam por M.onUpdateSeriesEvent que abre o AgendaStatusChangeConfirmDialog
// quando há regra de exceção, pacote saldo ou pending record. Antes, eventos
// reais faziam UPDATE direto sem passar pelo dialog (gap reportado pelo user).
async function updateEventoStatus(novoStatus, msgSucesso) {
const ev = eventoSelecionado.value;
if (!ev?.id || eventoBusy.value) return;
@@ -665,29 +677,18 @@ async function updateEventoStatus(novoStatus, msgSucesso) {
!!ev.is_occurrence ||
(typeof ev.id === 'string' && ev.id.startsWith('rec::'));
if (isVirtual) {
await M.onUpdateSeriesEvent({
id: null,
status: novoStatus,
recurrence_date:
ev.recurrence_date ||
ev.original_date ||
String(ev.inicio_em || '').slice(0, 10),
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
is_virtual: true,
// Passa o ev completo sem isso o handler depende de
// dialogEventRow.value (que está vazio quando o user clica
// direto no evento do FC sem abrir o dialog antes).
row: ev
});
} else {
const { error } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', ev.id);
if (error) throw error;
}
await M.onUpdateSeriesEvent({
id: isVirtual ? null : ev.id,
status: novoStatus,
recurrence_date:
ev.recurrence_date ||
ev.original_date ||
String(ev.inicio_em || '').slice(0, 10),
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
is_virtual: isVirtual,
row: ev
});
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
// Refetch:
// - M.refetch() alimenta a Agenda (FullCalendar + ocorrências virtuais)
@@ -700,6 +701,7 @@ async function updateEventoStatus(novoStatus, msgSucesso) {
} catch (e) {
const msg = e?.message || 'Erro ao atualizar evento';
toast.add({ severity: 'error', summary: 'Falha ao atualizar', detail: msg, life: 4000 });
} finally {
eventoBusy.value = false;
}
}
@@ -708,6 +710,354 @@ function onConcluir() { updateEventoStatus('realizado', 'Sessão marcada como r
function onFaltou() { updateEventoStatus('faltou', 'Marcada como falta'); }
function onCancelar() { updateEventoStatus('cancelado', 'Evento cancelado'); }
// Excluir evento via popover (Fase 5, 2026-05-14). Regra: não permitir
// exclusão direta de ocorrência de série recorrente usar fluxo do dialog
// pra encerrar série ou editar ocorrência. Pra avulsa: confirm + delete +
// remove cobranças vinculadas (com aviso explícito no confirm).
// Ver lançamentos da sessão abre dialog com financial_records vinculados.
// Reusa o mesmo padrão do dialog dentro do AgendaEventDialog. 2026-05-14.
const lancamentosDialogOpen = ref(false);
const lancamentosList = ref([]);
const lancamentosLoading = ref(false);
const lancamentosEventoTitulo = ref('');
// Antecipar pagamento (Fase 5, 2026-05-14): paciente quer pagar antes da
// sessão (caso típico em pacote saldo). Materializa a ocorrência (se virtual)
// + cria financial_record paid (PIX/etc) ou pending (Asaas). NÃO decrementa
// sessions_used só quando marcar Realizada depois.
const anteciparDialogOpen = ref(false);
const anteciparMethod = ref('pix');
const anteciparBusy = ref(false);
const anteciparEventoRef = ref(null); // snapshot do evento no momento do click
const anteciparMethodOptions = [
{ 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)' },
{ value: 'link', label: 'Enviar link de pagamento (Asaas)' }
];
async function onAnteciparPagamento() {
const ev = eventoSelecionado.value;
if (!ev) return;
// Valida: precisa ter paciente, valor (price)
if (!ev.patient_id || !ev.price) {
toast.add({
severity: 'warn',
summary: 'Não é possível antecipar',
detail: 'Sessão precisa ter paciente e valor configurado.',
life: 4000
});
return;
}
anteciparEventoRef.value = ev;
anteciparMethod.value = 'pix';
anteciparDialogOpen.value = true;
}
async function confirmAnteciparPagamento() {
const ev = anteciparEventoRef.value;
if (!ev || anteciparBusy.value) return;
anteciparBusy.value = true;
try {
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
const ownerId = ev.owner_id || ev.terapeuta_id || null;
const settlement = anteciparMethod.value;
const amount = Number(ev.price) || 0;
const dueIso = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
// 1) Materializa se virtual (cria agenda_evento real com status='agendado')
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
const isVirtual = ev.is_occurrence || isVirtualId;
let eventoId = ev.id;
if (isVirtual) {
const rid = ev.recurrence_id || ev.serie_id || null;
const rDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
if (!rid || !rDate) throw new Error('Não foi possível identificar a regra de recorrência.');
// Confere se já não foi materializada
const { data: existing } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', rid)
.eq('recurrence_date', rDate)
.maybeSingle();
if (existing?.id) {
eventoId = existing.id;
} else {
const { data: created, error: cErr } = await supabase
.from('agenda_eventos')
.insert({
owner_id: ownerId,
tenant_id: tenantId,
recurrence_id: rid,
recurrence_date: rDate,
tipo: 'sessao',
status: 'agendado',
titulo: ev.titulo || 'Sessão',
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
patient_id: ev.patient_id,
determined_commitment_id: ev.determined_commitment_id || null,
modalidade: ev.modalidade || 'presencial',
price: amount,
visibility_scope: 'public'
})
.select('id')
.single();
if (cErr) throw cErr;
eventoId = created.id;
}
}
// 2) Verifica se já tem financial_record vinculado
const { data: existRec } = await supabase
.from('financial_records')
.select('id, status')
.eq('agenda_evento_id', eventoId)
.is('deleted_at', null)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (existRec?.status === 'paid') {
toast.add({ severity: 'info', summary: 'Já está pago', detail: 'Esta sessão já tem cobrança paga.', life: 3500 });
return;
}
// 3) Cria record via RPC (ou usa existente pending pra marcar paid)
let recordId = existRec?.id || null;
if (!recordId) {
const { error: rpcErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: ownerId,
p_patient_id: ev.patient_id,
p_agenda_evento_id: eventoId,
p_amount: amount,
p_due_date: dueIso
});
if (rpcErr) throw rpcErr;
const { data: newRec } = await supabase
.from('financial_records')
.select('id')
.eq('agenda_evento_id', eventoId)
.order('created_at', { ascending: false })
.limit(1)
.single();
recordId = newRec?.id;
}
// 4) Aplica status conforme settlement
if (recordId) {
const patch = { updated_at: new Date().toISOString() };
if (settlement === 'link') {
patch.payment_method = 'asaas';
// status fica pending
} else {
patch.status = 'paid';
patch.paid_at = new Date().toISOString();
patch.payment_method = settlement;
}
await supabase.from('financial_records').update(patch).eq('id', recordId);
}
const methodLabel = anteciparMethodOptions.find((o) => o.value === settlement)?.label || settlement;
toast.add({
severity: 'success',
summary: settlement === 'link' ? 'Cobrança gerada' : 'Pagamento registrado',
detail: `R$ ${amount.toFixed(2).replace('.', ',')}${methodLabel}`,
life: 4000
});
anteciparDialogOpen.value = false;
M.refetch();
refetchEventosHoje();
fecharEvento();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao antecipar pagamento.', life: 5000 });
} finally {
anteciparBusy.value = false;
}
}
async function onVerLancamentos() {
const ev = eventoSelecionado.value;
if (!ev?.id) return;
// Ocorrência virtual ainda não foi materializada id é sintético
// `rec::<rule>::<date>`, não bate com agenda_evento_id (uuid).
// Aborta sem query e avisa o user. 2026-05-14.
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
if (ev.is_occurrence || isVirtualId) {
toast.add({
severity: 'info',
summary: 'Sem lançamentos ainda',
detail: 'Esta ocorrência ainda não foi materializada. Lançamentos aparecem após a primeira ação na sessão (status, edição etc).',
life: 5000
});
return;
}
lancamentosEventoTitulo.value = ev.pacienteNome || ev.label || ev.titulo || 'Sessão';
lancamentosDialogOpen.value = true;
lancamentosLoading.value = true;
try {
const { data, error } = await supabase
.from('financial_records')
.select('id, description, amount, final_amount, status, due_date, paid_at, payment_method, created_at')
.eq('agenda_evento_id', ev.id)
.is('deleted_at', null)
.order('created_at', { ascending: true });
if (error) throw error;
lancamentosList.value = data || [];
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao carregar lançamentos.', life: 4000 });
lancamentosList.value = [];
} finally {
lancamentosLoading.value = false;
}
}
function _fmtLancBRL(v) {
return Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function _fmtLancDate(d) {
if (!d) return '—';
try { return new Date(d).toLocaleDateString('pt-BR'); } catch { return '—'; }
}
const _lancMethodLabels = {
pix: 'PIX', dinheiro: 'Dinheiro', deposito: 'Depósito', cartao: 'Cartão',
cartao_maquininha: 'Cartão (maquininha)', convenio: 'Convênio', asaas: 'Asaas'
};
const _lancStatusLabels = {
pending: 'Pendente', paid: 'Pago', overdue: 'Vencido',
cancelled: 'Cancelado', refunded: 'Reembolsado', partial: 'Parcial'
};
function _lancStatusSeverity(s) {
return { pending: 'info', paid: 'success', overdue: 'danger', cancelled: 'secondary', refunded: 'warn', partial: 'warn' }[s] || 'secondary';
}
async function onDeleteEvento() {
const ev = eventoSelecionado.value;
if (!ev?.id || eventoBusy.value) return;
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
const isVirtual = ev.is_occurrence || isVirtualId;
// Ocorrência virtual: cria recurrence_exception (cancel_session)
// Sem interação ainda segura excluir. Mostra confirm simples.
if (isVirtual) {
const recId = ev.recurrence_id || ev.serie_id;
const origDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
if (!recId || !origDate) {
toast.add({ severity: 'warn', summary: 'Não excluível', detail: 'Não foi possível identificar a regra de recorrência.', life: 4000 });
return;
}
confirm.require({
header: 'Cancelar ocorrência',
message: 'Esta ocorrência ainda não tem cobranças. Tem certeza que deseja cancelá-la? Ela some da agenda; as outras sessões da série continuam.',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sim, cancelar',
rejectLabel: 'Manter',
acceptClass: 'p-button-danger',
accept: async () => {
eventoBusy.value = true;
try {
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
const { error } = await supabase.from('recurrence_exceptions').insert({
recurrence_id: recId,
tenant_id: tenantId,
original_date: origDate,
type: 'cancel_session',
reason: 'Cancelado pelo terapeuta antes de qualquer interação'
});
if (error) throw error;
toast.add({ severity: 'success', summary: 'Ocorrência cancelada', life: 2500 });
M.refetch();
refetchEventosHoje();
fecharEvento();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao cancelar.', life: 4000 });
} finally {
eventoBusy.value = false;
}
}
});
return;
}
// Evento real: conta cobranças vinculadas
let recordsCount = 0;
let hasPaidRecord = false;
try {
const { data } = await supabase
.from('financial_records')
.select('id, status')
.eq('agenda_evento_id', ev.id)
.is('deleted_at', null);
recordsCount = (data || []).length;
hasPaidRecord = (data || []).some((r) => r.status === 'paid');
} catch (e) {
console.warn('[Excluir sessão] erro contando records:', e?.message);
}
// Cobrança PAGA bloqueia exclusão precisa estornar pelo Financeiro
if (hasPaidRecord) {
toast.add({
severity: 'warn',
summary: 'Sessão com pagamento confirmado',
detail: 'Esta sessão tem cobrança paga. Estorne primeiro pelo Financeiro antes de excluir.',
life: 5500
});
return;
}
// Evento de série materializado (tem recurrence_id) vira exception
// (cancel_session) também, mas removendo records pendentes junto.
const isMaterializedOccurrence = !!ev.recurrence_id || !!ev.serie_id;
const msgRecords = recordsCount > 0
? `Esta sessão tem ${recordsCount} cobrança(s) pendente(s) que também será(ão) removida(s).`
: 'A sessão não tem cobranças vinculadas.';
confirm.require({
header: isMaterializedOccurrence ? 'Cancelar ocorrência' : 'Excluir sessão',
message: `${msgRecords} A ação não pode ser desfeita. Confirmar?`,
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sim, confirmar',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: async () => {
eventoBusy.value = true;
try {
// 1) Remove cobranças vinculadas (não-pagas)
if (recordsCount > 0) {
const { error: recErr } = await supabase.from('financial_records').delete().eq('agenda_evento_id', ev.id);
if (recErr) throw recErr;
}
if (isMaterializedOccurrence) {
// Cria exception cancel_session + DELETE da row (some da agenda)
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
const origDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
if (origDate) {
const { error: exErr } = await supabase.from('recurrence_exceptions').insert({
recurrence_id: ev.recurrence_id || ev.serie_id,
tenant_id: tenantId,
original_date: origDate,
type: 'cancel_session',
reason: 'Cancelado pelo terapeuta'
});
if (exErr) console.warn('[Excluir] exception insert falhou:', exErr?.message);
}
}
const { error } = await supabase.from('agenda_eventos').delete().eq('id', ev.id);
if (error) throw error;
const detail = recordsCount > 0 ? `Sessão e ${recordsCount} cobrança(s) removida(s).` : 'Sessão removida.';
toast.add({ severity: 'success', summary: isMaterializedOccurrence ? 'Ocorrência cancelada' : 'Excluída', detail, life: 3000 });
M.refetch();
refetchEventosHoje();
fecharEvento();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir.', life: 4000 });
} finally {
eventoBusy.value = false;
}
}
});
}
async function onWhatsapp() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
@@ -1709,6 +2059,9 @@ function onKeydown(e) {
@cancelar="onCancelar"
@remarcar="onRemarcar"
@edit-sessao="onEditEvento"
@delete-sessao="onDeleteEvento"
@ver-lancamentos="onVerLancamentos"
@antecipar-pagamento="onAnteciparPagamento"
@edit-paciente="onEditPaciente"
@abrir-prontuario="onAbrirProntuario"
@whatsapp="onWhatsapp"
@@ -2139,6 +2492,8 @@ function onKeydown(e) {
:allEvents="agendaAllEvents"
:pausasSemanais="agendaSettings?.pausas_semanais || []"
:feriados="agendaFeriados"
:serieRefreshTick="agendaSerieRefreshTick"
:blockOverlapWarning="agendaDialogBlockOverlap"
newPatientRoute="/therapist/patients/cadastro"
@save="M.onDialogSave"
@delete="M.onDialogDelete"
@@ -2146,6 +2501,34 @@ function onKeydown(e) {
@editSeriesOccurrence="M.onEditSeriesOccurrence"
/>
<!-- 2º AgendaEventDialog empilhado por cima do principal pra editar
uma OCORRÊNCIA específica de série. Acionado pelo botão "Editar"
nas pills da lista "Recorrências Aplicadas". Reusa os mesmos
handlers de save/delete/update o composable distingue pelo
id/recurrence_date. PrimeVue empilha automaticamente, então
nenhum gerenciamento manual de z-index é necessário.
Adicionado 2026-05-11; pendente replicar em Rail/Clínica. -->
<AgendaEventDialog
v-model="agendaOccDialogOpen"
:eventRow="agendaOccDialogEventRow"
:initialStartISO="agendaOccDialogStartISO"
:initialEndISO="agendaOccDialogEndISO"
:ownerId="agendaOwnerId"
:tenantId="agendaClinicTenantId"
:commitmentOptions="agendaCommitmentOptions"
:workRules="agendaWorkRules"
:blockedDates="[]"
:agendaSettings="agendaSettings"
:allEvents="agendaAllEvents"
:pausasSemanais="agendaSettings?.pausas_semanais || []"
:feriados="agendaFeriados"
newPatientRoute="/therapist/patients/cadastro"
:occurrenceMode="true"
@save="M.onDialogSave"
@delete="M.onDialogDelete"
@updateSeriesEvent="M.onUpdateSeriesEvent"
/>
<!-- BloqueioDialog bloqueio de horário/período/dia/feriados.
Trigger é o menu na toolbar da MelissaAgenda. Após salvar,
refetcha pra refletir o bloqueio na agenda. -->
@@ -2159,6 +2542,118 @@ function onKeydown(e) {
@saved="M.refetch"
/>
<!-- Dialog "Lançamentos da sessão" (2026-05-14): lista todos os
financial_records vinculados ao evento atual. Abre via botão
"Lançamentos" na seção Financeiro do MelissaEventoPanel. -->
<Dialog
v-model:visible="lancamentosDialogOpen"
modal
:draggable="false"
:style="{ width: '640px', maxWidth: '96vw' }"
>
<template #header>
<div class="flex flex-col gap-0.5">
<span class="text-base font-bold">Lançamentos da sessão</span>
<span class="text-xs opacity-70">{{ lancamentosEventoTitulo }}</span>
</div>
</template>
<div v-if="lancamentosLoading" class="py-6 text-center text-sm opacity-70">
<i class="pi pi-spin pi-spinner mr-1" /> Carregando
</div>
<div v-else-if="!lancamentosList.length" class="py-6 text-center text-sm opacity-70">
<i class="pi pi-info-circle mr-1" /> Nenhum lançamento vinculado a esta sessão.
</div>
<div v-else class="flex flex-col gap-2.5">
<div
v-for="(r, idx) in lancamentosList"
:key="r.id"
class="ml-lanc-card"
:class="{ 'ml-lanc-card--child': idx > 0 }"
>
<div class="ml-lanc-card__head">
<i v-if="idx > 0" class="pi pi-arrow-right-and-arrow-left-up-down ml-lanc-card__indent" />
<span class="ml-lanc-card__desc">{{ r.description || (idx === 0 ? 'Sessão' : 'Cobrança extra') }}</span>
<Tag :value="_lancStatusLabels[r.status] || r.status" :severity="_lancStatusSeverity(r.status)" class="text-xs ml-auto" />
</div>
<div class="ml-lanc-card__body">
<div class="ml-lanc-card__row">
<i class="pi pi-money-bill" />
<span class="ml-lanc-card__amount">{{ _fmtLancBRL(r.final_amount || r.amount) }}</span>
</div>
<div v-if="r.payment_method" class="ml-lanc-card__row">
<i class="pi pi-credit-card" />
<span>{{ _lancMethodLabels[r.payment_method] || r.payment_method }}</span>
</div>
<div class="ml-lanc-card__row">
<i class="pi pi-calendar" />
<span>Vencimento: {{ _fmtLancDate(r.due_date) }}</span>
</div>
<div v-if="r.paid_at" class="ml-lanc-card__row">
<i class="pi pi-check-circle" />
<span>Pago em {{ _fmtLancDate(r.paid_at) }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined @click="lancamentosDialogOpen = false" />
</template>
</Dialog>
<!-- Dialog "Antecipar pagamento" (Fase 5, 2026-05-14): paciente
quer pagar antes da sessão. Materializa ocorrência se virtual
e cria/atualiza financial_record. Não decrementa saldo. -->
<Dialog
v-model:visible="anteciparDialogOpen"
modal
:draggable="false"
header="Antecipar pagamento"
:style="{ width: '480px', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-3 pt-1">
<div class="text-sm">
Receba antecipadamente o valor desta sessão.
</div>
<div v-if="anteciparEventoRef" class="flex flex-col gap-1 px-3 py-2 rounded-md bg-[var(--surface-section)] border border-[var(--surface-border)]">
<div class="text-sm font-semibold">{{ anteciparEventoRef.pacienteNome || 'Sessão' }}</div>
<div class="text-xs opacity-70">Valor: R$ {{ Number(anteciparEventoRef.price || 0).toFixed(2).replace('.', ',') }}</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium">Como o paciente pagou?</label>
<Select
v-model="anteciparMethod"
:options="anteciparMethodOptions"
optionLabel="label"
optionValue="value"
size="small"
/>
</div>
<small class="text-xs opacity-60">
O saldo do pacote será decrementado quando você marcar a sessão como Realizada.
</small>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="anteciparBusy" @click="anteciparDialogOpen = false" />
<Button label="Confirmar" icon="pi pi-check" :loading="anteciparBusy" @click="confirmAnteciparPagamento" />
</template>
</Dialog>
<!-- AgendaStatusChangeConfirmDialog Fase 5 (2026-05-14): aparece
quando user muda status pra realizado/faltou/cancelado e
decisão a tomar (regra de exceção, pacote saldo, pending). -->
<AgendaStatusChangeConfirmDialog
v-model="agendaStatusDialogOpen"
:evento="agendaStatusDialogProps.evento"
:novoStatus="agendaStatusDialogProps.novoStatus"
:regraExcecao="agendaStatusDialogProps.regraExcecao"
:billingContract="agendaStatusDialogProps.billingContract"
:billingContractStyle="agendaStatusDialogProps.billingContractStyle"
:pendingRecord="agendaStatusDialogProps.pendingRecord"
:sessionPrice="agendaStatusDialogProps.sessionPrice"
@confirm="agendaOnStatusDialogConfirm"
@update:modelValue="(v) => !v && agendaOnStatusDialogCancel()"
/>
<!-- Toast: AppLayout não monta no Melissa (rota fullscreen),
então as pages embedadas (config, agendador online, etc.)
precisam de um Toast próprio aqui pra não silenciar o
@@ -2676,6 +3171,54 @@ function onKeydown(e) {
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Dialog "Lançamentos da sessão" (2026-05-14) ── */
.ml-lanc-card {
border: 1px solid var(--surface-border);
border-radius: 8px;
padding: 0.7rem 0.85rem;
background: var(--surface-card);
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.ml-lanc-card--child {
background: color-mix(in srgb, var(--p-primary-color) 4%, var(--surface-card));
margin-left: 1.5rem;
border-color: color-mix(in srgb, var(--p-primary-color) 25%, var(--surface-border));
}
.ml-lanc-card__head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ml-lanc-card__indent {
color: var(--text-color-secondary);
font-size: 0.7rem;
transform: scaleY(-1);
}
.ml-lanc-card__desc {
font-size: 0.88rem;
font-weight: 600;
color: var(--text-color);
}
.ml-lanc-card__body {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.ml-lanc-card__row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: var(--text-color-secondary);
}
.ml-lanc-card__row i { font-size: 0.72rem; }
.ml-lanc-card__amount {
font-weight: 600;
color: var(--text-color);
}
</style>
<!--
+267
View File
@@ -21,6 +21,7 @@ import { useConfirm } from 'primevue/useconfirm';
import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useLayout } from '@/layout/composables/layout';
import MelissaConfigList from './MelissaConfigList.vue';
// InputText/Select/Textarea/InputMask/Skeleton/Tag/Button: auto via PrimeVueResolver
@@ -30,6 +31,55 @@ const toast = useToast();
const confirm = useConfirm();
const router = useRouter();
const tenantStore = useTenantStore();
const { layoutConfig, setVariant } = useLayout();
// Troca de layout variant (classic/rail/melissa). Confirma + persiste +
// hard reload sair do shell Melissa requer reload pq AppLayout não tem
// branch pra essa rota; quem renderiza Melissa é a rota /melissa separada.
const variantSwitchOpen = ref(false);
async function switchToVariant(v) {
if (!['classic', 'rail', 'melissa'].includes(v)) return;
if (layoutConfig.variant === v) return;
if (variantSwitchOpen.value) return;
variantSwitchOpen.value = true;
const labels = { classic: 'Clássico', rail: 'Rail', melissa: 'Melissa' };
confirm.require({
header: `Trocar para o layout ${labels[v]}`,
message: 'A página será recarregada para aplicar o novo layout. Confirma?',
icon: 'pi pi-th-large',
acceptLabel: 'Trocar e recarregar',
rejectLabel: 'Cancelar',
accept: async () => {
try {
setVariant(v);
if (userId.value) {
const { error } = await supabase
.from('user_settings')
.upsert(
{
user_id: userId.value,
layout_variant: v,
updated_at: new Date().toISOString()
},
{ onConflict: 'user_id' }
);
if (error) {
const msg = String(error.message || '');
const tolerant = /does not exist/i.test(msg) || /permission denied/i.test(msg) || /violates row-level security/i.test(msg);
if (!tolerant) throw error;
}
}
toast.add({ severity: 'info', summary: `Aplicando ${labels[v]}`, detail: 'Recarregando…', life: 1500 });
window.location.assign('/');
} catch (e) {
variantSwitchOpen.value = false;
toast.add({ severity: 'error', summary: 'Erro ao trocar layout', detail: e?.message || 'Tente novamente.', life: 4000 });
}
},
reject: () => { variantSwitchOpen.value = false; },
onHide: () => { variantSwitchOpen.value = false; }
});
}
const AVATAR_BUCKET = 'avatars';
@@ -933,6 +983,92 @@ onBeforeUnmount(() => {
</div>
</div><!-- /.mpr-w__body -->
</div>
<!-- Layout (variante de navegação) -->
<div id="mpr-sec-layout" class="mpr-w">
<div class="mpr-w__head">
<div class="mpr-w__icon"><i class="pi pi-th-large" /></div>
<div class="mpr-w__title">
<div class="mpr-w__title-text">Layout</div>
<div class="mpr-w__sub">Estilo de navegação principal troca exige reload</div>
</div>
</div>
<div class="mpr-w__body">
<div class="mpr-lv-grid">
<button
class="mpr-lv-card"
:class="{ 'mpr-lv-card--current': layoutConfig.variant === 'classic' }"
:disabled="layoutConfig.variant === 'classic'"
@click="switchToVariant('classic')"
>
<div class="mpr-lv-preview mpr-lv-preview--classic">
<div class="mpr-lv-sidebar" />
<div class="mpr-lv-main">
<div class="mpr-lv-bar" />
<div class="mpr-lv-line" />
<div class="mpr-lv-line mpr-lv-line--sm" />
</div>
</div>
<div class="mpr-lv-foot">
<div class="mpr-lv-radio">
<div v-if="layoutConfig.variant === 'classic'" class="mpr-lv-dot" />
</div>
<div>
<div class="mpr-lv-name">Clássico</div>
<div class="mpr-lv-sub">Sidebar lateral com menu completo</div>
</div>
</div>
</button>
<button
class="mpr-lv-card"
:class="{ 'mpr-lv-card--current': layoutConfig.variant === 'rail' }"
:disabled="layoutConfig.variant === 'rail'"
@click="switchToVariant('rail')"
>
<div class="mpr-lv-preview mpr-lv-preview--rail">
<div class="mpr-lv-rail" />
<div class="mpr-lv-panel" />
<div class="mpr-lv-main">
<div class="mpr-lv-bar" />
<div class="mpr-lv-line" />
<div class="mpr-lv-line mpr-lv-line--sm" />
</div>
</div>
<div class="mpr-lv-foot">
<div class="mpr-lv-radio">
<div v-if="layoutConfig.variant === 'rail'" class="mpr-lv-dot" />
</div>
<div>
<div class="mpr-lv-name">Rail</div>
<div class="mpr-lv-sub">Mini rail + painel expansível, full-width</div>
</div>
</div>
</button>
<button
class="mpr-lv-card"
:class="{ 'mpr-lv-card--current': layoutConfig.variant === 'melissa' }"
:disabled="layoutConfig.variant === 'melissa'"
@click="switchToVariant('melissa')"
>
<div class="mpr-lv-preview mpr-lv-preview--melissa">
<div class="mpr-lv-melissa-bg" />
<div class="mpr-lv-melissa-dock" />
</div>
<div class="mpr-lv-foot">
<div class="mpr-lv-radio">
<div v-if="layoutConfig.variant === 'melissa'" class="mpr-lv-dot" />
</div>
<div>
<div class="mpr-lv-name">Melissa</div>
<div class="mpr-lv-sub">Lockscreen-style com dock central (atual)</div>
</div>
</div>
</button>
</div>
</div><!-- /.mpr-w__body -->
</div>
</template>
</div>
</div>
@@ -1676,4 +1812,135 @@ onBeforeUnmount(() => {
.mpr-custom { flex-direction: column; gap: 8px; }
.mpr-custom .mpr-btn--icon { align-self: flex-end; }
}
/* ═══════ Layout variant cards ═══════ */
.mpr-lv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px;
}
.mpr-lv-card {
background: var(--m-surface-2, var(--surface-card));
border: 1px solid var(--m-border, var(--surface-border));
border-radius: 10px;
padding: 12px;
text-align: left;
cursor: pointer;
transition: border-color .15s, background .15s, transform .12s;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--text-color);
}
.mpr-lv-card:hover:not(:disabled) {
border-color: var(--p-primary-500, #7c3aed);
background: var(--m-surface-hover, var(--surface-hover));
}
.mpr-lv-card:disabled {
cursor: default;
opacity: 0.9;
}
.mpr-lv-card--current {
border-color: var(--p-primary-500, #7c3aed);
box-shadow: 0 0 0 1px var(--p-primary-500, #7c3aed) inset;
}
.mpr-lv-preview {
height: 92px;
border-radius: 6px;
background: var(--m-surface-3, var(--surface-100));
border: 1px solid var(--m-border, var(--surface-border));
position: relative;
overflow: hidden;
display: flex;
}
.mpr-lv-preview--classic .mpr-lv-sidebar {
width: 30%;
background: var(--p-primary-500, #7c3aed);
opacity: 0.85;
}
.mpr-lv-preview--rail .mpr-lv-rail {
width: 12%;
background: var(--p-primary-500, #7c3aed);
opacity: 0.85;
}
.mpr-lv-preview--rail .mpr-lv-panel {
width: 22%;
background: var(--p-primary-500, #7c3aed);
opacity: 0.35;
}
.mpr-lv-main {
flex: 1;
padding: 8px;
display: flex;
flex-direction: column;
gap: 5px;
}
.mpr-lv-bar {
height: 8px;
background: var(--m-border-strong, var(--surface-300));
border-radius: 2px;
opacity: 0.6;
}
.mpr-lv-line {
height: 5px;
background: var(--m-border-strong, var(--surface-300));
border-radius: 2px;
opacity: 0.4;
}
.mpr-lv-line--sm { width: 65%; }
.mpr-lv-preview--melissa {
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%);
}
.mpr-lv-melissa-bg {
position: absolute;
inset: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
}
.mpr-lv-melissa-dock {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
width: 60%;
height: 14px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.25);
}
.mpr-lv-foot {
display: flex;
align-items: flex-start;
gap: 9px;
}
.mpr-lv-radio {
width: 14px;
height: 14px;
border-radius: 9999px;
border: 1.5px solid var(--m-border-strong, var(--surface-400));
display: grid;
place-items: center;
flex-shrink: 0;
margin-top: 3px;
}
.mpr-lv-card--current .mpr-lv-radio {
border-color: var(--p-primary-500, #7c3aed);
}
.mpr-lv-dot {
width: 7px;
height: 7px;
border-radius: 9999px;
background: var(--p-primary-500, #7c3aed);
}
.mpr-lv-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-color);
line-height: 1.2;
}
.mpr-lv-sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
line-height: 1.3;
margin-top: 2px;
}
</style>
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -437,9 +437,9 @@ export function applyGuards(router) {
if (shouldLoadEntitlements(_ent, _tid)) {
await loadEntitlementsSafe(_ent, _tid, true);
}
// Entitlements pessoais (therapist/supervisor têm assinatura própria)
const _roleNorm = normalizeRole(_role);
if (['therapist', 'supervisor'].includes(_roleNorm) && _ent.loadedForUser !== uid) {
// Entitlements pessoais — subscription do user independe do role
// no tenant ativo (tenant_admin do próprio tenant pode ter therapist_pro)
if (_ent.loadedForUser !== uid) {
try {
await _ent.loadForUser(uid);
} catch { }
@@ -738,11 +738,11 @@ export function applyGuards(router) {
await loadEntitlementsSafe(ent, tenantId, true);
}
// ✅ user entitlements: terapeuta pode ter assinatura pessoal (therapist_pro)
// que gera features em v_user_entitlements, não em v_tenant_entitlements.
// user entitlements: therapist e supervisor têm assinatura pessoal (v_user_entitlements)
const activeRoleNormForEnt = normalizeRole(tenant.activeRole);
if (['therapist', 'supervisor'].includes(activeRoleNormForEnt) && uid && ent.loadedForUser !== uid) {
// ✅ user entitlements: subscription pessoal (therapist_pro/free,
// supervisor_*, etc) independe do role no tenant ativo — um terapeuta
// pode ser tenant_admin do próprio tenant E ter assinatura pessoal.
// Sempre carrega pra qualquer user autenticado.
if (uid && ent.loadedForUser !== uid) {
logGuard('ent.loadForUser');
try {
await ent.loadForUser(uid);
+2 -1
View File
@@ -34,7 +34,8 @@ export default {
{
path: 'melissa/:secao?',
name: 'Melissa',
component: () => import('@/layout/melissa/MelissaLayout.vue')
component: () => import('@/layout/melissa/MelissaLayout.vue'),
meta: { requiresAuth: true }
},
// Preview do AgendaEventDialog V2 (A66 sub-sessão 2). Iteração