Files
agenciapsilmno/src/layout/melissa/MelissaEventoPanel.vue
T
Leonardo fad1f4ebd4 agenda: C8 OK + Usar/Revogar pacote saldo + UI de contract + ajustes UX
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>
2026-05-19 23:27:20 -03:00

949 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 ( 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" pra sessão materializada
com paymentState='none' (cobrança ainda não gerada).
Pago/pendente existe um record; nesses casos não
cabe gerar de novo. -->
<!-- "Gerar fatura" 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" 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 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 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 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. 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>