fad1f4ebd4
Cenário 8 (Pacote SALDO — Otávio Souza Ferreira 12 × R$ 50)
- Testado e passou. DB: 1 rule, 0 events, 1 contract (saldo), 0 records.
Visual: 12 virtuais limpas no calendário.
UI de pacote (saldo + upfront)
- _ruleContractMap em useMelissaAgenda: bulk-load popula contract info
(id, style, totalSessions, sessionsUsed, packagePrice) por
recurrence_id. Query recurrence_rules.patient_id como fonte
autoritativa — cobre saldo sem materializadas (sem isso, ruleToPatient
via records vinha vazio pra saldo)
- normalize injeta `contract` no evento via ruleContractMap
- MelissaEventoPanel: nova linha colorida (violeta saldo, verde upfront)
com "Pacote saldo · N/M usadas" ou "Pacote · N/M realizadas"
- AgendaEventDialog: info card mt-4 com header+body+hint explicando
modelo, gateado por occFinancialLoading (spinner durante carga
pra evitar piscar entre Usar/Revogar)
Handlers Usar/Revogar atômicos
- onUsarSessao em MelissaLayout: materializa virtual (preserva
determined_commitment_id da regra) → status=realizado +
billing_contract_id → create_financial_record_for_session →
sessions_used++ → (se atingiu total) contract.status=completed
- onRevogarSessao: cancela record + sessions_used-- + reativa contract
se estava completed + status=agendado. Bloqueia se record paid
(precisa estorno formal pelo Financeiro)
- Ambos aceitam payload {eventRow, contract} do dialog OU fallback
pra eventoSelecionado do popover
- Botão "Usar" verde no popover (paymentState=none) substituído por
"Revogar" vermelho (paymentState=pending). Equivalente "Usar agora"/
"Revogar uso" no info card do dialog
Fix enum status_evento_agenda
- 'realizada' não existe no enum — DB exige 'realizado' (masculino).
Corrigido em todas as ocorrências do handler
Fix campo "Título" indevido em sessão
- Sessão sem determined_commitment_id → selectedCommitment=null →
isSessionEvent=false → mostra campo Título (que é só pra não-sessão)
- Fix: materialize do Usar inclui determined_commitment_id (insert
path); update path backfilla via query da rule se NULL; Revogar
também backfilla pra consistência
Fix "Gerar fatura" não cabe em saldo
- Botão "Gerar fatura" do popover hide quando há contractInfo. Em
saldo, gerar fatura solta criaria cobrança duplicada sem incrementar
sessions_used. Fluxo correto: "Usar"
Recorrências Aplicadas — UI
- Header stats coloridos: total **azul**, realizadas **verde**,
faltaram **amber**, canceladas **cinza**, remarcadas **violeta**
- Pills com badge sólido por status (emerald-600 realizado, amber-600
faltou, stone-500 cancelado, violet-600 remarcado)
Race condition no dialog
- AgendaEventDialog mostrava botões Usar/Revogar baseado em
occFinancialRecord async; durante ~500ms de load, botão errado
podia piscar. Fix: spinner "Verificando estado…" enquanto
occFinancialLoading=true; botões só renderizam após
- Popover não fixado (race window pequena, fechar/reabrir resolve)
3 decisões UX confirmadas antes de codar
- Editar serviço pago → NÃO (cobrança fiscal imutável)
- Alternar Particular/Convênio/Gratuito em série cobrada → NÃO
- Gerar fatura individual em pacote upfront → NÃO (duplicação)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
949 lines
36 KiB
Vue
949 lines
36 KiB
Vue
<script setup>
|
||
/*
|
||
* MelissaEventoPanel — Painel de detalhes do evento selecionado.
|
||
* --------------------------------------------------
|
||
* Substitui o panel inline que vivia em MelissaLayout (era prone a crash
|
||
* por referenciar campos inexistentes no normalize: .valor, .participantes,
|
||
* .supervisorNome, .local).
|
||
*
|
||
* Renderiza apenas campos REAIS do useMelissaEventos.normalizeEvent:
|
||
* tipo, status, modalidade, descricao, pacienteNome, patient_id,
|
||
* color, label, startH, endH, inicio_em, fim_em
|
||
*
|
||
* Actions emitidas (parent decide o que fazer):
|
||
* - close
|
||
* - concluir / faltou / cancelar (mudança de status)
|
||
* - remarcar / edit (abre dialog de edição — TODO no parent)
|
||
* - abrir-prontuario / whatsapp / historico (paciente actions)
|
||
*/
|
||
import { computed } from 'vue';
|
||
|
||
const props = defineProps({
|
||
evento: { type: Object, required: true },
|
||
busy: { type: Boolean, default: false } // bloqueia botões enquanto UPDATE roda
|
||
});
|
||
|
||
const emit = defineEmits([
|
||
'close',
|
||
'concluir',
|
||
'faltou',
|
||
'cancelar',
|
||
'remarcar',
|
||
'edit-sessao', // botão dedicado ao lado das horas → AgendaEventDialog
|
||
'edit-paciente', // botão "Editar" do grupo Outras opções → PatientCadastroDialog
|
||
'abrir-prontuario',
|
||
'whatsapp',
|
||
'historico',
|
||
'delete-sessao', // botão "Excluir sessão" — só pra sessões avulsas (sem recorrência)
|
||
'delete-series', // botão "Excluir série inteira" — hard delete da regra + materializadas + records pendentes
|
||
'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)
|
||
'gerar-cobranca', // botão inline "Gerar fatura" ao lado de "A cobrar R$ X" — atalho sem precisar abrir dialog
|
||
'usar-sessao', // botão "Usar" no card de pacote saldo — materializa+realizada+gera cobrança individual
|
||
'revogar-sessao' // botão "Revogar" — desfaz Usar (cancela record + decrementa saldo). Bloqueado se já pago
|
||
]);
|
||
|
||
// 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;
|
||
});
|
||
|
||
// Mostra "Excluir série inteira" apenas pra eventos que pertencem a
|
||
// uma regra de recorrência (materializados OU ocorrências virtuais).
|
||
const isPartOfSeries = computed(() => {
|
||
const e = ev.value;
|
||
if (!e) return false;
|
||
return !!(e.recurrence_id || e.serie_id);
|
||
});
|
||
|
||
// Label "Pacote · X sessões" / "Série semanal" pro popover quando o
|
||
// evento pertence a uma recorrência. Lê do _raw que carrega os campos
|
||
// da regra (max_occurrences, frequency_type quando expandido).
|
||
const seriesLabel = computed(() => {
|
||
if (!isPartOfSeries.value) return null;
|
||
const raw = ev.value._raw || {};
|
||
const total = raw.max_occurrences || raw.qtd_sessoes || raw.series_total || null;
|
||
const idx = raw.occurrence_index || raw.serie_index || null;
|
||
if (total && idx) return `Sessão ${idx} de ${total}`;
|
||
if (total) return `Pacote · ${total} sessões`;
|
||
return 'Série recorrente';
|
||
});
|
||
|
||
// Info de contrato (saldo/upfront) — vem injetada pelo bulk-load via
|
||
// _ruleContractMap. Mostra linha no popover indicando o tipo de pacote
|
||
// e progresso (usadas/total). Pra saldo é especialmente importante —
|
||
// sem essa linha o user não sabe o que está acontecendo (nenhuma
|
||
// cobrança no /financeiro até realizar).
|
||
const contractInfo = computed(() => {
|
||
const c = ev.value.contract;
|
||
if (!c) return null;
|
||
const total = c.totalSessions || 0;
|
||
const used = c.sessionsUsed || 0;
|
||
const remaining = Math.max(0, total - used);
|
||
const perSession = total > 0 ? (c.packagePrice || 0) / total : 0;
|
||
return {
|
||
style: c.style || 'upfront',
|
||
total,
|
||
used,
|
||
remaining,
|
||
packagePrice: c.packagePrice || 0,
|
||
perSession,
|
||
label: c.style === 'saldo'
|
||
? `Pacote saldo · ${used}/${total} usadas`
|
||
: `Pacote · ${used}/${total} realizadas`
|
||
};
|
||
});
|
||
|
||
const ev = computed(() => props.evento || {});
|
||
|
||
const tipoLabel = computed(() => {
|
||
const t = String(ev.value.tipo || '').toLowerCase();
|
||
if (t === 'sessao') return 'Sessão';
|
||
if (t === 'supervisao' || t === 'supervisão') return 'Supervisão';
|
||
if (t === 'reuniao' || t === 'reunião') return 'Reunião';
|
||
if (t === 'bloqueio') return 'Bloqueio';
|
||
return t || 'Evento';
|
||
});
|
||
|
||
const statusLabel = computed(() => {
|
||
const s = String(ev.value.status || '').toLowerCase();
|
||
if (!s || s === 'agendado') return 'Agendado';
|
||
if (s === 'realizado' || s === 'realizada') return 'Realizada';
|
||
if (s === 'faltou') return 'Faltou';
|
||
if (s === 'cancelado' || s === 'cancelada') return 'Cancelada';
|
||
if (s === 'remarcar') return 'A remarcar';
|
||
return ev.value.status;
|
||
});
|
||
|
||
const statusSlug = computed(() => {
|
||
const s = String(ev.value.status || '').toLowerCase();
|
||
if (s === 'realizada') return 'realizado';
|
||
if (s === 'cancelada') return 'cancelado';
|
||
return s || 'agendado';
|
||
});
|
||
|
||
// Sessão com paciente vinculado mostra o grupo de actions de paciente
|
||
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;
|
||
// Virtuais sem estado herdado de contrato ficam limpas (paymentState='none').
|
||
// Quando herdam 'paid' ou 'pending' de pacote upfront via propagação no
|
||
// bulk-load, exibem normalmente. Pacote saldo continua limpo (siblings
|
||
// ficam 'none' propositadamente — modelo Cliniko).
|
||
if (ev.value.is_occurrence && (!ev.value.paymentState || ev.value.paymentState === 'none')) 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;
|
||
// Pra estado 'paid', usar o VALOR REAL pago (paymentAmount, vem do
|
||
// financial_record). Em pacote upfront, é o package_price total —
|
||
// o evento.price pode ter sido editado depois e divergir. Em outros
|
||
// estados, fallback pro price/insurance_value do evento.
|
||
const valor = state === 'paid' && ev.value.paymentAmount != null
|
||
? ev.value.paymentAmount
|
||
: (ev.value.price ?? ev.value.insurance_value);
|
||
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);
|
||
const m = Math.round((decimal - h) * 60);
|
||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||
}
|
||
|
||
function duracaoMin() {
|
||
const s = ev.value.startH;
|
||
const e = ev.value.endH;
|
||
if (typeof s !== 'number' || typeof e !== 'number') return null;
|
||
return Math.max(0, Math.round((e - s) * 60));
|
||
}
|
||
|
||
function modalidadeIcon(mod) {
|
||
const m = String(mod || '').toLowerCase();
|
||
if (m === 'online') return 'pi pi-video';
|
||
return 'pi pi-map-marker';
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="evento-layer" @click.self="emit('close')">
|
||
<div class="evento-panel">
|
||
<!-- Header -->
|
||
<div class="evento-head">
|
||
<div class="evento-head__main">
|
||
<div class="evento-pill" :style="{ backgroundColor: ev.color }" />
|
||
<div class="min-w-0">
|
||
<div class="evento-tipo">
|
||
{{ tipoLabel }}
|
||
<span v-if="seriesLabel" class="evento-tipo__series">· {{ seriesLabel }}</span>
|
||
</div>
|
||
<div class="evento-titulo">
|
||
{{ isSessaoComPaciente ? ev.pacienteNome : (ev.label || ev.titulo || '—') }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
class="glass-btn evento-close"
|
||
v-tooltip.left="'Fechar (Esc)'"
|
||
@click="emit('close')"
|
||
>
|
||
<i class="pi pi-times" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Conteúdo (só campos reais) -->
|
||
<div class="evento-content">
|
||
<div class="evento-row">
|
||
<i class="pi pi-clock" />
|
||
<span>
|
||
{{ fmtHora(ev.startH) }} – {{ fmtHora(ev.endH) }}
|
||
<span v-if="duracaoMin() !== null" class="evento-row__sub">· {{ duracaoMin() }}min</span>
|
||
</span>
|
||
<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>
|
||
<button
|
||
v-if="isPartOfSeries"
|
||
type="button"
|
||
class="evento-row__edit evento-row__edit--danger"
|
||
:disabled="busy"
|
||
v-tooltip.top="'Excluir a série inteira (todas as sessões da recorrência)'"
|
||
@click="emit('delete-series')"
|
||
>
|
||
<i class="pi pi-history" />
|
||
<span>Excluir série</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>
|
||
<!-- Atalho "Gerar fatura" — só pra sessão materializada
|
||
com paymentState='none' (cobrança ainda não gerada).
|
||
Pago/pendente já existe um record; nesses casos não
|
||
cabe gerar de novo. -->
|
||
<!-- "Gerar fatura" só faz sentido pra sessão SEM contrato
|
||
de pacote OU sem saldo ativo. Em sessão de saldo o
|
||
fluxo correto é "Usar" (que orquestra cobrança +
|
||
sessions_used) — gerar fatura solta aqui criaria
|
||
cobrança duplicada e dessincronizaria o saldo. -->
|
||
<button
|
||
v-if="paymentVariant === 'none' && !ev.is_occurrence && !contractInfo"
|
||
type="button"
|
||
class="evento-row__pay-action"
|
||
:disabled="busy"
|
||
v-tooltip.top="'Gerar fatura agora'"
|
||
@click="emit('gerar-cobranca')"
|
||
>
|
||
<i class="pi pi-plus" />
|
||
<span>Gerar fatura</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Linha de info do contrato (saldo/upfront) — quando a
|
||
sessão pertence a uma série com billing_contract ativo.
|
||
Pra saldo é a única forma do user entender que tem um
|
||
pacote sem cobrança no /financeiro até realizar.
|
||
Botão "Usar" só pra saldo com saldo disponível —
|
||
materializa+realizada+gera cobrança em 1 click. -->
|
||
<div v-if="contractInfo" class="evento-row evento-row--contract" :class="`evento-row--contract-${contractInfo.style}`">
|
||
<i class="pi pi-box" />
|
||
<span>{{ contractInfo.label }}</span>
|
||
<!-- Revogar: aparece quando sessão já foi "usada" (record
|
||
pending vinculado). Bloqueia pra paid (precisa estorno
|
||
formal pelo Financeiro). -->
|
||
<button
|
||
v-if="contractInfo.style === 'saldo' && !ev.is_occurrence && ev.paymentState === 'pending'"
|
||
type="button"
|
||
class="evento-row__pay-action evento-row__pay-action--revogar"
|
||
:disabled="busy"
|
||
v-tooltip.top="'Desfaz o uso (cancela cobrança e devolve 1 ao saldo)'"
|
||
@click="emit('revogar-sessao')"
|
||
>
|
||
<i class="pi pi-undo" />
|
||
<span>Revogar</span>
|
||
</button>
|
||
<!-- Usar: aparece quando há saldo + sessão ainda não usada
|
||
(virtual OU materializada sem cobrança ativa). -->
|
||
<button
|
||
v-else-if="contractInfo.style === 'saldo' && contractInfo.remaining > 0 && (ev.is_occurrence || ev.paymentState === 'none')"
|
||
type="button"
|
||
class="evento-row__pay-action evento-row__pay-action--contract"
|
||
:disabled="busy"
|
||
v-tooltip.top="'Marca sessão como realizada e gera cobrança (1 do saldo)'"
|
||
@click="emit('usar-sessao')"
|
||
>
|
||
<i class="pi pi-check" />
|
||
<span>Usar</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="ev.modalidade" class="evento-row">
|
||
<i :class="modalidadeIcon(ev.modalidade)" />
|
||
<span class="capitalize">{{ ev.modalidade }}</span>
|
||
</div>
|
||
|
||
<div class="evento-row">
|
||
<i class="pi pi-info-circle" />
|
||
<span class="evento-status" :class="`is-${statusSlug}`">{{ statusLabel }}</span>
|
||
</div>
|
||
|
||
<div v-if="ev.descricao" class="evento-desc">
|
||
{{ ev.descricao }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Action bar — agrupada por contexto.
|
||
Cada botão tem ícone + label textual empilhados pra reduzir
|
||
ambiguidade (tooltip sozinho não é descobrível em touch). -->
|
||
<footer class="evento-actions">
|
||
<!-- Grupo Status — sempre visível pra sessão (permite trocar
|
||
de status mesmo após marcar realizado/faltou/cancelado).
|
||
Status atual fica destacado via .is-current. -->
|
||
<section v-if="isSessaoComPaciente" class="evento-actions__section">
|
||
<div class="evento-actions__label">Marcar sessão como:</div>
|
||
<div class="evento-actions__group">
|
||
<button
|
||
class="evento-act evento-act--ok"
|
||
:class="{ 'is-current': statusSlug === 'realizado' }"
|
||
:disabled="busy"
|
||
@click="emit('concluir')"
|
||
>
|
||
<i class="pi pi-check-circle" />
|
||
<span class="evento-act__label">Realizada</span>
|
||
</button>
|
||
<button
|
||
class="evento-act evento-act--warn"
|
||
:class="{ 'is-current': statusSlug === 'faltou' }"
|
||
:disabled="busy"
|
||
@click="emit('faltou')"
|
||
>
|
||
<i class="pi pi-user-minus" />
|
||
<span class="evento-act__label">Falta</span>
|
||
</button>
|
||
<button
|
||
class="evento-act"
|
||
:class="{ 'is-current': statusSlug === 'remarcar' || statusSlug === 'remarcado' }"
|
||
:disabled="busy"
|
||
@click="emit('remarcar')"
|
||
>
|
||
<i class="pi pi-calendar-clock" />
|
||
<span class="evento-act__label">Reagendar</span>
|
||
</button>
|
||
<button
|
||
class="evento-act evento-act--danger"
|
||
:class="{ 'is-current': statusSlug === 'cancelado' }"
|
||
:disabled="busy"
|
||
@click="emit('cancelar')"
|
||
>
|
||
<i class="pi pi-ban" />
|
||
<span class="evento-act__label">Cancelar</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Grupo Outras opções — só pra sessão com paciente.
|
||
"Editar" abre o cadastro do paciente (não a sessão);
|
||
pra editar a sessão, usar o botão ao lado das horas. -->
|
||
<section v-if="isSessaoComPaciente" class="evento-actions__section">
|
||
<div class="evento-actions__label">Outras opções:</div>
|
||
<div class="evento-actions__group">
|
||
<button
|
||
class="evento-act"
|
||
:disabled="busy"
|
||
@click="emit('abrir-prontuario')"
|
||
>
|
||
<i class="pi pi-file" />
|
||
<span class="evento-act__label">Prontuário</span>
|
||
</button>
|
||
<button
|
||
class="evento-act"
|
||
:disabled="busy"
|
||
@click="emit('historico')"
|
||
>
|
||
<i class="pi pi-history" />
|
||
<span class="evento-act__label">Sessões</span>
|
||
</button>
|
||
<button
|
||
class="evento-act"
|
||
:disabled="busy"
|
||
@click="emit('whatsapp')"
|
||
>
|
||
<i class="pi pi-whatsapp" />
|
||
<span class="evento-act__label">Conversar</span>
|
||
</button>
|
||
<button
|
||
class="evento-act"
|
||
:disabled="busy"
|
||
v-tooltip.top="'Editar cadastro do paciente'"
|
||
@click="emit('edit-paciente')"
|
||
>
|
||
<i class="pi pi-user-edit" />
|
||
<span class="evento-act__label">Editar</span>
|
||
</button>
|
||
</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">
|
||
<div class="evento-actions__group">
|
||
<button
|
||
class="evento-act"
|
||
:disabled="busy"
|
||
@click="emit('edit-sessao')"
|
||
>
|
||
<i class="pi pi-pencil" />
|
||
<span class="evento-act__label">Editar</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* Camada full-screen com backdrop blur — mantém pattern .evento-layer
|
||
que vivia inline no MelissaLayout (assim o lift transition no parent
|
||
continua funcionando sem alteração). */
|
||
.evento-layer {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 60;
|
||
display: grid;
|
||
place-items: center;
|
||
/* Blur XS — bem leve. O resumo continua legível atrás, só ganha
|
||
um leve "tilt-shift" pra direcionar o olhar pro panel. */
|
||
background: rgba(0, 0, 0, 0.32);
|
||
backdrop-filter: blur(4px) saturate(110%);
|
||
-webkit-backdrop-filter: blur(4px) saturate(110%);
|
||
padding: 20px;
|
||
}
|
||
|
||
.evento-panel {
|
||
width: 100%;
|
||
max-width: 480px;
|
||
background: var(--m-bg-medium, rgba(20, 20, 20, 0.85));
|
||
backdrop-filter: blur(28px) saturate(170%);
|
||
-webkit-backdrop-filter: blur(28px) saturate(170%);
|
||
border: 1px solid var(--m-border, rgba(255, 255, 255, 0.12));
|
||
border-radius: 18px;
|
||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
|
||
color: var(--m-text);
|
||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||
padding: 22px 22px 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18px;
|
||
}
|
||
|
||
/* ─── Header ────────────────────────────────────── */
|
||
.evento-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
.evento-head__main {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
min-width: 0;
|
||
}
|
||
.evento-pill {
|
||
width: 4px;
|
||
height: 38px;
|
||
border-radius: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
.evento-tipo {
|
||
color: var(--m-text-muted);
|
||
font-size: 0.65rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.2em;
|
||
font-weight: 600;
|
||
}
|
||
.evento-tipo__series {
|
||
color: var(--p-primary-color);
|
||
text-transform: none;
|
||
letter-spacing: 0;
|
||
font-weight: 500;
|
||
margin-left: 4px;
|
||
}
|
||
.evento-titulo {
|
||
color: var(--m-text);
|
||
font-size: 1.1rem;
|
||
font-weight: 500;
|
||
margin-top: 2px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 340px;
|
||
}
|
||
.evento-close {
|
||
width: 34px;
|
||
height: 34px;
|
||
display: grid;
|
||
place-items: center;
|
||
flex-shrink: 0;
|
||
background: var(--m-bg-soft, rgba(255, 255, 255, 0.08));
|
||
border: 1px solid var(--m-border, rgba(255, 255, 255, 0.12));
|
||
color: var(--m-text);
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
transition: background-color 140ms ease;
|
||
font-size: 0.85rem;
|
||
}
|
||
.evento-close:hover { background: var(--m-bg-soft-hover, rgba(255, 255, 255, 0.16)); }
|
||
|
||
/* ─── Conteúdo ──────────────────────────────────── */
|
||
.evento-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.evento-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
color: var(--m-text);
|
||
font-size: 0.88rem;
|
||
}
|
||
.evento-row > i {
|
||
color: var(--m-text-muted);
|
||
font-size: 0.95rem;
|
||
width: 18px;
|
||
text-align: center;
|
||
}
|
||
.evento-row__sub {
|
||
color: var(--m-text-muted);
|
||
margin-left: 4px;
|
||
font-size: 0.82rem;
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
/* Atalho "Gerar fatura" — pill amber pequeno ao lado de "A cobrar R$ X".
|
||
Aparece só pra paymentVariant='none' (sem cobrança ainda). Click emite
|
||
'gerar-cobranca' pro parent que chama gerarCobrancaManual sem abrir
|
||
o AgendaEventDialog. */
|
||
.evento-row__pay-action {
|
||
margin-left: auto;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 3px 10px;
|
||
border-radius: 999px;
|
||
background: color-mix(in srgb, #f59e0b 16%, transparent);
|
||
border: 1px solid color-mix(in srgb, #f59e0b 40%, transparent);
|
||
color: #b45309;
|
||
font-size: 0.74rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
transition: background-color 120ms ease, border-color 120ms ease;
|
||
}
|
||
.evento-row__pay-action:hover:not(:disabled) {
|
||
background: color-mix(in srgb, #f59e0b 26%, transparent);
|
||
border-color: color-mix(in srgb, #f59e0b 60%, transparent);
|
||
}
|
||
.evento-row__pay-action:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
.evento-row__pay-action > i {
|
||
font-size: 0.72rem;
|
||
}
|
||
html.app-dark .evento-row__pay-action {
|
||
color: #fbbf24;
|
||
background: color-mix(in srgb, #fbbf24 14%, transparent);
|
||
border-color: color-mix(in srgb, #fbbf24 30%, transparent);
|
||
}
|
||
|
||
/* Linha de contrato — exibe "Pacote saldo · N/M usadas" ou "Pacote · N/M
|
||
realizadas". Saldo em violeta (modelo Cliniko), upfront em verde
|
||
(já cobrado). Acompanha as cores do info card do AgendaEventDialog. */
|
||
.evento-row--contract {
|
||
font-weight: 500;
|
||
}
|
||
.evento-row--contract-saldo {
|
||
color: rgb(124, 58, 237); /* violet-600 */
|
||
}
|
||
.evento-row--contract-saldo > i {
|
||
color: rgb(124, 58, 237);
|
||
}
|
||
.evento-row--contract-upfront {
|
||
color: rgb(5, 150, 105); /* emerald-600 */
|
||
}
|
||
.evento-row--contract-upfront > i {
|
||
color: rgb(5, 150, 105);
|
||
}
|
||
html.app-dark .evento-row--contract-saldo {
|
||
color: #a78bfa; /* violet-400 */
|
||
}
|
||
html.app-dark .evento-row--contract-saldo > i {
|
||
color: #a78bfa;
|
||
}
|
||
html.app-dark .evento-row--contract-upfront {
|
||
color: #34d399; /* emerald-400 */
|
||
}
|
||
html.app-dark .evento-row--contract-upfront > i {
|
||
color: #34d399;
|
||
}
|
||
/* Override do pay-action quando dentro da linha de contrato — usa
|
||
violeta (saldo) pra combinar com a linha. */
|
||
.evento-row__pay-action--contract {
|
||
color: rgb(124, 58, 237) !important;
|
||
background: color-mix(in srgb, rgb(124, 58, 237) 12%, transparent) !important;
|
||
border-color: color-mix(in srgb, rgb(124, 58, 237) 36%, transparent) !important;
|
||
}
|
||
.evento-row__pay-action--contract:hover:not(:disabled) {
|
||
background: color-mix(in srgb, rgb(124, 58, 237) 22%, transparent) !important;
|
||
border-color: color-mix(in srgb, rgb(124, 58, 237) 56%, transparent) !important;
|
||
}
|
||
html.app-dark .evento-row__pay-action--contract {
|
||
color: #a78bfa !important;
|
||
background: color-mix(in srgb, #a78bfa 14%, transparent) !important;
|
||
border-color: color-mix(in srgb, #a78bfa 30%, transparent) !important;
|
||
}
|
||
/* Revogar — vermelho/amber pra sinalizar ação destrutiva. */
|
||
.evento-row__pay-action--revogar {
|
||
color: rgb(220, 38, 38) !important; /* red-600 */
|
||
background: color-mix(in srgb, rgb(220, 38, 38) 10%, transparent) !important;
|
||
border-color: color-mix(in srgb, rgb(220, 38, 38) 32%, transparent) !important;
|
||
}
|
||
.evento-row__pay-action--revogar:hover:not(:disabled) {
|
||
background: color-mix(in srgb, rgb(220, 38, 38) 18%, transparent) !important;
|
||
border-color: color-mix(in srgb, rgb(220, 38, 38) 50%, transparent) !important;
|
||
}
|
||
html.app-dark .evento-row__pay-action--revogar {
|
||
color: #f87171 !important; /* red-400 */
|
||
background: color-mix(in srgb, #f87171 14%, transparent) !important;
|
||
border-color: color-mix(in srgb, #f87171 30%, transparent) !important;
|
||
}
|
||
|
||
/* 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);
|
||
border: 1px solid var(--m-border);
|
||
border-radius: 100px;
|
||
color: var(--m-text-muted);
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||
}
|
||
.evento-row__edit:hover:not(:disabled) {
|
||
background: var(--m-bg-soft-hover);
|
||
color: var(--m-text);
|
||
border-color: var(--m-accent, var(--primary-color, #7c6af7));
|
||
}
|
||
.evento-row__edit:disabled {
|
||
opacity: 0.4;
|
||
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;
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
border: 1px solid var(--m-border);
|
||
background: var(--m-bg-soft);
|
||
}
|
||
.evento-status.is-realizado {
|
||
color: rgb(16, 185, 129);
|
||
border-color: rgba(16, 185, 129, 0.35);
|
||
background: rgba(16, 185, 129, 0.12);
|
||
}
|
||
.evento-status.is-faltou {
|
||
color: rgb(239, 68, 68);
|
||
border-color: rgba(239, 68, 68, 0.35);
|
||
background: rgba(239, 68, 68, 0.12);
|
||
}
|
||
.evento-status.is-cancelado {
|
||
color: rgb(148, 163, 184);
|
||
border-color: rgba(148, 163, 184, 0.35);
|
||
background: rgba(148, 163, 184, 0.12);
|
||
}
|
||
.evento-status.is-remarcar {
|
||
color: rgb(245, 158, 11);
|
||
border-color: rgba(245, 158, 11, 0.35);
|
||
background: rgba(245, 158, 11, 0.12);
|
||
}
|
||
.evento-desc {
|
||
background: var(--m-bg-soft);
|
||
border: 1px solid var(--m-border);
|
||
border-radius: 10px;
|
||
padding: 10px 12px;
|
||
color: var(--m-text);
|
||
font-size: 0.85rem;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
max-height: 140px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* ─── Action bar ────────────────────────────────── */
|
||
.evento-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding-top: 14px;
|
||
border-top: 1px solid var(--m-border);
|
||
}
|
||
.evento-actions__section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.evento-actions__label {
|
||
font-size: 0.7rem;
|
||
color: var(--m-text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
font-weight: 600;
|
||
padding-left: 2px;
|
||
}
|
||
.evento-actions__group {
|
||
display: flex;
|
||
gap: 4px;
|
||
background: var(--m-bg-soft);
|
||
border: 1px solid var(--m-border);
|
||
border-radius: 12px;
|
||
padding: 4px;
|
||
}
|
||
.evento-act {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
padding: 8px 4px;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--m-text);
|
||
border-radius: 9px;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
font-family: inherit;
|
||
transition: background-color 140ms ease, color 140ms ease, transform 140ms ease;
|
||
}
|
||
.evento-act__label {
|
||
font-size: 0.7rem;
|
||
line-height: 1.1;
|
||
font-weight: 500;
|
||
letter-spacing: 0.01em;
|
||
white-space: nowrap;
|
||
}
|
||
.evento-act:hover:not(:disabled) {
|
||
background: var(--m-bg-soft-hover);
|
||
transform: translateY(-1px);
|
||
}
|
||
.evento-act:focus-visible {
|
||
outline: 2px solid var(--m-accent);
|
||
outline-offset: 2px;
|
||
}
|
||
.evento-act:disabled {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
}
|
||
.evento-act--ok:hover:not(:disabled) {
|
||
color: rgb(16, 185, 129);
|
||
background: rgba(16, 185, 129, 0.15);
|
||
}
|
||
.evento-act--warn:hover:not(:disabled) {
|
||
color: rgb(245, 158, 11);
|
||
background: rgba(245, 158, 11, 0.15);
|
||
}
|
||
.evento-act--danger:hover:not(:disabled) {
|
||
color: rgb(239, 68, 68);
|
||
background: rgba(239, 68, 68, 0.15);
|
||
}
|
||
|
||
/* Estado .is-current — sinaliza o status atual da sessão dentro do
|
||
grupo de actions. Permite que o usuário troque o status mesmo após
|
||
marcar realizado/faltou/cancelado, vendo qual está ativo. */
|
||
.evento-act.is-current {
|
||
background: rgba(255, 255, 255, 0.12);
|
||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25);
|
||
}
|
||
.evento-act--ok.is-current {
|
||
color: rgb(16, 185, 129);
|
||
background: rgba(16, 185, 129, 0.18);
|
||
box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.55);
|
||
}
|
||
.evento-act--warn.is-current {
|
||
color: rgb(245, 158, 11);
|
||
background: rgba(245, 158, 11, 0.18);
|
||
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.55);
|
||
}
|
||
.evento-act--danger.is-current {
|
||
color: rgb(239, 68, 68);
|
||
background: rgba(239, 68, 68, 0.18);
|
||
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.55);
|
||
}
|
||
|
||
/* Light mode — overlay ainda mais discreto */
|
||
html:not(.app-dark) .evento-layer {
|
||
background: rgba(15, 23, 42, 0.18);
|
||
}
|
||
</style>
|