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:
@@ -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". Só 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" />
|
||||
|
||||
@@ -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 só 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. Só 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;
|
||||
|
||||
@@ -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 há
|
||||
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>
|
||||
|
||||
<!-- ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user