Files
agenciapsilmno/src/features/agenda/components/AgendaEventDialog.vue
T
Leonardo c23d0a574f agenda: C5+C6 testes OK + atalho Gerar fatura + RPC idempotência fix
DB
- migration 20260519000001: create_financial_record_for_session passa a
  ignorar status='cancelled' na idempotência (era bug — cancelar e tentar
  regerar travava silencioso retornando o cancelado)

Cenário 5 (convênio) — fixes pra save + visualização
- Convênio: amount lia 'price' (null) → agora detecta via insurance_plan_id
  e usa insurance_value. payment_method forçado 'convenio' (era 'asaas')
- Popover: ev.price era null em convênio → normalize expõe insurance_value
  e paymentLabel faz fallback. Linha mostra "A receber R$ X" corretamente
- /financeiro: branch novo pra payment_method='convenio' → pill violeta
  com pi-id-card (antes ficava sem indicador, igual particular)

Cenário 6 (recorrente sem pacote, Maria Magali) — materialização
- chargeMode='none' não materializava a 1ª (todas viravam virtuais, sem
  badge $). Agora materializa a 1ª no fluxo de criação recorrente
- Bug intermediário: usei 'paciente_id' (Portuguese) mas agendaRepository
  dropa esse campo. Corrigido pra 'patient_id' (English DB column)

Atalho "Gerar fatura" no popover
- Pill amber pequeno ao lado de "A cobrar R$ X" no popover (paymentVariant
  ='none' + sessão materializada)
- Wire em MelissaLayout via emit gerar-cobranca + handler onGerarCobrancaQuick
  (chama gerarCobrancaManual, fecha popover pra impedir double-click)
- Bulk-load do useMelissaAgenda e fetchRecord do AgendaEventoFinanceiroPanel
  agora filtram status='cancelled' (resolve badge $ residual + botão sumido)

Header do popover: info de pacote/série
- "Sessão · Pacote · N sessões" ou "Sessão X de Y" abaixo do tipo
  (computed seriesLabel lê do _raw da rule)

Título do dialog "Sessão do Pacote · Sessão"
- Quando commitment name é "Sessão" (default), drop pra evitar duplicação
- Outros nomes (Avaliação, etc) permanecem com forma completa

Excluir série inteira (popover)
- Novo botão "Excluir série" no popover quando evento pertence a recorrência
- Hard delete: financial_records pendentes → agenda_eventos materializados
  → recurrence_rules (CASCADE leva exceptions + rule_services)
- Bloqueia se algum record tem status='paid' (estornar primeiro)

cancel_session some da agenda
- useRecurrence.expandRules agora pula occurrence com exception type=
  'cancel_session' (era visível com status cancelado; doc dizia "some
  da agenda" mas código mantinha. Honra a promessa do diálogo)
- patient_missed / therapist_canceled / holiday_block permanecem visíveis
  como histórico

UX outros
- "+ Novo convênio" toolbar em ConfiguracoesConveniosPage (botão faltava
  — empty state mandava clicar em botão inexistente)
- InsurancePlanServiceQuickCreateDialog: cadastrar procedimento POR CIMA
  do AgendaEventDialog sem sair da agenda. Auto-seleciona quando nada
  estava selecionado antes
- Hint contextual abaixo do card Sessão/Honorários: convênio = "Nº guia
  opcional"; gratuito = "sem cobrança". Particular sem hint
- recurrence_exceptions cancel agora usa upsert com onConflict
  (idempotente, não quebra com unique violation em re-cancel)
- goToConveniosConfig removida (dead code após quick-create inline)

CSS
- .aed-row-50 perdeu margin-bottom (user request)
- .field-card.mb-4 ganhou margin-top: 1rem (scoped a composer wrappers)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:23:42 -03:00

5761 lines
244 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.
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/AgendaEventDialog.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch, nextTick, onBeforeUnmount } from 'vue';
import { generateGoogleCalendarLink, formatGCalDate, addMinutesToHHMM } from '@/utils/googleCalendarLink';
import { useRouter, useRoute } from 'vue-router';
import Select from 'primevue/select';
import Textarea from 'primevue/textarea';
import DatePicker from 'primevue/datepicker';
import InputNumber from 'primevue/inputnumber';
// RadioButton removido (auto-import via PrimeVueResolver caso necessário no template)
import Message from 'primevue/message';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import AgendaEventoFinanceiroPanel from '@/components/agenda/AgendaEventoFinanceiroPanel.vue';
import ServiceQuickCreateDialog from './ServiceQuickCreateDialog.vue';
import InsurancePlanQuickCreateDialog from './InsurancePlanQuickCreateDialog.vue';
import InsurancePlanServiceQuickCreateDialog from './InsurancePlanServiceQuickCreateDialog.vue';
import { useServices } from '@/features/agenda/composables/useServices';
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts';
import { useInsurancePlans } from '@/features/agenda/composables/useInsurancePlans';
// getPatientAgendaPermissions agora é importado dentro do composer (1B)
// A66 sub-sessão 1A — helpers PUROS extraídos pra módulo isolado e
// testável. Antes viviam inline neste arquivo (24 funções, ~150 linhas).
// Agora vem de um único import; lógica idêntica, só repousada.
import {
patientInitials,
fmtBRL,
fmtJornadaHora,
fmtDateBR,
fmtDateBRLong,
fmtTime,
fmtDuracao,
nomeDiaSemana,
fmtWeekdayShort,
fmtDayNum,
fmtMonthShort,
hhmmToMin,
minToHHMM,
isPast,
isNativeSession,
calcFinalPrice,
labelStatusSessao,
statusSeverity,
statusExtraClass
} from '@/features/agenda/composables/agendaEventHelpers';
// A66 sub-sessão 1B — composable factory com state + computeds derivados.
import { useAgendaEventComposer } from '@/features/agenda/composables/useAgendaEventComposer';
// A66 sub-sessão 1C-i — watchers + handlers de save/delete.
import { useAgendaEventActions } from '@/features/agenda/composables/useAgendaEventActions';
// A66 sub-sessão 1C-ii-a — patient picker + billing items + 2 watchers
// (form.commitment_id auto-fill price, form.insurance_plan_id limpa items).
import { useAgendaEventPickerBilling } from '@/features/agenda/composables/useAgendaEventPickerBilling';
// A66 sub-sessão 1C-ii-b — lifecycle: 4 watchers (modelValue init, tenant
// scope, solicitação pendente, online slots) + series pills + selectSlot
// + quick-creates wiring + onSendManualReminder.
import { useAgendaEventLifecycle } from '@/features/agenda/composables/useAgendaEventLifecycle';
const props = defineProps({
modelValue: { type: Boolean, default: false },
eventRow: { type: Object, default: null },
initialStartISO: { type: String, default: '' },
initialEndISO: { type: String, default: '' },
ownerId: { type: String, default: '' },
planOwnerId: { type: String, default: '' }, // owner dos convênios (clínica); fallback: ownerId
allowOwnerEdit: { type: Boolean, default: false },
ownerOptions: { type: Array, default: () => [] },
tenantId: { type: String, default: '' },
commitmentOptions: { type: Array, default: () => [] },
presetCommitmentId: { type: [String, null], default: null },
lockCommitment: { type: Boolean, default: false },
restrictPatientsToOwner: { type: Boolean, default: false },
patientScopeOwnerId: { type: String, default: null },
workRules: { type: Array, default: () => [] },
blockedDates: { type: Array, default: () => [] },
agendaSettings: { type: Object, default: null },
allEvents: { type: Array, default: () => [] },
pausasSemanais: { type: Array, default: () => [] },
feriados: { type: Array, default: () => [] },
// Rota para cadastro completo de paciente (abre em nova aba)
newPatientRoute: { type: String, default: '' },
// ── Locks aditivos (default false — comportamento atual) ──────────
// Usados pelo MelissaPaciente.vue pra abrir esse dialog ja em
// contexto de Sessao + paciente fixo do prontuario. Os 5 callsites
// existentes (TherapistDashboard, PatientsListPage, MelissaAgenda,
// MelissaAgendamentosRecebidos, MelissaLayout) seguem com defaults.
//
// lockType=true: pula o step de escolha de tipo (commitment cards) e
// vai direto pro form. Espera que eventRow ja venha com tipo+commitment
// resolvidos (no MelissaPaciente: tipo='sessao').
//
// lockPatient=true: esconde os botoes "trocar"/"limpar" do paciente
// e mostra o icon de lock (mesma UX do patientLocked computed que
// ja existia pra modo edit, agora cobre tambem cenarios "criar sessao
// pra paciente fixo").
lockType: { type: Boolean, default: false },
lockPatient: { type: Boolean, default: false },
// occurrenceMode=true: dialog empilhado pra editar UMA ocorrencia da
// serie (acionado pelo botao "Editar" das pills). Layout enxuto:
// titulo "Pacote · X de Y Sessoes", apenas 4 cards (recorrencia,
// status, horario, escopo). O card "Aplicar alteracoes em" some do
// dialog pai e migra pra ca. Adicionado 2026-05-12.
occurrenceMode: { type: Boolean, default: false },
// serieRefreshTick: contador que bumpa quando o 2º dialog empilhado
// salva uma ocorrencia. Watch interno re-roda loadSerieEvents pra
// atualizar status das pills no dialog pai. So usado no dialog pai
// (no occurrenceMode é ignorado). Adicionado 2026-05-12.
serieRefreshTick: { type: Number, default: 0 },
// blockOverlapWarning: aviso quando o slot escolhido cai em cima de
// um bloqueio próprio do terapeuta. Renderizado como Message warn no
// topo do step 1. Shape: { titulo: string } ou null. O fluxo NÃO é
// bloqueado (só agendador público veta); a mensagem só sinaliza.
// Substitui o toast antigo que ficava atrás do overlay do dialog.
blockOverlapWarning: { type: Object, default: null }
});
const emit = defineEmits(['update:modelValue', 'save', 'delete', 'updateSeriesEvent', 'editSeriesOccurrence', 'updated']);
const confirm = useConfirm();
const toast = useToast();
const router = useRouter();
const route = useRoute();
// ──────────────────────────────────────────────────────────────────
// A66 sub-sessão 1B — state + computeds derivados extraídos pra
// composable factory (testado isoladamente em useAgendaEventComposer.spec).
// Refs externos (commitmentItems, serieEvents) declarados aqui antes
// pra serem passados via extras — o composer usa em canSave e isFirstOccurrence.
// ──────────────────────────────────────────────────────────────────
const commitmentItems = ref([]);
const serieEvents = ref([]);
const _composer = useAgendaEventComposer(props, emit, { commitmentItems, serieEvents });
const {
visible,
step,
editScope,
recorrenciaType,
diasSelecionados,
qtdSessoesMode,
qtdSessoesCustom,
dataLimiteManual,
billingType,
form,
isEdit,
allowBack,
hasSerie,
currentRecurrenceDate,
isFirstOccurrence,
editScopeOptions,
qtdSessoesEfetiva,
diaSemanaRecorrencia,
proximasOcorrencias,
dataFimCalculada,
totalOcorrencias,
sessoesForaDoPlano,
ocorrenciasComConflito,
totalConflitos,
commitmentCards,
selectedCommitment,
selectedCommitmentName,
selectedCommitmentFields,
requiresPatient,
isSessionEvent,
patientLocked,
hasInsurance,
agendaPerms,
isSessionFuture,
isArchivedPastEdit,
isInativoFutureEdit,
statusOptionsFiltered,
startTimeDate,
inicioDateTime,
fimDateTime,
dataHoraDisplay,
previewRange,
computedTitulo,
headerTitle,
canSave,
timeConflict,
toggleDiaSelecionado,
isForaDoPlano,
resetForm
} = _composer;
// Lock de tipo: quando lockType=true (ex: MelissaPaciente), simula o
// click no card "Sessão" do step 1. Chama selectCommitment com o
// commitment native_key='session' — isso seta form.commitment_id +
// extra_fields + step.value=2. Sem isso, pular pro step 2 sem
// commitment_id deixaria isSessionEvent=false e esconderia jornada,
// billing (particular/convenio/gratuito) e frequencia.
//
// Re-roda quando commitmentOptions popula tarde (caso usual: dialog
// abre antes do tenant load terminar). Idempotente: so chama se ainda
// nao tem commitment_id ou step != 2.
watch(
[() => props.lockType, () => props.modelValue, () => props.commitmentOptions],
([locked, open]) => {
if (!locked || !open) return;
const sessao = commitmentCards.value.find((c) => c.native_key === 'session');
if (!sessao) return;
if (!form.value.commitment_id || form.value.commitment_id !== sessao.id) {
selectCommitment(sessao);
} else if (step.value !== 2) {
step.value = 2;
}
},
{ immediate: true }
);
// ── recorrência: opções estáticas (consts data — não migradas) ──
const freqOpcoes = [
{ value: 'avulsa', label: 'Avulsa' },
{ value: 'semanal', label: 'Semanal' },
{ value: 'quinzenal', label: 'Quinzenal' },
{ value: 'diasEspecificos', label: 'Dias específicos' }
];
const diasSemanaOpcoes = [
{ value: 1, short: 'Seg' },
{ value: 2, short: 'Ter' },
{ value: 3, short: 'Qua' },
{ value: 4, short: 'Qui' },
{ value: 5, short: 'Sex' },
{ value: 6, short: 'Sáb' },
{ value: 0, short: 'Dom' }
];
function setHoje() {
form.value.dia = new Date();
}
const qtdSessoesOpcoes = [
{ value: '4', label: '4 sessões' },
{ value: '8', label: '8 sessões' },
{ value: '12', label: '12 sessões' },
{ value: 'personalizar', label: 'Personalizar' }
];
// Linha superior de cada opção — equivalente em meses, varia por frequência.
// Semanal: 4/8/12 sessões = 1/2/3 meses. Quinzenal: 4/8/12 = 2/4/6 meses.
// Outros tipos (diasEspecificos, avulsa): sem topLabel.
function qtdSessoesTopLabel(value) {
if (value === 'personalizar') return null;
const t = recorrenciaType.value;
if (t === 'semanal') {
if (value === '4') return '1 mês';
if (value === '8') return '2 meses';
if (value === '12') return '3 meses';
}
if (t === 'quinzenal') {
if (value === '4') return '2 meses';
if (value === '8') return '4 meses';
if (value === '12') return '6 meses';
}
return null;
}
// recorrenciaType, diasSelecionados, toggleDiaSelecionado, qtdSessoesMode,
// qtdSessoesCustom, qtdSessoesEfetiva, diaSemanaRecorrencia → composer (1B)
// proximasOcorrencias, dataFimCalculada, totalOcorrencias, dataLimiteManual,
// isForaDoPlano, sessoesForaDoPlano, conflictForDate, ocorrenciasComConflito,
// totalConflitos, commitmentCards, form (+ resetForm) → composer (1B)
// isNativeSession → agendaEventHelpers (1A)
// Watcher status confirm (cancelado/remarcado), watcher billingType,
// watcher [paciente_id, dia], onSave, onDelete, onEncerrarSerie
// → useAgendaEventActions (1C-i). Refs internos (_skipStatusWatch,
// _prevStatus, _restoringConvenio, samePatientConflict) vêm do composable
// abaixo, declarado depois das dependências (services/insurancePlans/etc).
// ── Precificação / Serviços ─────────────────────────────────────────
const { services, getDefaultPrice, load: loadServices } = useServices();
const { loadItems: _csLoadItems, saveItems: saveCommitmentItems, loadItemsOrTemplate: _csLoadItemsOrTemplate } = useCommitmentServices();
const { loadActive: loadActiveDiscount } = usePatientDiscounts();
const { plans: insurancePlans, load: loadInsurancePlans } = useInsurancePlans();
const selectedPlanService = ref(null);
// Quick-create dialogs (serviceQuickDlgOpen/insuranceQuickDlgOpen) +
// openServiceQuickCreate / onServiceCreated / openInsuranceQuickCreate /
// onInsuranceCreated → useAgendaEventLifecycle (1C-ii-b)
const activePlans = computed(() => insurancePlans.value.filter((p) => p.active !== false));
const planServices = computed(() => {
if (!form.value.insurance_plan_id) return [];
return (insurancePlans.value.find((p) => p.id === form.value.insurance_plan_id)?.insurance_plan_services || []).filter((s) => s.active);
});
// ensureServicesLoaded + applyDefaultPrice → useAgendaEventPickerBilling (1C-ii-a)
// ── Itens de serviço (commitment_services) ──────────────────────────
// commitmentItems já declarado no topo (passado ao composer via extras)
const servicePickerSel = ref(null);
// serieValorMode usado no template + watcher init (1C-ii-b) — declarado
// antes do useAgendaEventLifecycle abaixo.
const serieValorMode = ref('multiplicar'); // 'multiplicar' | 'dividir'
// isDynamic precisa existir antes do useAgendaEventPickerBilling abaixo
// (passado como dep). Movido pra cá da seção de computeds derivados.
const isDynamic = computed(() => (props.agendaSettings?.slot_mode ?? 'fixed') === 'dynamic');
// billingType → composer (1B); watcher → useAgendaEventActions (1C-i)
const billingTypeOptions = [
{ label: 'Sessão Particular', value: 'particular' },
{ label: 'Sessão Gratuita', value: 'gratuito' },
{ label: 'Convênio', value: 'convenio' }
];
// ──────────────────────────────────────────────────────────────────
// A66 sub-sessão 1C-i — watchers + handlers de save/delete via factory.
// Cobrança ao salvar — SelectButton com opções dinâmicas (Opção C1, 2026-05-13).
// - Avulsa: ['none' | 'session']
// - Recorrente: ['none' | 'package' | 'per_session']
// Declarado ANTES do _actions pq onSave precisa incluir chargeMode no payload.
// Substituiu o checkbox boolean gerarCobrancaAoSalvar (Fase 1 original).
//
// Default (user pediu 2026-05-14): em avulsa, vem com 'session' selecionado
// (fluxo normal é cobrar). Em recorrente, mantém 'none' (mais conservador —
// criar pacote ou per-session requer decisão consciente).
const chargeMode = ref('session');
function _defaultChargeModeFor(recorrType) {
return recorrType === 'avulsa' ? 'session' : 'none';
}
watch(
() => props.modelValue,
(open) => {
if (open) chargeMode.value = _defaultChargeModeFor(_composer.recorrenciaType?.value);
}
);
// Quando user troca entre avulsa ↔ recorrente sem fechar o dialog,
// reseta pro default daquele modo (avulsa='session', recorrente='none').
// Mostra mensagem warning de mudança que PERSISTE até user fechar o dialog
// OU até nova troca de tipo (que sobrescreve). 2026-05-14.
const chargeChangeMsg = ref('');
// Key incrementa a cada troca pra forçar remount → re-dispara animação de pulse.
const chargeChangeMsgKey = ref(0);
watch(
() => _composer.recorrenciaType?.value,
(newType, oldType) => {
chargeMode.value = _defaultChargeModeFor(newType);
// Só mostra msg de mudança se houve transição real (não no init)
if (oldType && newType && oldType !== newType) {
const labels = {
avulsa: 'sessão avulsa',
semanal: 'recorrência semanal',
quinzenal: 'recorrência quinzenal',
diasEspecificos: 'recorrência personalizada'
};
const from = labels[oldType] || oldType;
const to = labels[newType] || newType;
chargeChangeMsg.value = `Você alterou de <b>${from}</b> para <b>${to}</b>. Confirme a forma de cobrança abaixo.`;
chargeChangeMsgKey.value++;
}
}
);
// Reset da mensagem só quando dialog reabre (zero state).
watch(
() => props.modelValue,
(open) => {
if (open) chargeChangeMsg.value = '';
}
);
const chargeModeOptions = computed(() => {
const isAvulsa = _composer.recorrenciaType?.value === 'avulsa';
if (isAvulsa) {
return [
{ value: 'none', label: 'Não cobrar agora' },
{ value: 'session', label: 'Gerar cobrança' }
];
}
return [
{ value: 'none', label: 'Não cobrar agora' },
{ value: 'package', label: 'Pacote único' },
{ value: 'per_session', label: '1 por sessão' }
];
});
const chargeModeHint = computed(() => {
switch (chargeMode.value) {
case 'session':
return 'Cria 1 cobrança no Financeiro com vencimento na data da sessão.';
case 'package':
return 'Cria 1 contrato de pacote no Financeiro com o valor total da série.';
case 'per_session':
return 'Materializa todas as ocorrências e cria 1 cobrança por sessão.';
case 'none':
default:
return 'Você pode gerar a cobrança depois pelo Financeiro ou pelo painel da sessão.';
}
});
// Estilo do pacote (2026-05-14): híbrido — quando chargeMode='package',
// admin escolhe se quer cobrar tudo upfront (1 financial_record total)
// ou só registrar saldo (Cliniko-style, sem cobrança imediata).
const packageStyle = ref('upfront');
const packageStyleOptions = [
{ value: 'upfront', label: 'Cobrar total agora (1 boleto fechado)' },
{ value: 'saldo', label: 'Saldo de pacote (sem cobrança imediata)' }
];
const showPackageStyle = computed(() => chargeMode.value === 'package');
// Forma de pagamento (Request 1, 2026-05-14; refatorado 2026-05-16).
// Antes era um único Select que misturava método + status ("Já recebi — PIX").
// Agora separamos em 2 controles:
// - paymentMethod: forma de recebimento (link/pix/dinheiro/deposito/cartao_maquininha)
// - markPaidNow: bool — quando true, record nasce paid (paciente pagou na hora)
// Quando method === 'link' (Asaas), markPaidNow é sempre false (gateway externo
// só liquida via webhook). Os 2 controles juntam-se em paymentMethodOptions +
// SelectButton "Cobrança pendente / Já recebi (dar baixa)".
const paymentMethod = ref('link');
const paymentMethodOptions = [
{ value: 'link', label: 'Enviar link de pagamento (Asaas)' },
{ value: 'pix', label: 'PIX' },
{ value: 'dinheiro', label: 'Dinheiro' },
{ value: 'deposito', label: 'Depósito' },
{ value: 'cartao_maquininha', label: 'Cartão (maquininha)' }
];
const markPaidNow = ref(false);
const markPaidOptions = [
{ value: false, label: 'Cobrança pendente' },
{ value: true, label: 'Já recebi (dar baixa)' }
];
// SelectButton de baixa só faz sentido quando método é direto (não Asaas).
const showMarkPaidToggle = computed(() => paymentMethod.value !== 'link');
watch(
() => props.modelValue,
(open) => {
if (open) {
paymentMethod.value = 'link';
markPaidNow.value = false;
packageStyle.value = 'upfront';
}
}
);
// Reset method/status e packageStyle quando user muda chargeMode.
watch(chargeMode, () => {
paymentMethod.value = 'link';
markPaidNow.value = false;
packageStyle.value = 'upfront';
});
// Se user volta pra 'link' (Asaas), garante que markPaidNow=false (gateway
// externo nunca nasce paid — depende do webhook).
watch(paymentMethod, (m) => {
if (m === 'link') markPaidNow.value = false;
});
// Settlement aparece em:
// - avulsa + 'session': 1 cobrança da sessão
// - recorrente + 'package' + 'upfront': 1 cobrança fechada do pacote total
// Não aparece em 'per_session' (N cobranças futuras, "já recebi" não cabe)
// nem em 'package' + 'saldo' (sem cobrança imediata).
const showPaymentSettlement = computed(() => {
if (_composer.recorrenciaType?.value === 'avulsa' && chargeMode.value === 'session') return true;
if (chargeMode.value === 'package' && packageStyle.value === 'upfront') return true;
return false;
});
// Recebe o composer (1B) + refs externos (commitmentItems, servicePickerSel,
// selectedPlanService) + saveCommitmentItems do useCommitmentServices.
// Retorna: refs internos (_skipStatusWatch, _prevStatus, _restoringConvenio,
// samePatientConflict) + handlers (onSave, onDelete, onEncerrarSerie).
// ──────────────────────────────────────────────────────────────────
const _actions = useAgendaEventActions({
composer: _composer,
commitmentItems,
servicePickerSel,
selectedPlanService,
saveCommitmentItems,
chargeMode,
packageStyle,
paymentMethod,
markPaidNow,
props,
emit
});
const {
_skipStatusWatch,
_prevStatus,
_restoringConvenio,
samePatientConflict,
onSave,
onDelete,
onEncerrarSerie
} = _actions;
// ──────────────────────────────────────────────────────────────────
// A66 sub-sessão 1C-ii-a — patient picker + billing items + 2 watchers.
// Recebe composer + actions (pra _restoringConvenio e samePatientConflict)
// + refs externos + composables (services, insurancePlans, discounts).
// ──────────────────────────────────────────────────────────────────
const _pickerBilling = useAgendaEventPickerBilling({
composer: _composer,
actions: _actions,
commitmentItems,
servicePickerSel,
selectedPlanService,
services,
loadServices,
getDefaultPrice,
planServices,
loadActiveDiscount,
_csLoadItems,
_csLoadItemsOrTemplate,
isDynamic,
props
});
const {
pacientePickerOpen,
pacienteSearch,
pacientesLoading,
pacientesError,
patients,
cadRapidoOpen,
ensureServicesLoaded,
resetServicesGate,
applyDefaultPrice,
addItem,
removeItem,
onItemChange,
_loadCommitmentItemsForEvent,
onProcedureSelect,
selectCommitment,
goBack,
openPacientePicker,
clearPatientsCache,
loadPatients,
selectPaciente,
clearPaciente,
openCadastroRapido,
abrirCadastroCompleto
} = _pickerBilling;
// ──────────────────────────────────────────────────────────────────
// A66 sub-sessão 1C-ii-b — lifecycle: 4 watchers (modelValue init,
// tenant/scope, solicitação pendente, online slots) + series pills
// + selectSlot + quick-creates wiring + onSendManualReminder.
// ──────────────────────────────────────────────────────────────────
const _lifecycle = useAgendaEventLifecycle({
composer: _composer,
actions: _actions,
pickerBilling: _pickerBilling,
commitmentItems,
serieEvents,
servicePickerSel,
selectedPlanService,
serieValorMode,
services,
loadServices,
loadInsurancePlans,
props,
emit,
confirm,
toast
});
const {
solicitacaoPendente,
onlineSlots,
loadingOnlineSlots,
serieLoading,
pillDeleteMenuRef,
pillDeleteTarget,
sendingReminder,
serviceQuickDlgOpen,
insuranceQuickDlgOpen,
planServiceQuickDlgOpen,
occFinancialRecord,
occFinancialLoading,
sessionPaymentRecord,
serieCountByStatus,
pillDeleteMenuItems,
loadSerieEvents,
loadOccFinancialRecord,
loadSessionPaymentRecord,
onPillEditClick,
onPillStatusChange,
onPillDeleteClick,
onPillDelete,
selectSlot,
openServiceQuickCreate,
onServiceCreated,
openInsuranceQuickCreate,
onInsuranceCreated,
openPlanServiceQuickCreate,
onPlanServiceCreated,
onSendManualReminder
} = _lifecycle;
// serieRefreshTick: bumpa quando o 2º dialog empilhado salva/deleta uma
// ocorrencia. Re-roda loadSerieEvents pra refletir o novo status nas pills
// do dialog pai. No occurrenceMode é ignorado — só o pai tem pills/lista.
// 2026-05-12.
watch(
() => props.serieRefreshTick,
(tick, prev) => {
if (props.occurrenceMode) return;
if (tick === prev) return;
if (!props.modelValue) return;
if (!hasSerie.value) return;
loadSerieEvents();
}
);
const totalFromItems = computed(() => commitmentItems.value.reduce((sum, item) => sum + (item.final_price ?? 0), 0));
// Duração calculada como soma de services.duration_min dos itens (slot_mode=dynamic)
const dynamicDuration = computed(() => {
if (!isDynamic.value) return null;
return commitmentItems.value.reduce((sum, item) => {
const svc = services.value.find((s) => s.id === item.service_id);
return sum + (svc?.duration_min ?? 0);
}, 0);
});
// Preço exibido no resumo: total dos itens quando há itens, form.price caso contrário
const displayPrice = computed(() => (commitmentItems.value.length > 0 ? totalFromItems.value : form.value.price));
// Aviso informativo de valor total da série (não altera os valores gravados)
const serieValorAviso = computed(() => {
if (recorrenciaType.value === 'avulsa' || !commitmentItems.value.length) return null;
const n = qtdSessoesEfetiva.value;
if (!n || !totalFromItems.value) return null;
if (serieValorMode.value === 'multiplicar') {
return `Total da série: ${fmtBRL(totalFromItems.value * n)} (${fmtBRL(totalFromItems.value)} × ${n} sessões)`;
}
return `Valor por sessão: ${fmtBRL(totalFromItems.value / n)} (${fmtBRL(totalFromItems.value)} ÷ ${n} sessões)`;
});
// Sync: total dos itens → form.price
watch(totalFromItems, (total) => {
if (commitmentItems.value.length > 0) form.value.price = total;
});
// Sync: duração dinâmica → form.duracaoMin (slot_mode=dynamic)
watch(dynamicDuration, (dur) => {
if (isDynamic.value && dur != null && dur > 0) form.value.duracaoMin = dur;
});
// calcFinalPrice movido pra agendaEventHelpers.js (A66/1A)
// addItem, removeItem, onItemChange, _loadCommitmentItemsForEvent
// → useAgendaEventPickerBilling (1C-ii-a)
// fmtBRL movido pra agendaEventHelpers.js (A66/1A)
// selectedCommitment, selectedCommitmentName, selectedCommitmentFields,
// requiresPatient, isSessionEvent, patientLocked, hasInsurance → composer (1B)
// ── jornada ────────────────────────────────────────────────
// fmtJornadaHora (antes _fmtH) movido pra agendaEventHelpers.js (A66/1A)
const jornadaDialog = computed(() => {
const rules = props.workRules;
if (!rules?.length) return null;
const d = form.value.dia ? new Date(form.value.dia) : new Date();
const dow = d.getDay();
const rule = rules.find((r) => Number(r.dia_semana) === dow);
if (!rule) return { text: 'Este é um dia de folga na sua agenda.', isOff: true };
const inicio = String(rule.hora_inicio || '').slice(0, 5);
const fim = String(rule.hora_fim || '').slice(0, 5);
const [h1, m1] = inicio.split(':').map(Number);
const [h2, m2] = fim.split(':').map(Number);
const totalMin = h2 * 60 + m2 - (h1 * 60 + m1);
const durH = Math.floor(totalMin / 60);
const durM = totalMin % 60;
const durStr = durM > 0 ? `${durH}h${String(durM).padStart(2, '0')}` : `${durH}h`;
return { text: `Jornada: das ${fmtJornadaHora(inicio)} às ${fmtJornadaHora(fim)} (${durStr})`, isOff: false };
});
// ── bloqueio de dia ────────────────────────────────────────
const blockedSet = computed(() => new Set(props.blockedDates || []));
const formDayISO = computed(() => {
const d = form.value.dia ? new Date(form.value.dia) : null;
if (!d) return '';
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const isDayBlocked = computed(() => !!formDayISO.value && blockedSet.value.has(formDayISO.value));
const isSessionBlockedByDay = computed(() => isDayBlocked.value && isSessionEvent.value);
const isDiaFolga = computed(() => {
if (!form.value.dia || !props.workRules?.length) return false;
const dow = new Date(form.value.dia).getDay();
return !props.workRules.some((r) => Number(r.dia_semana) === dow);
});
// ── time picker ────────────────────────────────────────────
const timePickerOpen = ref(false);
// Snapshot pra revert ao fechar sem confirmar. Sem isso, o user mudava
// dia/hora/duração no time picker e mesmo fechando via X / overlay / Esc,
// os valores vazavam pro card principal (porque os bindings v-model são
// diretos no form). User reportou em 2026-05-13.
const _tpSnap = ref(null);
const _tpCommitted = ref(false);
function onTimePickerShow() {
_tpSnap.value = {
dia: form.value.dia ? new Date(form.value.dia) : null,
startTime: form.value.startTime,
duracaoMin: form.value.duracaoMin
};
_tpCommitted.value = false;
}
function onTimePickerHide() {
if (!_tpCommitted.value && _tpSnap.value) {
form.value.dia = _tpSnap.value.dia;
form.value.startTime = _tpSnap.value.startTime;
form.value.duracaoMin = _tpSnap.value.duracaoMin;
}
_tpSnap.value = null;
_tpCommitted.value = false;
}
function confirmTimePicker() {
_tpCommitted.value = true;
timePickerOpen.value = false;
}
function cancelTimePicker() {
// _tpCommitted permanece false → onTimePickerHide reverte
timePickerOpen.value = false;
}
// samePatientConflict → useAgendaEventActions (1C-i) — watcher
// [paciente_id, dia] já está dentro do composable, samePatientConflict
// vem do destructuring acima.
// Toggle de modalidade — botao unico que alterna Presencial/Online,
// substituindo o SelectButton em todas as resolucoes (mobile + desktop).
const modalidadeLabel = computed(() => (form.value.modalidade === 'online' ? 'Online' : 'Presencial'));
function toggleModalidade() {
form.value.modalidade = form.value.modalidade === 'online' ? 'presencial' : 'online';
}
// ── novo paciente ──────────────────────────────────────────
// cadRapidoOpen, abrirCadastroCompleto → useAgendaEventPickerBilling (1C-ii-a)
// onPatientCreatedRapido fica aqui (autocontido, usa form + cadRapidoOpen)
function onPatientCreatedRapido(p) {
if (!p) return;
const nome = p.nome_completo || p.nome || p.name || '';
form.value.paciente_id = p.id;
form.value.paciente_nome = nome;
form.value.paciente_avatar = p.avatar_url || null;
cadRapidoOpen.value = false;
}
// "Cadastro completo" inline — substitui o open-em-nova-aba pra
// (a) nao vazar do layout Melissa pra /therapist/patients/cadastro
// (b) preservar o estado do form de evento sem precisar de nova aba.
// Reutiliza o PatientCadastroDialog (mesmo usado em MelissaPacientes).
const cadCompletoOpen = ref(false);
// Popovers de ajuda dos InputGroups do card Pagamento. Refs separados
// pra cada (servico/convenio) pra evitar conflito quando o user abre
// um e clica direto no outro sem fechar.
const servicoHelpRef = ref(null);
const convenioHelpRef = ref(null);
const extrasHelpRef = ref(null);
const onlineHelpRef = ref(null);
// Dialog "Ver lançamentos da sessão" (2026-05-14): lista todos os
// financial_records vinculados ao agenda_evento atual (sessão + multas
// /taxas/etc). Aparece em edição via botão "Ver lançamentos".
const sessionRecordsDialogOpen = ref(false);
const sessionRecordsList = ref([]);
const sessionRecordsLoading = ref(false);
async function openSessionRecordsDialog() {
const eid = props.eventRow?.id;
if (!eid) return;
sessionRecordsDialogOpen.value = true;
sessionRecordsLoading.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', eid)
.is('deleted_at', null)
.order('created_at', { ascending: true });
if (error) throw error;
sessionRecordsList.value = data || [];
} catch (e) {
console.warn('[VerLancamentos] erro:', e?.message);
sessionRecordsList.value = [];
} finally {
sessionRecordsLoading.value = false;
}
}
function _fmtRecordBRL(v) {
return Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function _fmtRecordDate(d) {
if (!d) return '—';
try {
return new Date(d).toLocaleDateString('pt-BR');
} catch {
return '—';
}
}
const _recordMethodLabels = {
pix: 'PIX',
dinheiro: 'Dinheiro',
deposito: 'Depósito',
cartao: 'Cartão',
cartao_maquininha: 'Cartão (maquininha)',
convenio: 'Convênio',
asaas: 'Asaas'
};
const _recordStatusLabels = {
pending: 'Pendente',
paid: 'Pago',
overdue: 'Vencido',
cancelled: 'Cancelado',
refunded: 'Reembolsado',
partial: 'Parcial'
};
function _recordStatusSeverity(s) {
return { pending: 'info', paid: 'success', overdue: 'danger', cancelled: 'secondary', refunded: 'warn', partial: 'warn' }[s] || 'secondary';
}
// Dialog de seleção de serviços (particular) ou convênio. Substitui a
// UI inline antiga: o card 3 (Sessão / Honorários) agora mostra empty
// state ou resumo + botão "Editar" — a configuração detalhada (qtd,
// desconto, procedimento, guia) acontece dentro deste dialog.
const serviceDialogOpen = ref(false);
// Dialog de configuração de Frequência (recorrência). Mesma lógica do
// serviceDialogOpen — card de Frequência mostra empty/resumo + Editar,
// e a config detalhada (chips, dias, qtd, preview) vive aqui.
const freqDialogOpen = ref(false);
// Resumo do card Frequência (display only)
const freqResumoMain = computed(() => {
const t = recorrenciaType.value;
if (t === 'avulsa') return 'Sessão única';
if (t === 'semanal') return `Semanal · ${nomeDiaSemana(diaSemanaRecorrencia.value)}`;
if (t === 'quinzenal') return `Quinzenal · ${nomeDiaSemana(diaSemanaRecorrencia.value)}`;
if (t === 'diasEspecificos') {
const dias = (diasSelecionados.value || []).map((d) => nomeDiaSemana(d).slice(0, 3)).join(', ');
return `Dias específicos${dias ? ': ' + dias : ''}`;
}
return '—';
});
const freqResumoSub = computed(() => {
if (recorrenciaType.value === 'avulsa') return 'Sem repetição';
return totalOcorrencias.value
? `${totalOcorrencias.value} ${totalOcorrencias.value === 1 ? 'sessão prevista' : 'sessões previstas'}`
: null;
});
// Total do pacote (items × ocorrências) quando há recorrência E valor.
// Só ativa com >= 2 sessões — 1 sessão "recorrente" via Personalizar
// não vira pacote (semanticamente esquisito).
const pacoteTotal = computed(() => {
if (recorrenciaType.value === 'avulsa') return null;
if (!totalFromItems.value) return null;
if (!totalOcorrencias.value || totalOcorrencias.value < 2) return null;
return totalFromItems.value * totalOcorrencias.value;
});
// É um pacote? (recorrência com ≥ 2 sessões na criação OU edição de
// sessão que faz parte de série já existente — hasSerie indica isso).
const isPacote = computed(() =>
(recorrenciaType.value !== 'avulsa' && (totalOcorrencias.value || 0) >= 2) ||
(isEdit.value && hasSerie.value)
);
// Plural simples PT-BR pro nome do commitment (Sessão → Sessões,
// Atividade → Atividades, etc.). Cobre os casos típicos do sistema.
function pluralCommitment(name) {
if (!name) return 'sessões';
if (/ão$/i.test(name)) return name.replace(/ão$/i, 'ões');
if (/l$/i.test(name)) return name.replace(/l$/i, 'is');
if (/s$/i.test(name)) return name;
return name + 's';
}
// Posicao da ocorrencia atual na serie (1-based). Usa currentRecurrenceDate
// (que vem de eventRow.recurrence_date) + serieEvents pra calcular. Retorna
// null se nao for possivel localizar — fallback no headerMainLabel.
const occurrenceIndex = computed(() => {
if (!currentRecurrenceDate.value || !serieEvents.value?.length) return null;
const idx = serieEvents.value.findIndex((ev) => ev.recurrence_date === currentRecurrenceDate.value);
return idx >= 0 ? idx + 1 : null;
});
const occurrenceTotalSessions = computed(() => serieEvents.value?.length || 0);
// Título do header — adaptativo:
// Criando avulsa: "Nova {Name}"
// Criando pacote: "Pacote · N {Names}"
// Editando avulsa: "Editar {Name}"
// Editando pacote: "Sessão do Pacote · {Name}" (faz parte de série)
// occurrenceMode: "Pacote · X de Y Sessões" (2º dialog empilhado)
const headerMainLabel = computed(() => {
if (props.occurrenceMode) {
const idx = occurrenceIndex.value;
const total = occurrenceTotalSessions.value;
if (idx && total) return `Pacote · ${idx} de ${total} Sessões`;
if (total) return `Pacote · ${total} Sessões`;
return 'Pacote · Sessão';
}
const name = selectedCommitment.value?.name || '';
if (!name) return headerTitle.value;
if (isEdit.value) {
if (hasSerie.value) {
// Evita "Sessão do Pacote · Sessão" (nome duplicado) — quando
// o commitment chama 'Sessão' (default), mostra só "Sessão do
// Pacote". Outros nomes (ex: Avaliação) entram normais.
const lower = name.toLowerCase();
if (lower === 'sessão' || lower === 'sessao') return 'Sessão do Pacote';
return `Sessão do Pacote · ${name}`;
}
return `Editar ${name}`;
}
if (isPacote.value) {
return `Pacote · ${totalOcorrencias.value} ${pluralCommitment(name)}`;
}
return `Nova ${name}`;
});
// ── occurrence billing lock state ──────────────────────────────────
// Mapeia financial_record.status pra label+severity do Tag no card
// Sessao/Honorarios do occurrenceMode. Quando ocorrencia ja tem cobranca
// emitida (qualquer status), trava edicao de tipo/itens — ajustes via
// fluxo do Financeiro (padrao SimplePractice). 2026-05-12.
const occBillingStatusLabel = computed(() => {
const s = occFinancialRecord.value?.status;
if (s === 'paid') return 'Cobrança paga';
if (s === 'overdue') return 'Cobrança atrasada';
if (s === 'pending') return 'Cobrança pendente';
return '';
});
const occBillingStatusSeverity = computed(() => {
const s = occFinancialRecord.value?.status;
if (s === 'paid') return 'success';
if (s === 'overdue') return 'danger';
if (s === 'pending') return 'warn';
return 'info';
});
// ── Linha "Cobrança" do Resumo lateral (2026-05-18) ──────────────
// Renderiza no Resumo do dialog uma linha curta com o estado atual
// da cobrança (Pago / Pendente / Atrasada / Sem cobrança). Espelha
// os 3 canais visuais da agenda (verde pago / amber pendente /
// neutro). Sources:
// - sessionPaymentRecord (1 query em useAgendaEventLifecycle)
// - eventRow.price (fallback pra "Sem cobrança · R$ X" quando
// ainda nao ha record)
const paymentSummary = computed(() => {
if (!isSessionEvent.value) return null;
const rec = sessionPaymentRecord.value;
const fmtDate = (d) => {
if (!d) return '';
try {
return new Date(d).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
} catch {
return '';
}
};
if (rec) {
const value = fmtBRL(rec.final_amount ?? rec.amount ?? 0);
if (rec.status === 'paid') {
const when = fmtDate(rec.paid_at);
return { icon: 'pi-check-circle', cls: 'aed-pay-summary-row--paid', label: when ? `Pago · ${value} · ${when}` : `Pago · ${value}` };
}
if (rec.status === 'overdue') {
const when = fmtDate(rec.due_date);
return { icon: 'pi-exclamation-circle', cls: 'aed-pay-summary-row--overdue', label: when ? `Atrasada · ${value} · venceu ${when}` : `Atrasada · ${value}` };
}
if (rec.status === 'pending') {
const when = fmtDate(rec.due_date);
return { icon: 'pi-dollar', cls: 'aed-pay-summary-row--pending', label: when ? `Pendente · ${value} · vence ${when}` : `Pendente · ${value}` };
}
}
// Sem record. Mostra só quando ja eh edit + tem paciente — em
// criacao nao faz sentido (a cobranca ainda nem foi decidida).
if (isEdit.value && form.value?.paciente_id) {
const price = form.value?.price;
if (price != null && price > 0) {
return { icon: 'pi-dollar', cls: 'aed-pay-summary-row--none', label: `Sem cobrança · ${fmtBRL(price)}` };
}
return { icon: 'pi-info-circle', cls: 'aed-pay-summary-row--none', label: 'Sem cobrança gerada' };
}
return null;
});
// ── Preview "antes e depois" das ocorrencias afetadas ─────────────────
// So vale no occurrenceMode quando o user escolhe escopo > somente_este.
// Mostra abaixo do card "Aplicar alteracoes em" duas colunas: as datas
// originais (esquerda) e como ficam apos aplicar o delta de horario
// (direita). Util pra confirmar antes de salvar.
//
// Delta = inicioDateTime.value - eventRow.inicio_em original.
// Aplicamos uniforme em todas as ocorrencias afetadas. Para mudancas
// de time-of-day puras (caso comum) o delta funciona perfeito; para
// shift de dia-da-semana e aproximacao boa o bastante pro preview.
const previewDeltaMs = computed(() => {
if (!props.occurrenceMode) return 0;
if (!inicioDateTime.value) return 0;
const origIso = props.eventRow?.inicio_em;
if (!origIso) return 0;
return inicioDateTime.value.getTime() - new Date(origIso).getTime();
});
const previewAffectedOccurrences = computed(() => {
if (!props.occurrenceMode) return [];
if (!hasSerie.value) return [];
if (editScope.value === 'somente_este') return [];
const list = serieEvents.value || [];
if (!list.length) return [];
let affected = list;
if (editScope.value === 'este_e_seguintes') {
const curIdx = list.findIndex((ev) => ev.recurrence_date === currentRecurrenceDate.value);
affected = curIdx >= 0 ? list.slice(curIdx) : list;
}
// 'todos' / 'todos_sem_excecao' — usa lista completa do serieEvents.
// (Exceptions canceladas ja aparecem em serieEvents marcadas como _cancelled.)
const delta = previewDeltaMs.value;
return affected.map((ev) => {
const origStart = new Date(ev.inicio_em);
const origEnd = new Date(ev.fim_em);
const newStart = delta ? new Date(origStart.getTime() + delta) : origStart;
const newEnd = delta ? new Date(origEnd.getTime() + delta) : origEnd;
return {
ev,
origStart,
origEnd,
newStart,
newEnd,
changed: delta !== 0
};
});
});
// Label do wallet no Resumo flutuante: valor total (pacote × N quando
// recorrente, ou displayPrice base). O indicador "Pacote" aparece junto
// com a modalidade ("Presencial · Pacote") em vez do wallet.
const walletLabel = computed(() => {
if (pacoteTotal.value != null) {
return fmtBRL(pacoteTotal.value);
}
return displayPrice.value != null ? fmtBRL(displayPrice.value) : '—';
});
// Conta da multiplicação no card Frequência: "4× R$ 40 = R$ 160".
const freqValorConta = computed(() => {
if (pacoteTotal.value == null) return null;
return `${totalOcorrencias.value}× ${fmtBRL(totalFromItems.value)} = ${fmtBRL(pacoteTotal.value)}`;
});
// Helpers da seção "Próximas ocorrências" — separador por mês +
// referência temporal relativa ("em 2 semanas" etc.)
function freqShouldShowMonthSep(idx) {
const cur = ocorrenciasComConflito.value?.[idx];
if (!cur) return false;
if (idx === 0) return true;
const prev = ocorrenciasComConflito.value[idx - 1];
const cd = new Date(cur.date);
const pd = new Date(prev.date);
return cd.getMonth() !== pd.getMonth() || cd.getFullYear() !== pd.getFullYear();
}
function freqMonthLabel(date) {
const d = new Date(date);
return d.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })
.replace(/^./, (c) => c.toUpperCase());
}
function freqRelativeLabel(date) {
const target = new Date(date);
target.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
const diff = Math.round((target.getTime() - today.getTime()) / 86400000);
if (diff === 0) return 'hoje';
if (diff === 1) return 'amanhã';
if (diff < 0) {
const past = -diff;
if (past < 7) return `${past} dia${past === 1 ? '' : 's'} atrás`;
const w = Math.round(past / 7);
if (w < 8) return `${w} semana${w === 1 ? '' : 's'} atrás`;
const m = Math.round(past / 30);
return `${m} ${m === 1 ? 'mês' : 'meses'} atrás`;
}
if (diff < 14) return `em ${diff} dias`;
const w = Math.round(diff / 7);
if (w < 8) return `em ${w} semana${w === 1 ? '' : 's'}`;
const m = Math.round(diff / 30);
if (m < 12) return `em ${m} ${m === 1 ? 'mês' : 'meses'}`;
const y = Math.round(diff / 365);
return `em ${y} ano${y === 1 ? '' : 's'}`;
}
// Flags pra decidir empty vs resumo no card body
const hasParticularConfigured = computed(() => commitmentItems.value.length > 0);
const hasConvenioConfigured = computed(() => !!form.value.insurance_plan_id);
const hasBillingConfigured = computed(() =>
billingType.value === 'particular' ? hasParticularConfigured.value :
billingType.value === 'convenio' ? hasConvenioConfigured.value :
false
);
// Helpers de desconto por item (display only — cálculo real vive no composer)
function itemHasDiscount(item) {
return (item?.discount_pct > 0) || (item?.discount_flat > 0);
}
function itemSubtotal(item) {
return (Number(item?.unit_price) || 0) * (Number(item?.quantity) || 1);
}
function itemDiscountValue(item) {
return Math.max(0, itemSubtotal(item) - (Number(item?.final_price) || 0));
}
// Soma do desconto total aplicado nos itens — pro card resumo no body
const totalDiscountFromItems = computed(() =>
commitmentItems.value.reduce((acc, it) => acc + itemDiscountValue(it), 0)
);
// Nome do convênio selecionado (pra resumo)
const selectedPlanName = computed(() => {
const id = form.value.insurance_plan_id;
if (!id) return '';
const plan = (activePlans.value || []).find((p) => p.id === id);
return plan?.name || '';
});
// Nome do procedimento selecionado (pra resumo)
const selectedPlanServiceName = computed(() => {
if (!selectedPlanService.value) return '';
const ps = (planServices.value || []).find((p) => p.id === selectedPlanService.value);
return ps?.name || '';
});
function onPatientCreatedCompleto(p) {
if (!p) return;
const nome = p.nome_completo || p.nome || p.name || '';
form.value.paciente_id = p.id;
form.value.paciente_nome = nome;
form.value.paciente_avatar = p.avatar_url || null;
cadCompletoOpen.value = false;
}
// ── paciente picker ────────────────────────────────────────
// pacientePickerOpen, pacienteSearch, pacientesLoading, pacientesError, patients,
// selectCommitment, goBack, openPacientePicker, clearPatientsCache, loadPatients,
// selectPaciente, clearPaciente → useAgendaEventPickerBilling (1C-ii-a)
// filteredPatients depende de patients/pacienteSearch (do composable) — fica no .vue:
// Mostra TODOS os pacientes (inclusive Inativo/Arquivado) — UX intencional pra
// o user achar quem procura. Os nao-Ativos vem com badge + disabled (template),
// e selectPaciente bloqueia o clique se status !== 'Ativo'. Ordenacao:
// Ativos primeiro, depois Inativos, depois Arquivados.
const _statusRank = { Ativo: 0, Inativo: 1, Arquivado: 2 };
const filteredPatients = computed(() => {
const q = String(pacienteSearch.value || '')
.trim()
.toLowerCase();
const list = patients.value || [];
const matched = !q
? [...list]
: list.filter((p) => {
const nome = String(p.nome || '').toLowerCase();
const email = String(p.email || '').toLowerCase();
const tel = String(p.telefone || '').toLowerCase();
return nome.includes(q) || email.includes(q) || tel.includes(q);
});
return matched.sort((a, b) => (_statusRank[a.status] ?? 3) - (_statusRank[b.status] ?? 3));
});
// 4 watchers (modelValue init, tenant/scope, solicitação pendente, online
// slots) + solicitacaoPendente ref → useAgendaEventLifecycle (1C-ii-b)
// watcher form.commitment_id, form.insurance_plan_id, onProcedureSelect
// → useAgendaEventPickerBilling (1C-ii-a)
// watcher [paciente_id, dia] → useAgendaEventActions (1C-i)
function goToAgendamentosRecebidos() {
visible.value = false;
const prefix = route.path.startsWith('/admin') ? '/admin' : '/therapist';
router.push(`${prefix}/agendamentos-recebidos`);
}
// Rota dos Compromissos Determinados — Melissa usa /melissa/compromissos;
// admin/therapist usam /agenda/compromissos sob seus prefixos.
function goToCompromissosConfig() {
visible.value = false;
const path = route.path.startsWith('/melissa')
? '/melissa/compromissos'
: route.path.startsWith('/admin')
? '/admin/agenda/compromissos'
: '/therapist/agenda/compromissos';
router.push(path);
}
// Rota das Configurações da Agenda (seção Online) — Melissa usa
// /melissa/agenda-config; admin/therapist usam /configuracoes/agenda
// dentro do contexto Melissa não existe (sempre embedado em MelissaLayout).
function goToAgendaConfig() {
visible.value = false;
const path = route.path.startsWith('/melissa') ? '/melissa/agenda-config' : '/configuracoes/agenda';
router.push(path);
}
// ── duração / slots ────────────────────────────────────────
// Chips de duração rápida no Time Picker (mesmo set do AgendaEventDialogV2
// pra paridade visual entre os dois dialogs).
const duracaoQuickChips = [30, 50, 60, 90];
// ── Mini calendar do Time Picker (estilo MelissaAgenda mini-cal) ─────
// Versao enxuta: sem dots de eventos, sem feriados, sem dias fechados.
// So renderiza grid 6x7 com hoje (cor primary) + selecionado (fundo
// primary) + outros meses (cinza). Sincroniza com form.dia ao abrir.
const miniRefDate = ref(new Date());
watch(
() => timePickerOpen.value,
(open) => {
if (!open) return;
const d = form.value.dia ? new Date(form.value.dia) : new Date();
if (!Number.isNaN(d.getTime())) miniRefDate.value = d;
}
);
const miniMesAno = computed(() =>
miniRefDate.value
.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })
.replace(/(^|\s)\S/g, (l) => l.toUpperCase())
);
const miniDias = computed(() => {
const refD = miniRefDate.value;
const ano = refD.getFullYear();
const mes = refD.getMonth();
const ultimoDia = new Date(ano, mes + 1, 0).getDate();
const inicio = new Date(ano, mes, 1).getDay(); // 0=dom
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
const sel = form.value.dia ? new Date(form.value.dia) : null;
if (sel) sel.setHours(0, 0, 0, 0);
const sameDay = (a, b) => a && b && a.getTime() === b.getTime();
const arr = [];
// Padding antes (mês anterior, em cinza)
for (let i = inicio - 1; i >= 0; i--) {
const d = new Date(ano, mes, -i);
d.setHours(0, 0, 0, 0);
arr.push({ dia: d.getDate(), outro: true, isHoje: false, isSel: sameDay(d, sel), date: d });
}
// Dias do mês corrente
for (let d = 1; d <= ultimoDia; d++) {
const data = new Date(ano, mes, d);
data.setHours(0, 0, 0, 0);
arr.push({ dia: d, outro: false, isHoje: sameDay(data, hoje), isSel: sameDay(data, sel), date: data });
}
// Padding depois até 42 (6 semanas completas)
while (arr.length < 42) {
const last = arr[arr.length - 1].date;
const next = new Date(last);
next.setDate(last.getDate() + 1);
next.setHours(0, 0, 0, 0);
arr.push({ dia: next.getDate(), outro: true, isHoje: false, isSel: sameDay(next, sel), date: next });
}
return arr;
});
function miniPrev() {
const d = new Date(miniRefDate.value);
d.setMonth(d.getMonth() - 1);
miniRefDate.value = d;
}
function miniNext() {
const d = new Date(miniRefDate.value);
d.setMonth(d.getMonth() + 1);
miniRefDate.value = d;
}
function miniToday() {
const t = new Date();
miniRefDate.value = t;
form.value.dia = new Date(t.getFullYear(), t.getMonth(), t.getDate());
}
function selecionarDiaMini(d) {
if (!d?.date) return;
form.value.dia = new Date(d.date);
miniRefDate.value = new Date(d.date);
}
// grouped duration options: default preset first, then com pausa, then sem pausa
const duracaoOptions = computed(() => {
const defaultDur = props.agendaSettings?.session_duration_min || 50;
const defaultGap = props.agendaSettings?.session_break_min || 0;
const ALL_PRESETS = [
{ label: '30 min', dur: 30, gap: 0 },
{ label: '40 min', dur: 40, gap: 0 },
{ label: '45 min', dur: 45, gap: 15 },
{ label: '50 min', dur: 50, gap: 10 },
{ label: '60 min', dur: 60, gap: 0 },
{ label: '90 min', dur: 90, gap: 0 },
{ label: '2h', dur: 120, gap: 0 }
];
const defaultItem = {
label: `${defaultDur} min${defaultGap > 0 ? ` + ${defaultGap}min pausa` : ''} ✦ padrão`,
value: defaultDur
};
const others = ALL_PRESETS.filter((p) => p.dur !== defaultDur);
const wBreak = others.filter((p) => p.gap > 0);
const noBreak = others.filter((p) => p.gap === 0);
const groups = [{ label: 'Padrão da agenda', items: [defaultItem] }];
if (wBreak.length) groups.push({ label: 'Com pausa', items: wBreak.map((p) => ({ label: `${p.label} + ${p.gap}min pausa`, value: p.dur })) });
if (noBreak.length) groups.push({ label: 'Sem pausa', items: noBreak.map((p) => ({ label: p.label, value: p.dur })) });
return groups;
});
// hhmmToMin / minToHHMM movidos pra agendaEventHelpers.js (A66/1A)
// Slots disponíveis no dia selecionado (respeitando jornada + pausas + eventos existentes)
const availableSlots = computed(() => {
const dia = form.value.dia;
if (!dia || !props.workRules?.length) return [];
const dow = new Date(dia).getDay();
const dur = props.agendaSettings?.session_duration_min || 50;
const gap = props.agendaSettings?.session_break_min || 0;
const cycle = Math.max(1, dur + gap);
const windows = (props.workRules || [])
.filter((r) => Number(r.dia_semana) === dow && r.ativo !== false)
.map((r) => ({ start: String(r.hora_inicio || '').slice(0, 5), end: String(r.hora_fim || '').slice(0, 5) }))
.sort((a, b) => a.start.localeCompare(b.start));
if (!windows.length) return [];
const breaks = (props.pausasSemanais || []).filter((p) => p.dia_semana == null || Number(p.dia_semana) === dow).map((p) => ({ s: hhmmToMin(p.hora_inicio || '00:00'), e: hhmmToMin(p.hora_fim || '00:00') }));
// eventos já agendados no mesmo dia (para marcar como ocupado)
const todayStr = dia instanceof Date ? `${dia.getFullYear()}-${String(dia.getMonth() + 1).padStart(2, '0')}-${String(dia.getDate()).padStart(2, '0')}` : String(dia).slice(0, 10);
const busySlots = (props.allEvents || [])
.filter((e) => {
if (!e.inicio_em) return false;
return e.inicio_em.slice(0, 10) === todayStr && e.id !== form.value.id;
})
.map((e) => ({
s: hhmmToMin(e.inicio_em.slice(11, 16)),
e: e.fim_em ? hhmmToMin(e.fim_em.slice(11, 16)) : hhmmToMin(e.inicio_em.slice(11, 16)) + dur
}));
const slots = [];
for (const w of windows) {
const wStart = hhmmToMin(w.start);
const wEnd = hhmmToMin(w.end);
let t = wStart;
while (t + dur <= wEnd) {
const aEnd = t + dur;
const brk = breaks.find((b) => !(aEnd <= b.s || t >= b.e));
if (brk) {
t = brk.e;
continue;
}
const busy = busySlots.some((b) => !(aEnd <= b.s || t >= b.e));
slots.push({ hhmm: minToHHMM(t), endHhmm: minToHHMM(aEnd), busy });
t += cycle;
}
}
const seen = new Set();
return slots.filter((s) => {
if (seen.has(s.hhmm)) return false;
seen.add(s.hhmm);
return true;
});
});
const PERIODOS = [
{ label: 'Manhã', icon: 'pi-sun', from: '05:00', to: '12:00' },
{ label: 'Tarde', icon: 'pi-cloud', from: '12:00', to: '18:00' },
{ label: 'Noite', icon: 'pi-moon', from: '18:00', to: '24:00' }
];
const selectedPeriodo = ref(null);
// Apenas períodos que têm ao menos 1 slot disponível
const activePeriodos = computed(() => PERIODOS.filter((p) => availableSlots.value.some((s) => s.hhmm >= p.from && s.hhmm < p.to)));
// Set dos horários online pré-configurados (para marcar nos pills)
const onlineConfigSet = computed(() => new Set((onlineSlots.value || []).map((s) => s.hhmm)));
const filteredSlots = computed(() => {
const base = selectedPeriodo.value ? availableSlots.value.filter((s) => s.hhmm >= selectedPeriodo.value.from && s.hhmm < selectedPeriodo.value.to) : availableSlots.value;
return base.map((s) => ({ ...s, isOnlineConfigured: onlineConfigSet.value.has(s.hhmm) }));
});
// selectSlot, onlineSlots/loadingOnlineSlots refs + watcher
// → useAgendaEventLifecycle (1C-ii-b)
const onlineAtivo = computed(() => !!props.agendaSettings?.online_ativo);
const onlineDisponivel = computed(() => (form.value.modalidade === 'online' ? onlineAtivo.value && onlineSlots.value.length > 0 : true));
const dayWorkRule = computed(() => {
if (!form.value.dia || !props.workRules?.length) return null;
const dow = new Date(form.value.dia).getDay();
return props.workRules.find((r) => Number(r.dia_semana) === dow) ?? null;
});
// ── série: info do banner ──────────────────────────────────
const serieDiaSemana = computed(() => {
if (props.eventRow?.serie_dia_semana != null) return Number(props.eventRow.serie_dia_semana);
const iso = props.eventRow?.inicio_em;
if (iso) return new Date(iso).getDay();
return form.value.dia ? new Date(form.value.dia).getDay() : null;
});
const serieHoraDisplay = computed(() => {
if (props.eventRow?.serie_hora) return props.eventRow.serie_hora;
return form.value.startTime || null;
});
// ── série: lista de sessões ────────────────────────────────
// serieEvents já declarado no topo (passado ao composer via extras).
// serieLoading, pillDeleteMenuRef, pillDeleteTarget, serieCountByStatus,
// pillDeleteMenuItems, generateRuleDates, loadSerieEvents,
// onPillEditClick, onPillStatusChange, onPillDeleteClick, onPillDelete
// → useAgendaEventLifecycle (1C-ii-b)
const statusOptions = [
{ label: 'Agendado', value: 'agendado' },
{ label: 'Realizado', value: 'realizado' },
{ label: 'Faltou', value: 'faltou' },
{ label: 'Cancelado', value: 'cancelado' },
{ label: 'Remarcar', value: 'remarcado' }
];
// isPast movido pra agendaEventHelpers.js (A66/1A)
// agendaPerms, isSessionFuture, isArchivedPastEdit, isInativoFutureEdit,
// statusOptionsFiltered → composer (1B)
// fmtWeekdayShort / fmtDayNum / fmtMonthShort → agendaEventHelpers (1A)
// timeConflict, startTimeDate, inicioDateTime, fimDateTime, dataHoraDisplay,
// previewRange, computedTitulo, canSave, headerTitle → composer (1B)
// ── save / delete ──────────────────────────────────────────
// onSave / onDelete / onEncerrarSerie → useAgendaEventActions (1C-i)
// onSendManualReminder + sendingReminder → useAgendaEventLifecycle (1C-ii-b)
// onDelete → useAgendaEventActions (1C-i)
// ── helpers ────────────────────────────────────────────────
// addMinutesDate / fmtDateBR / fmtDateBRLong / fmtTime / fmtDuracao /
// fmtSerieHora / nomeDiaSemana / isoToHHMM / calcMinutes movidos pra
// agendaEventHelpers.js (A66/1A)
// resetForm → composer (1B). Se algum watcher do .vue precisa resetar
// o form (ex: ao reabrir o dialog), chamar `form.value = resetForm()`
// usando a função exportada do composer.
// ── Google Calendar link ────────────────────────────────────────
const googleCalendarUrl = computed(() => {
const dia = form.value.dia;
const hora = form.value.startTime;
if (!dia || !hora) return null;
const start = formatGCalDate(dia, hora);
const endHHMM = addMinutesToHHMM(hora, form.value.duracaoMin || 50);
const end = formatGCalDate(dia, endHHMM);
const paciente = form.value.paciente_nome ? `${form.value.paciente_nome}` : '';
const title = (form.value.titulo_custom?.trim() || 'Sessão') + paciente;
const location = form.value.modalidade === 'online' ? 'Atendimento Online' : '';
return generateGoogleCalendarLink({
title,
description: form.value.observacoes?.trim() || '',
location,
start,
end
});
});
// labelStatusSessao / statusSeverity / statusExtraClass movidos pra
// agendaEventHelpers.js (A66/1A)
// ── Dialog width: compacto no step 1, full no step 2 ───────────
// Step 1 (escolha de tipo) e uma lista de rows finas — 400px da
// foco. Step 2 (formulario) precisa de espaco pro grid 2-col.
// Quando lockType=true, abre direto no step 2 (sem step 1).
const dialogStyle = computed(() => {
const isStep1 = step.value === 1 && !props.lockType;
return {
width: isStep1 ? '420px' : '600px',
maxWidth: '96vw'
};
});
// Some o card flutuante (Resumo) quando QUALQUER sub-dialog/picker
// abre. Sem isso, o card (z-index alto) ficaria por cima do
// pacientePicker/timePicker/cadRapido/quickCreate, atrapalhando.
const anyChildDialogOpen = computed(() =>
pacientePickerOpen.value ||
timePickerOpen.value ||
cadRapidoOpen.value ||
cadCompletoOpen.value ||
serviceQuickDlgOpen.value ||
insuranceQuickDlgOpen.value ||
planServiceQuickDlgOpen.value ||
serviceDialogOpen.value ||
freqDialogOpen.value
);
// ── Sincronização do Resumo flutuante com a posição do Dialog ──
// O Dialog do PrimeVue centra vertical quando o conteudo cabe no viewport
// (commitment "Bloqueio" / "Atividade" sao baixos), e ancora ~5vh do topo
// quando o conteudo é alto (Sessao com paciente+frequencia). O Resumo
// flutuante estava com top:5vh fixo, então em dialog baixo ficava lá em
// cima desalinhado. ResizeObserver mede o .p-dialog e atualiza o style do
// aside pra acompanhar top + altura — funciona em qualquer cenario.
const resumoStyle = ref({ top: '5vh', maxHeight: '90vh' });
let _dialogObserver = null;
function _syncResumoToDialog() {
const dialogEl = document.querySelector('.p-dialog.agenda-event-composer');
if (!dialogEl) return;
const rect = dialogEl.getBoundingClientRect();
resumoStyle.value = {
top: `${Math.round(rect.top)}px`,
maxHeight: `${Math.round(rect.height)}px`
};
}
watch([visible, () => step.value], async ([open, stp]) => {
if (_dialogObserver) {
_dialogObserver.disconnect();
_dialogObserver = null;
}
if (!open || stp !== 2) return;
await nextTick();
const dialogEl = document.querySelector('.p-dialog.agenda-event-composer');
if (!dialogEl || typeof ResizeObserver === 'undefined') return;
_syncResumoToDialog();
_dialogObserver = new ResizeObserver(() => _syncResumoToDialog());
_dialogObserver.observe(dialogEl);
}, { immediate: true });
onBeforeUnmount(() => {
if (_dialogObserver) {
_dialogObserver.disconnect();
_dialogObserver = null;
}
});
</script>
<template>
<Dialog v-model:visible="visible" modal :draggable="false" :dismissableMask="!anyChildDialogOpen" :closeOnEscape="!anyChildDialogOpen" :style="dialogStyle" :breakpoints="{ '960px': '96vw', '640px': '98vw' }" class="agenda-event-composer">
<template #header>
<div class="w-full flex items-center justify-between gap-3">
<!-- Slot headerLeft (override de chamadas como MelissaPaciente
que precisam icon+title+subtitle custom). Default: dot
colorido + headerTitle + previewRange. -->
<slot name="headerLeft">
<div class="flex items-center gap-2 min-w-0">
<div class="header-dot shrink-0" :style="selectedCommitment?.bg_color ? { background: `#${selectedCommitment.bg_color}` } : {}" />
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<span class="font-semibold truncate text-base">
{{ (occurrenceMode || (step === 2 && selectedCommitment?.name)) ? headerMainLabel : headerTitle }}
</span>
<button
v-if="step === 2 && !isEdit && allowBack"
type="button"
class="commit-badge header-swap-btn shrink-0"
@click="goBack"
>
<i class="pi pi-refresh" />
trocar tipo
</button>
</div>
<div v-if="step === 2" class="text-xs text-color-secondary truncate">{{ previewRange }}</div>
</div>
</div>
</slot>
<!-- Cadastro Rapido promovido pro topo do dialog (ao lado do close button)
So aparece no step 2, quando o tipo exige paciente (sessao) E
é criação (em edição faz pouco sentido cadastrar paciente novo). -->
<Button
v-if="step === 2 && isSessionEvent && !isEdit"
label="Cadastro Rápido"
icon="pi pi-user-plus"
size="small"
severity="secondary"
outlined
class="rounded-full text-xs h-7 ml-auto shrink-0"
@click="cadRapidoOpen = true"
/>
</div>
</template>
<!-- ConfirmDialog renderizado na página pai para evitar conflito de z-index com o Dialog -->
<!-- -->
<!-- STEPS 1 + 2 wrapper com transition slide-fade -->
<!-- -->
<Transition name="step-fade" mode="out-in">
<div v-if="step === 1 && !lockType" key="step-1" class="p-2">
<Message v-if="blockOverlapWarning" severity="warn" class="mb-4" :closable="false">
<i class="pi pi-exclamation-triangle mr-1" />
<span>Este horário está dentro do bloqueio <strong>"{{ blockOverlapWarning.titulo || 'Bloqueio' }}"</strong>. O compromisso pode ser criado mesmo assim.</span>
</Message>
<div class="mb-4 text-sm text-color-secondary">Selecione o tipo de compromisso para começar.</div>
<Message v-if="isDayBlocked" severity="warn" class="mb-4" :closable="false">
<i class="pi pi-lock mr-1" />
<span>Este dia está bloqueado. Compromissos do tipo <strong>Sessão</strong> não podem ser agendados.</span>
</Message>
<div class="commitment-list">
<button
v-for="c in commitmentCards"
:key="c.id"
class="commitment-row"
:class="{ 'commitment-row--blocked': isDayBlocked && isNativeSession(c) }"
:style="c.bg_color ? { '--card-color': `#${c.bg_color}` } : {}"
:disabled="isDayBlocked && isNativeSession(c)"
:title="isDayBlocked && isNativeSession(c) ? 'Dia bloqueado — sessões não permitidas' : (c.description || '')"
@click="isDayBlocked && isNativeSession(c) ? null : selectCommitment(c)"
>
<span class="commitment-row__icon" :style="c.bg_color ? { background: `#${c.bg_color}1f`, color: `#${c.bg_color}` } : {}">
<i :class="isNativeSession(c) ? 'pi pi-user' : 'pi pi-calendar'" />
</span>
<span class="commitment-row__body">
<span class="commitment-row__name">{{ c.name }}</span>
<span v-if="c.description" class="commitment-row__desc">{{ c.description }}</span>
</span>
<i :class="isDayBlocked && isNativeSession(c) ? 'pi pi-lock' : 'pi pi-chevron-right'" class="commitment-row__chevron" />
</button>
</div>
</div>
<!-- -->
<!-- STEP 2 formulário + painel lateral -->
<!-- -->
<div v-else key="step-2" class="composer-grid">
<!-- -->
<!-- OCCURRENCE MODE layout enxuto pra editar -->
<!-- UMA ocorrência da série. Ordem fixa: -->
<!-- 1) Dados da Recorrência -->
<!-- 2) Status -->
<!-- 3) Horário -->
<!-- 4) Aplicar alterações em -->
<!-- -->
<div v-if="occurrenceMode" class="composer-occurrence">
<!-- 1) DADOS DA RECORRÊNCIA -->
<div class="field-card mb-4">
<div class="field-card__header">
<i class="pi pi-refresh" />
<span>Dados da Recorrência</span>
</div>
<div class="field-card__body aed-occ-data">
<div class="aed-occ-data__row">
<i class="pi pi-bookmark aed-occ-data__icon" :style="selectedCommitment?.bg_color ? { color: `#${selectedCommitment.bg_color}` } : {}" />
<span class="aed-occ-data__label">Compromisso</span>
<span class="aed-occ-data__value">{{ selectedCommitment?.name || '—' }}</span>
</div>
<div v-if="isSessionEvent" class="aed-occ-data__row">
<i class="pi pi-user aed-occ-data__icon" />
<span class="aed-occ-data__label">Paciente</span>
<span class="aed-occ-data__value truncate">{{ form.paciente_nome || '—' }}</span>
</div>
<div class="aed-occ-data__row">
<i class="pi pi-calendar-clock aed-occ-data__icon" />
<span class="aed-occ-data__label">Frequência</span>
<span class="aed-occ-data__value">{{ freqResumoMain }}</span>
</div>
<div v-if="occurrenceIndex && occurrenceTotalSessions" class="aed-occ-data__row">
<i class="pi pi-list aed-occ-data__icon" />
<span class="aed-occ-data__label">Posição</span>
<span class="aed-occ-data__value">Sessão {{ occurrenceIndex }} de {{ occurrenceTotalSessions }}</span>
</div>
<div class="aed-occ-data__row">
<i class="pi pi-map-marker aed-occ-data__icon" />
<span class="aed-occ-data__label">Modalidade</span>
<span class="aed-occ-data__value capitalize">{{ form.modalidade || '—' }}</span>
</div>
</div>
</div>
<!-- 2) STATUS DA SESSÃO -->
<div v-if="isSessionEvent" class="field-card mb-4">
<div class="field-card__header">
<i class="pi pi-tag" />
<span>Status da Sessão</span>
</div>
<div class="field-card__body">
<SelectButton v-model="form.status" :options="statusOptionsFiltered" optionLabel="label" optionValue="value" optionDisabled="disabled" :allowEmpty="false" :disabled="isArchivedPastEdit" class="w-full status-select-btn" />
</div>
</div>
<!-- 3) HORÁRIO -->
<div class="field-card mb-4">
<div class="field-card__header">
<i class="pi pi-calendar" />
<span class="md:hidden">Data e Horário</span>
<Button
label="Ajustar horário"
icon="pi pi-pencil"
size="small"
severity="secondary"
outlined
class="rounded-full text-xs h-7 ml-auto md:ml-0"
@click="timePickerOpen = true"
/>
</div>
<div class="field-card__body">
<div class="time-hero" @click="timePickerOpen = true">
<div class="time-hero__line">
<span class="time-hero__date">{{ form.dia ? fmtDateBR(form.dia) : '—' }}</span>
<span class="time-hero__bullet"></span>
<span class="time-hero__duration">{{ fmtDuracao(form.duracaoMin) }}</span>
</div>
<div class="time-hero__line">
<span class="time-hero__time">{{ form.startTime || '—' }}</span>
<span class="time-hero__arrow"></span>
<span class="time-hero__time">{{ fimDateTime ? fmtTime(fimDateTime) : '—' }}</span>
</div>
</div>
<Message v-if="timeConflict" severity="warn" class="m-2 time-conflict-msg time-conflict-msg--inline" :closable="false">
<i class="pi pi-exclamation-triangle mr-1" />
{{ timeConflict }}
</Message>
</div>
</div>
<!-- 4) SESSÃO / HONORÁRIOS
Lock baseado em financial_record (padrao SimplePractice):
- Sem cobranca emitida: edicao livre de tipo+itens.
- Cobranca em pending/paid/overdue: trava edicao, mostra
o panel embutido pra gerar/registrar pagamento no
fluxo do Financeiro. Garante imutabilidade fiscal das
cobrancas ja emitidas. 2026-05-12. -->
<div v-if="isSessionEvent" class="field-card mb-4">
<div class="field-card__header">
<i class="pi pi-wallet" />
<span>Sessão / Honorários</span>
<Tag v-if="occFinancialRecord" :value="occBillingStatusLabel" :severity="occBillingStatusSeverity" class="ml-auto" />
<Select v-else-if="!occFinancialLoading"
v-model="billingType"
:options="billingTypeOptions"
optionLabel="label"
optionValue="value"
size="small"
class="aed-pay-mod-select ml-auto"
/>
</div>
<div class="field-card__body">
<!-- LOCKED: cobranca ja emitida -->
<template v-if="occFinancialRecord">
<Message severity="info" :closable="false" class="m-2">
<div class="text-sm">
<i class="pi pi-lock mr-1" />
Cobrança de <b>{{ fmtBRL(occFinancialRecord.final_amount ?? occFinancialRecord.amount) }}</b> emitida.
Para alterar tipo ou serviços, ajuste a cobrança no Financeiro abaixo.
</div>
</Message>
<AgendaEventoFinanceiroPanel :evento="eventRow" class="m-3" @cobranca-atualizada="loadOccFinancialRecord" />
</template>
<!-- LOADING -->
<div v-else-if="occFinancialLoading" class="text-xs text-color-secondary p-3">
<i class="pi pi-spinner pi-spin mr-1" /> Verificando estado da cobrança
</div>
<!-- UNLOCKED: sem cobranca, edicao livre -->
<template v-else>
<div v-if="billingType === 'gratuito'" class="aed-pay-gratuito">
<i class="pi pi-gift" />
<span><b>Esta sessão será gratuita</b> sem cobrança ao paciente.</span>
</div>
<template v-else>
<button v-if="!hasBillingConfigured" type="button" class="patient-hero__empty" @click="serviceDialogOpen = true">
<div class="patient-hero__empty-icon">
<i :class="billingType === 'convenio' ? 'pi pi-shield' : 'pi pi-shopping-bag'" />
</div>
<div>
<div class="font-semibold">
{{ billingType === 'convenio' ? 'Selecione convênio para adicionar e calcular' : 'Selecione serviços para adicionar e calcular' }}
</div>
<div class="text-xs text-color-secondary">Toque para configurar</div>
</div>
<i class="pi pi-chevron-right ml-auto text-color-secondary opacity-50" />
</button>
<div v-else class="aed-pay-summary">
<div class="aed-pay-summary__main">
<template v-if="billingType === 'particular'">
<div class="aed-pay-summary__count">
{{ commitmentItems.length }} {{ commitmentItems.length === 1 ? 'serviço' : 'serviços' }}
<span v-if="totalDiscountFromItems > 0" class="aed-pay-summary__discount">· desconto de {{ fmtBRL(totalDiscountFromItems) }}</span>
</div>
<div class="aed-pay-summary__value">{{ fmtBRL(totalFromItems) }}</div>
</template>
<template v-else-if="billingType === 'convenio'">
<div class="aed-pay-summary__count">
{{ selectedPlanName || 'Convênio' }}
<span v-if="selectedPlanServiceName" class="opacity-70">· {{ selectedPlanServiceName }}</span>
</div>
<div class="aed-pay-summary__value">{{ form.insurance_value != null ? fmtBRL(form.insurance_value) : '—' }}</div>
</template>
</div>
<Button label="Editar" icon="pi pi-pencil" size="small" severity="secondary" outlined class="rounded-full text-xs h-7" @click="serviceDialogOpen = true" />
</div>
</template>
</template>
</div>
</div>
<!-- Hint contextual abaixo do card Sessão / Honorários.
Esclarece dúvidas comuns por tipo de cobrança sem
poluir o form. Renderiza quando o card está
unlocked (sem cobrança emitida) quando travado,
a Message do lock ja explica o estado. -->
<div v-if="isSessionEvent && !occFinancialRecord && billingType === 'convenio'" class="aed-billing-hint mb-3">
<i class="pi pi-info-circle" />
<span><b> da guia é opcional</b> você pode salvar a sessão e preencher depois, quando o convênio responder.</span>
</div>
<div v-else-if="isSessionEvent && !occFinancialRecord && billingType === 'gratuito'" class="aed-billing-hint mb-3">
<i class="pi pi-gift" />
<span>Sessão <b>gratuita</b> nenhum lançamento será gerado no Financeiro.</span>
</div>
<!-- 5) APLICAR ALTERAÇÕES EM
Travado quando cobranca ja emitida: mudancas estruturais
em escopo > somente_este podem afetar serie inteira,
mas cobrancas existentes permanecem imutaveis (filtro
em propagateToSerie). Aqui apenas oculta o card pra
simplificar user nao confunde. 2026-05-12. -->
<div v-if="hasSerie && !occFinancialRecord" class="side-card mb-3">
<div class="side-card__title mb-2">Aplicar alterações em</div>
<SelectButton v-model="editScope" :options="editScopeOptions" optionLabel="label" optionValue="value" :optionDisabled="(o) => !!o.disabled" class="w-full" size="small" />
<Message v-if="isFirstOccurrence" severity="info" :closable="false" class="mt-2">
<span class="text-sm"> Esta é a primeira sessão da série. Para alterar todas as ocorrências, use <b>Todas da série</b>. </span>
</Message>
</div>
<!-- 6) PREVIEW ANTES/DEPOIS
So renderiza no occurrenceMode com escopo > somente_este
e quando ha ocorrencias afetadas. Mostra duas colunas
lado a lado: original (esquerda) e como ficara apos a
mudanca (direita). Apoia o user a entender o impacto da
escolha de escopo antes de salvar. 2026-05-12. -->
<div v-if="previewAffectedOccurrences.length" class="aed-preview-card mb-3">
<div class="aed-preview-card__header">
<i class="pi pi-arrow-right-arrow-left" />
<span>Pré-visualização {{ previewAffectedOccurrences.length }} {{ previewAffectedOccurrences.length === 1 ? 'sessão afetada' : 'sessões afetadas' }}</span>
</div>
<div class="aed-preview-card__cols">
<div class="aed-preview-col">
<div class="aed-preview-col__title">Antes</div>
<div class="aed-preview-list">
<div
v-for="(item, idx) in previewAffectedOccurrences"
:key="`before-${item.ev.id || item.ev.recurrence_date}`"
class="aed-preview-row"
:class="[`aed-preview-row--${item.ev._status || 'agendado'}`, { 'aed-preview-row--past': isPast(item.ev.inicio_em) }]"
>
<span class="aed-preview-row__num">{{ idx + 1 }}</span>
<div class="aed-preview-row__date">
<span class="aed-preview-row__weekday">{{ fmtWeekdayShort(item.origStart) }}</span>
<span class="aed-preview-row__daynum">{{ fmtDayNum(item.origStart) }}</span>
<span class="aed-preview-row__month">{{ fmtMonthShort(item.origStart) }}</span>
</div>
<span class="aed-preview-row__time">{{ fmtTime(item.origStart) }}</span>
</div>
</div>
</div>
<div class="aed-preview-col aed-preview-col--after">
<div class="aed-preview-col__title">Depois</div>
<div class="aed-preview-list">
<div
v-for="(item, idx) in previewAffectedOccurrences"
:key="`after-${item.ev.id || item.ev.recurrence_date}`"
class="aed-preview-row aed-preview-row--after"
:class="[`aed-preview-row--${item.ev._status || 'agendado'}`, { 'aed-preview-row--changed': item.changed }]"
>
<span class="aed-preview-row__num">{{ idx + 1 }}</span>
<div class="aed-preview-row__date">
<span class="aed-preview-row__weekday">{{ fmtWeekdayShort(item.newStart) }}</span>
<span class="aed-preview-row__daynum">{{ fmtDayNum(item.newStart) }}</span>
<span class="aed-preview-row__month">{{ fmtMonthShort(item.newStart) }}</span>
</div>
<span class="aed-preview-row__time">{{ fmtTime(item.newStart) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- COLUNA ESQUERDA campos -->
<div v-if="!occurrenceMode" class="composer-left">
<!-- Avisos topo -->
<Message v-if="form.conflito" severity="warn" class="mb-3" :closable="false"> <span class="font-semibold">Conflito:</span> {{ form.conflito }} </Message>
<Message v-if="isDayBlocked && !isSessionEvent" severity="warn" class="mb-3" :closable="false">
<i class="pi pi-lock mr-1" />
Atenção: dia bloqueado. Este tipo pode ser salvo normalmente.
</Message>
<Message v-if="jornadaDialog" :severity="jornadaDialog.isOff ? 'warn' : 'info'" class="mb-3 aed-msg-jornada aed-msg-jornada--inline" :closable="false">
<i :class="jornadaDialog.isOff ? 'pi pi-moon mr-1' : 'pi pi-clock mr-1'" />
{{ jornadaDialog.text }}
</Message>
<Message v-if="isDiaFolga && isSessionEvent" severity="warn" class="mb-3" :closable="false">
<i class="pi pi-moon mr-1" />
Este dia é folga na sua jornada. Você ainda pode salvar se necessário.
</Message>
<!-- Restrições de status do paciente -->
<Message v-if="isArchivedPastEdit" severity="warn" class="mb-3" :closable="false">
<i class="pi pi-lock mr-1" />
<b>Paciente arquivado.</b> O histórico de sessões é somente leitura.
</Message>
<Message v-if="isEdit && form.paciente_status === 'Inativo' && isSessionFuture" severity="warn" class="mb-3" :closable="false">
<i class="pi pi-ban mr-1" />
<b>Paciente inativo.</b> Remarcação de sessões está bloqueada.
</Message>
<Message v-if="isEdit && form.paciente_status === 'Arquivado' && isSessionFuture" severity="info" class="mb-3" :closable="false">
<i class="pi pi-info-circle mr-1" />
<b>Paciente arquivado.</b> Sessão futura pode ser editada, mas novos agendamentos e recorrências estão bloqueados.
</Message>
<Message v-if="!isEdit && isSessionEvent && form.paciente_id && !agendaPerms.canCreateSession" severity="error" class="mb-3" :closable="false">
<i class="pi pi-ban mr-1" />
<b>{{ form.paciente_status === 'Arquivado' ? 'Paciente arquivado.' : 'Paciente inativo.' }}</b>
Novos agendamentos estão bloqueados.
</Message>
<!-- Alerta: solicitação pendente neste horário -->
<Message v-if="solicitacaoPendente && isSessionEvent && !isEdit" severity="info" class="mb-3" :closable="false">
<div class="flex items-center justify-between gap-3 w-full flex-wrap">
<span>
<i class="pi pi-inbox mr-1" />
Solicitação pendente de
<b>{{ solicitacaoPendente.paciente_nome }} {{ solicitacaoPendente.paciente_sobrenome }}</b>
para este horário.
</span>
<Button label="Ver agendamentos recebidos" icon="pi pi-arrow-right" size="small" severity="info" outlined class="rounded-full shrink-0" @click="goToAgendamentosRecebidos" />
</div>
</Message>
<!-- PACIENTE + DATA E HORÁRIO (lado a lado em desktop)
Em sessao, ambos lado a lado (aed-row-50, 50/50 >=768px).
Em commitments nao-sessao (sem paciente), wrapper vira
div vazio e Data e Horario usa seu proprio mb-4. -->
<div :class="isSessionEvent ? 'aed-row-50' : ''">
<div v-if="isSessionEvent" class="patient-hero">
<div class="patient-hero__label">
<div class="flex items-center gap-1.5">
<i class="pi pi-user" />
<!-- Mobile: label "Paciente" textual.
Desktop: substituido pelo botao toggle modalidade abaixo. -->
<span class="patient-hero__label-text">Paciente</span>
<Button
v-tooltip.top="'Clique para alternar Presencial/Online'"
:label="modalidadeLabel"
icon="pi pi-pencil"
size="small"
severity="secondary"
outlined
class="patient-hero__modal-btn rounded-full text-xs h-7"
@click="toggleModalidade"
/>
</div>
<div class="flex items-center gap-2 ml-auto">
<Button
icon="pi pi-id-card"
severity="primary"
text
class="patient-hero__cad-completo-btn"
v-tooltip.top="isEdit ? 'Edição de paciente indisponível no fluxo de sessão' : 'Cadastro completo'"
:disabled="isEdit"
@click="cadCompletoOpen = true"
/>
</div>
</div>
<!-- Sem paciente selecionado -->
<button v-if="!form.paciente_id" class="patient-hero__empty" @click="openPacientePicker">
<div class="patient-hero__empty-icon">
<i class="pi pi-user-plus" />
</div>
<div>
<div class="font-semibold">Selecionar paciente</div>
<div class="text-xs text-color-secondary">Toque para buscar</div>
</div>
<i class="pi pi-chevron-right ml-auto text-color-secondary opacity-50" />
</button>
<!-- Com paciente selecionado -->
<div v-else class="patient-hero__selected">
<Avatar v-if="form.paciente_avatar" :image="form.paciente_avatar" shape="circle" size="large" class="shrink-0" />
<Avatar v-else :label="patientInitials(form.paciente_nome)" shape="circle" size="large" class="shrink-0 patient-avatar-bg" />
<div class="min-w-0 flex-1">
<div class="font-bold text-base truncate">{{ form.paciente_nome }}</div>
<div class="flex items-center gap-1.5 flex-wrap">
<!-- Mini links Editar / Limpar substituem o texto "Paciente vinculado"
+ botoes redondos a direita. Locked mostra texto e cadeado. -->
<template v-if="!patientLocked && !lockPatient">
<button type="button" class="patient-hero__link" @click="openPacientePicker">Editar</button>
<span class="patient-hero__link-sep">/</span>
<button type="button" class="patient-hero__link patient-hero__link--danger" @click="clearPaciente">Limpar</button>
</template>
<span v-else class="text-xs text-color-secondary inline-flex items-center gap-1" v-tooltip.top="lockPatient ? 'Paciente do prontuário' : 'Paciente não pode ser alterado após criação'">
<i class="pi pi-lock" style="font-size: 0.7rem" />
Paciente vinculado
</span>
<span
v-if="form.paciente_status === 'Inativo' || form.paciente_status === 'Arquivado'"
style="display: inline-block; background: #f97316; color: #fff; font-size: 9px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; padding: 1px 6px; border-radius: 3px; line-height: 1.5"
>{{ form.paciente_status === 'Arquivado' ? 'arquivado' : 'desativado' }}</span
>
</div>
</div>
</div>
<Message v-if="samePatientConflict && isSessionEvent" severity="warn" class="mt-2" :closable="false">
Paciente tem sessão neste dia:
<b>{{ fmtTime(new Date(samePatientConflict.inicio_em)) }} {{ fmtTime(new Date(samePatientConflict.fim_em)) }}</b>
</Message>
</div>
<!-- DATA E HORÁRIO -->
<div class="field-card mb-4">
<div class="field-card__header">
<i class="pi pi-calendar" />
<!-- Mobile: label visível; Desktop (>=md/768): label some
e botão "Ajustar horário" toma o lugar. Mesmo padrão
do patient-hero com o toggle Modalidade. -->
<span class="md:hidden">Data e Horário</span>
<!-- Ajustar horário:
- Em criação: sempre visível
- Em edição avulsa: visível (terapeuta pode reagendar)
- Em edição de série: escondido (use o 3º dialog de ocorrência)
Política "esconder em vez de disabled" pra não criar dúvida.
2026-05-13. -->
<Button
v-if="!isEdit || !hasSerie"
label="Ajustar horário"
icon="pi pi-pencil"
size="small"
severity="secondary"
outlined
class="rounded-full text-xs h-7 ml-auto md:ml-0"
@click="timePickerOpen = true"
/>
</div>
<div class="field-card__body">
<div class="time-hero" :class="{ 'time-hero--readonly': isEdit && hasSerie }" @click="(!isEdit || !hasSerie) && (timePickerOpen = true)">
<!-- Linha 1: Data Duração -->
<div class="time-hero__line">
<span class="time-hero__date">{{ form.dia ? fmtDateBR(form.dia) : '—' }}</span>
<span class="time-hero__bullet"></span>
<span class="time-hero__duration">{{ fmtDuracao(form.duracaoMin) }}</span>
</div>
<!-- Linha 2: Início Término -->
<div class="time-hero__line">
<span class="time-hero__time">{{ form.startTime || '—' }}</span>
<span class="time-hero__arrow"></span>
<span class="time-hero__time">{{ fimDateTime ? fmtTime(fimDateTime) : '—' }}</span>
</div>
</div>
<!-- Inline (mobile) no desktop migra pro Teleport abaixo do Resumo flutuante -->
<Message v-if="timeConflict" severity="warn" class="m-2 time-conflict-msg time-conflict-msg--inline" :closable="false">
<i class="pi pi-exclamation-triangle mr-1" />
{{ timeConflict }}
</Message>
</div>
<!-- /field-card__body -->
</div>
<!-- /field-card -->
</div>
<!-- /aed-row-50 (Paciente + Data e Horário) -->
<!-- STATUS DA SESSÃO -->
<!-- Não aparece em pacote o status é por ocorrência da série,
gerenciado no calendário, não aqui. -->
<div v-if="isSessionEvent && isEdit && !isPacote" class="field-card mb-4">
<div class="field-card__header">
<i class="pi pi-tag" />
<span>Status da Sessão</span>
</div>
<div class="field-card__body">
<SelectButton v-model="form.status" :options="statusOptionsFiltered" optionLabel="label" optionValue="value" optionDisabled="disabled" :allowEmpty="false" :disabled="isArchivedPastEdit" class="w-full status-select-btn" />
</div>
</div>
<!-- Extras movido pro final do form (depois de Sessão/Honorários + Frequência) -->
<!-- DEMAIS CAMPOS -->
<div class="fields-grid">
<!-- Título (apenas para não-sessão) -->
<div v-if="!isSessionEvent">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-pencil" />
<InputText id="aed-titulo" v-model="form.titulo_custom" class="w-full" variant="filled" />
</IconField>
<label for="aed-titulo">Título</label>
</FloatLabel>
</div>
<!-- Profissional -->
<div v-if="allowOwnerEdit">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-briefcase" />
<Select inputId="aed-owner" class="w-full" v-model="form.owner_id" :options="ownerOptions" optionLabel="label" optionValue="value" variant="filled" />
</IconField>
<label for="aed-owner">Profissional</label>
</FloatLabel>
</div>
<!-- Observação nativa removida em 2026-05-11. Agora vem como
campo extra do compromisso determinado (key='notes').
Migration: 20260511000001_session_default_notes_field.sql. -->
</div>
<!-- RECORRÊNCIAS APLICADAS -->
<div v-if="hasSerie" class="serie-panel mt-4">
<div class="serie-panel__header">
<i class="pi pi-refresh shrink-0" />
<span>Recorrências Aplicadas</span>
<div v-if="!serieLoading && serieEvents.length" class="serie-panel__stats">
<span>{{ serieEvents.length }} sessões</span>
<span v-if="serieCountByStatus.realizado"> · {{ serieCountByStatus.realizado }} realizadas</span>
<span v-if="serieCountByStatus.faltou"> · {{ serieCountByStatus.faltou }} faltaram</span>
<span v-if="serieCountByStatus.cancelado"> · {{ serieCountByStatus.cancelado }} canceladas</span>
<span v-if="serieCountByStatus.remarcado"> · {{ serieCountByStatus.remarcado }} para remarcar</span>
</div>
<span v-if="serieLoading" class="ml-auto text-xs opacity-50">Carregando</span>
</div>
<div v-if="!serieLoading && serieEvents.length" class="serie-pills-wrap">
<div
v-for="(ev, idx) in serieEvents"
:key="ev.id || ev.recurrence_date"
class="serie-pill"
:class="[`serie-pill--${ev._status || 'agendado'}`, { 'serie-pill--current': ev.recurrence_date === currentRecurrenceDate, 'serie-pill--past': isPast(ev.inicio_em) }]"
>
<span class="serie-pill__num">{{ idx + 1 }}</span>
<div class="serie-pill__date">
<span class="serie-pill__weekday">{{ fmtWeekdayShort(ev.inicio_em) }}</span>
<span class="serie-pill__daynum">{{ fmtDayNum(ev.inicio_em) }}</span>
<span class="serie-pill__month">{{ fmtMonthShort(ev.inicio_em) }}</span>
</div>
<span class="serie-pill__time">{{ fmtTime(new Date(ev.inicio_em)) }}</span>
<!-- Status: badge read-only. Edicao do status vive
dentro do dialog "Editar Ocorrencia" (botao
Editar abre o 2º dialog). 2026-05-12. -->
<span class="serie-pill__status-badge">{{ labelStatusSessao(ev._status || 'agendado') }}</span>
<span v-if="ev.recurrence_date === currentRecurrenceDate" class="serie-pill__cur-badge">Atual</span>
<Button label="Editar" icon="pi pi-pencil" text size="small" severity="secondary" class="serie-pill__edit" v-tooltip.left="'Editar esta sessão'" @click="onPillEditClick(ev)" />
<Button icon="pi pi-times" text size="small" severity="secondary" class="serie-pill__del" v-tooltip.left="'Remover'" @click="onPillDeleteClick(ev, $event)" />
</div>
</div>
<div v-if="!serieLoading && !serieEvents.length" class="serie-panel__empty">Nenhuma sessão encontrada na série.</div>
<Menu ref="pillDeleteMenuRef" :model="pillDeleteMenuItems" :popup="true" />
</div>
</div>
<!-- ABAIXO DO FORM Financeiro, e Resumo (mobile) -->
<!-- Escopo "Aplicar alterações em" migrou pro occurrenceMode em
2026-05-12 aparece no 2º dialog empilhado de ocorrência. -->
<div v-if="!occurrenceMode" class="composer-right">
<!-- SESSÃO/HONORÁRIOS + FREQUÊNCIA (lado a lado em desktop)
Quando ha serie (edicao de evento ja vinculado a regra),
Frequencia some e Sessao/Honorarios ocupa a linha sozinho. -->
<div :class="(isSessionEvent && !hasSerie) ? 'aed-row-50' : ''">
<!-- FINANCEIRO ( sessão) -->
<!-- Padrao .field-card: header com icone + label (mobile only) +
Select Gratuito/Particular/Convenio. Default Particular.
Body so renderiza pra particular/convenio (Gratuito =
card so com header, compacto). -->
<div v-if="isSessionEvent" class="field-card mb-4">
<div class="field-card__header">
<i class="pi pi-wallet" />
<!-- Mobile: label visível; Desktop: label some e Select
ocupa o espaço. Mesmo padrão do Paciente e Data/Horário. -->
<span class="md:hidden">Sessão / Honorários</span>
<Select
v-model="billingType"
:options="billingTypeOptions"
optionLabel="label"
optionValue="value"
size="small"
class="aed-pay-mod-select ml-auto md:ml-0"
/>
</div>
<Transition name="aed-pay-expand">
<div class="field-card__body aed-pay-body">
<!-- Gratuito: aviso informativo, sem ações -->
<div v-if="billingType === 'gratuito'" class="aed-pay-gratuito">
<i class="pi pi-gift" />
<span><b>Esta sessão será gratuita</b> sem cobrança ao paciente.</span>
</div>
<template v-else>
<!-- Empty state convida a abrir o dialog de seleção/cálculo -->
<button
v-if="!hasBillingConfigured"
type="button"
class="patient-hero__empty"
@click="serviceDialogOpen = true"
>
<div class="patient-hero__empty-icon">
<i :class="billingType === 'convenio' ? 'pi pi-shield' : 'pi pi-shopping-bag'" />
</div>
<div>
<div class="font-semibold">
{{ billingType === 'convenio' ? 'Selecione convênio para adicionar e calcular' : 'Selecione serviços para adicionar e calcular' }}
</div>
<div class="text-xs text-color-secondary">Toque para configurar</div>
</div>
<i class="pi pi-chevron-right ml-auto text-color-secondary opacity-50" />
</button>
<!-- Resumo configurado total + count/nome + botão Editar -->
<div v-else class="aed-pay-summary">
<div class="aed-pay-summary__main">
<template v-if="billingType === 'particular'">
<div class="aed-pay-summary__count">
{{ commitmentItems.length }} {{ commitmentItems.length === 1 ? 'serviço' : 'serviços' }}
<span v-if="totalDiscountFromItems > 0" class="aed-pay-summary__discount">· desconto de {{ fmtBRL(totalDiscountFromItems) }}</span>
</div>
<div class="aed-pay-summary__value">{{ fmtBRL(totalFromItems) }}</div>
</template>
<template v-else-if="billingType === 'convenio'">
<div class="aed-pay-summary__count">
{{ selectedPlanName || 'Convênio' }}
<span v-if="selectedPlanServiceName" class="opacity-70">· {{ selectedPlanServiceName }}</span>
</div>
<div class="aed-pay-summary__value">{{ form.insurance_value != null ? fmtBRL(form.insurance_value) : '—' }}</div>
</template>
</div>
<Button
label="Editar"
icon="pi pi-pencil"
size="small"
severity="secondary"
outlined
class="rounded-full text-xs h-7"
@click="serviceDialogOpen = true"
/>
</div>
</template>
</div>
</Transition>
<!-- Cobrança da sessão (financial_records ja criado)
em edição. Movido pra dentro do Sessao/Honorarios
em 2026-05-11 (antes ficava num side-card separado). -->
<AgendaEventoFinanceiroPanel v-if="isSessionEvent && isEdit && eventRow?.id" :evento="eventRow" class="m-3" @cobranca-atualizada="loadSessionPaymentRecord" />
<!-- Botão "Ver lançamentos" (2026-05-14): abre dialog
listando todos os financial_records vinculados à
sessão (cobrança original + multas/extras). Aparece
em edição com evento real. -->
<div v-if="isSessionEvent && isEdit && eventRow?.id" class="px-3 pb-3">
<Button
label="Ver lançamentos da sessão"
icon="pi pi-list"
severity="secondary"
outlined
size="small"
class="rounded-full text-xs h-8"
@click="openSessionRecordsDialog"
/>
</div>
<!-- Invariante de propagacao em series (padrao SimplePractice):
alteracoes no template da regra so afetam sessoes futuras
AINDA NAO cobradas. financial_records emitidos sao imutaveis
pelo dialog alteracoes vao via fluxo do Financeiro. Aviso
fixo abaixo do panel financeiro pra usuario nao se surpreender
com cobrancas inalteradas. 2026-05-12. -->
<Message v-if="isSessionEvent && isEdit && hasSerie" severity="info" :closable="false" class="m-3">
<div class="text-xs">
<i class="pi pi-info-circle mr-1" />
Alterações de tipo ou serviços afetam apenas sessões futuras
ainda não cobradas. <b>Cobranças emitidas permanecem inalteradas</b>
para ajustá-las, acesse o Financeiro.
</div>
</Message>
</div>
<!-- FREQUÊNCIA ( sessão, sem série) -->
<div v-if="isSessionEvent && !hasSerie" class="field-card mb-4">
<div class="field-card__header">
<i class="pi pi-refresh" />
<span>Frequência</span>
</div>
<div class="field-card__body">
<!-- Avulsa = padrão. Renderiza como "selecionado" (mesma
estrutura do estado configurado) pra ficar claro
que É uma escolha válida não um estado vazio.
Botão Editar abre o dialog pra trocar pra recorrente. -->
<div v-if="recorrenciaType === 'avulsa'" class="aed-pay-summary">
<div class="aed-pay-summary__main">
<div class="aed-pay-summary__count">Tipo</div>
<div class="aed-pay-summary__value text-base">Avulsa</div>
<div class="aed-freq-conta">Sessão única, sem repetição</div>
</div>
<Button
label="Editar"
icon="pi pi-pencil"
size="small"
severity="secondary"
outlined
class="rounded-full text-xs h-7"
@click="freqDialogOpen = true"
/>
</div>
<!-- Recorrência configurada resumo + botão Editar -->
<div v-else class="aed-pay-summary">
<div class="aed-pay-summary__main">
<div class="aed-pay-summary__count">{{ freqResumoMain }}</div>
<div v-if="freqResumoSub" class="aed-pay-summary__value text-base">{{ freqResumoSub }}</div>
<div v-if="freqValorConta" class="aed-freq-conta">{{ freqValorConta }}</div>
</div>
<Button
label="Editar"
icon="pi pi-pencil"
size="small"
severity="secondary"
outlined
class="rounded-full text-xs h-7"
@click="freqDialogOpen = true"
/>
</div>
</div>
</div>
</div>
<!-- /aed-row-50 (Sessão/Honorários + Frequência) -->
<!-- COBRANÇA (Card dedicado fullwidth, 2026-05-14)
Opções dinâmicas baseadas em recorrenciaType (avulsa/recorrente).
Quando user troca tipo (avulsarecorrente), mostra mensagem
vermelha de mudança e reseta as opções. -->
<div
v-if="!isEdit && isSessionEvent && billingType === 'particular' && hasBillingConfigured"
class="field-card mb-4 aed-charge-card"
>
<div class="field-card__header">
<i class="pi pi-bolt" />
<span>Cobrança ao salvar</span>
</div>
<div class="field-card__body aed-charge-card__body">
<Transition name="aed-charge-warn">
<div v-if="chargeChangeMsg" :key="chargeChangeMsgKey" class="aed-charge-change-msg">
<i class="pi pi-exclamation-triangle" />
<span v-html="chargeChangeMsg" />
</div>
</Transition>
<SelectButton
v-model="chargeMode"
:options="chargeModeOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
class="aed-charge-mode-buttons"
/>
<div v-if="showPackageStyle" class="aed-payment-settle">
<label class="aed-payment-settle__label">Estilo do pacote</label>
<Select
v-model="packageStyle"
:options="packageStyleOptions"
optionLabel="label"
optionValue="value"
class="aed-payment-settle__select"
size="small"
/>
</div>
<div v-if="showPaymentSettlement" class="aed-payment-settle">
<label class="aed-payment-settle__label">Forma de pagamento</label>
<Select
v-model="paymentMethod"
:options="paymentMethodOptions"
optionLabel="label"
optionValue="value"
class="aed-payment-settle__select"
size="small"
/>
</div>
<div v-if="showPaymentSettlement && showMarkPaidToggle" class="aed-payment-settle">
<label class="aed-payment-settle__label">Status do pagamento</label>
<SelectButton
v-model="markPaidNow"
:options="markPaidOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
class="aed-charge-mode-buttons"
/>
</div>
<span class="aed-charge-mode-hint">{{ chargeModeHint }}</span>
</div>
</div>
<!-- Hint contextual por tipo de cobrança fullwidth, acima
do card Extras. Estava antes dentro do aed-row-50 (grid
50/50) e ocupava uma cell, empurrando Frequência pra
baixo. Movido pra fora do row em 2026-05-19.
Esconde quando cobrança paga/pendente (lock-edit)
a Message do panel cobre. -->
<div v-if="isSessionEvent && !occFinancialRecord && billingType === 'convenio'" class="aed-billing-hint mb-4">
<i class="pi pi-info-circle" />
<span><b> da guia é opcional</b> você pode salvar a sessão e preencher depois, quando o convênio responder.</span>
</div>
<div v-else-if="isSessionEvent && !occFinancialRecord && billingType === 'gratuito'" class="aed-billing-hint mb-4">
<i class="pi pi-gift" />
<span>Sessão <b>gratuita</b> nenhum lançamento será gerado no Financeiro.</span>
</div>
<!-- EXTRAS (herdados do compromisso determinado) último card.
'notes' é caso especial: bindamos em form.observacoes pra
manter compat com a coluna nativa agenda_eventos.observacoes
(consumida por relatórios/prontuário). Outros campos vão pra
agenda_eventos.extra_fields (JSONB) via form.extra_fields. -->
<div v-if="selectedCommitmentFields.length" class="field-card mb-4">
<div class="field-card__header">
<i class="pi pi-list" />
<span>Extras</span>
<Button
icon="pi pi-question-circle"
v-tooltip.top="'Como funciona'"
severity="secondary"
outlined
size="small"
class="ml-auto rounded-full h-7 w-7"
@click="(e) => extrasHelpRef?.toggle(e)"
/>
<Popover ref="extrasHelpRef">
<div class="aed-help-pop">
<div class="aed-help-pop__title"><i class="pi pi-info-circle" /> Como funcionam os campos Extras</div>
<ul class="aed-help-pop__list">
<li>Esses campos vêm do <b>Compromisso Determinado</b> selecionado (ex.: Observação na Sessão, Livro/Autor na Leitura).</li>
<li>Cada tipo de compromisso pode ter <b>campos próprios</b> você define quais quer registrar em cada atendimento.</li>
<li>Pra <b>adicionar, editar ou remover</b> campos extras, edite o tipo na tela de Compromissos.</li>
</ul>
<div class="aed-help-pop__link">
<Button label="Gerenciar Compromissos" icon="pi pi-external-link" size="small" text @click="goToCompromissosConfig" />
</div>
</div>
</Popover>
</div>
<div class="field-card__body aed-extras-body">
<div class="fields-grid">
<div v-for="f in selectedCommitmentFields" :key="f.key" :class="{ 'col-span-full': f.field_type === 'textarea' }">
<FloatLabel variant="on">
<template v-if="f.field_type === 'textarea' && f.key === 'notes'">
<Textarea :id="`aed-extra-${f.key}`" v-model="form.observacoes" class="w-full" variant="filled" rows="3" autoResize :disabled="isArchivedPastEdit" />
</template>
<Textarea v-else-if="f.field_type === 'textarea'" :id="`aed-extra-${f.key}`" v-model="form.extra_fields[f.key]" class="w-full" variant="filled" rows="2" autoResize />
<InputText v-else :id="`aed-extra-${f.key}`" v-model="form.extra_fields[f.key]" class="w-full" variant="filled" />
<label :for="`aed-extra-${f.key}`">{{ f.label }}{{ f.required ? ' *' : '' }}</label>
</FloatLabel>
</div>
</div>
</div>
</div>
<!-- RESUMO (mobile inline; desktop migra pro Teleport flutuante abaixo) -->
<div class="side-card agenda-resumo agenda-resumo--mobile">
<div class="side-card__title">Resumo</div>
<div class="flex items-center gap-2 mb-2">
<span v-if="selectedCommitment?.bg_color" class="commit-badge" :style="{ background: `#${selectedCommitment.bg_color}20`, color: `#${selectedCommitment.bg_color}`, borderColor: `#${selectedCommitment.bg_color}40` }">{{
selectedCommitmentName
}}</span>
<Tag v-else :value="selectedCommitmentName" severity="info" />
<Tag v-if="isSessionEvent" :value="labelStatusSessao(form.status)" :severity="statusSeverity(form.status)" :class="statusExtraClass(form.status)" />
</div>
<div class="summary-row">
<i class="pi pi-user summary-icon" />
<span class="truncate">{{ form.paciente_nome || (isSessionEvent ? 'Sem paciente' : '—') }}</span>
</div>
<div class="summary-row">
<i class="pi pi-calendar summary-icon" />
<span class="truncate">{{ form.dia ? fmtDateBR(form.dia) : '—' }}</span>
</div>
<div class="summary-row">
<i class="pi pi-clock summary-icon" />
<span>{{ form.startTime || '—' }} {{ fimDateTime ? fmtTime(fimDateTime) : '—' }}</span>
</div>
<div v-if="paymentSummary" class="summary-row aed-pay-summary-row" :class="paymentSummary.cls">
<i class="pi summary-icon" :class="paymentSummary.icon" />
<span class="truncate">{{ paymentSummary.label }}</span>
</div>
<div class="summary-row">
<i class="pi pi-map-marker summary-icon" />
<span><span class="capitalize">{{ form.modalidade || '—' }}</span><span v-if="isPacote"> · Pacote</span></span>
</div>
<div v-if="isSessionEvent" class="summary-row">
<i class="pi pi-wallet summary-icon" />
<span>{{ walletLabel }}</span>
</div>
<div v-if="isSessionEvent && pacoteTotal != null" class="summary-row aed-summary-row--calc">
<i class="pi pi-calculator summary-icon" />
<span>{{ freqValorConta }}</span>
</div>
</div>
</div>
</div>
</Transition>
<!-- -->
<!-- Patient Picker Dialog -->
<!-- -->
<Dialog
v-model:visible="pacientePickerOpen"
modal
:draggable="false"
:style="{ width: '860px', maxWidth: '96vw', border: '3px solid var(--p-primary-color)' }"
:breakpoints="{ '960px': '96vw', '640px': '98vw' }"
>
<template #header>
<div class="aed-svc-dlg-head">
<div class="aed-svc-dlg-head__icon">
<i class="pi pi-user" />
</div>
<div class="aed-svc-dlg-head__text">
<div class="aed-svc-dlg-head__title">Selecionar paciente</div>
<div class="aed-svc-dlg-head__sub">Busque por nome, e-mail ou telefone</div>
</div>
</div>
</template>
<div class="flex flex-col gap-3 pt-3">
<div class="flex flex-wrap items-center gap-3">
<div class="flex-1 min-w-[240px]">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-search" />
<InputText id="aed-paciente-search" v-model="pacienteSearch" class="w-full" autocomplete="off" variant="filled" />
</IconField>
<label for="aed-paciente-search">Buscar por nome, e-mail ou telefone</label>
</FloatLabel>
</div>
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined class="rounded-full" :loading="pacientesLoading" @click="loadPatients(true)" />
</div>
<Message v-if="pacientesError" severity="warn">{{ pacientesError }}</Message>
<DataTable
:value="filteredPatients"
dataKey="id"
:loading="pacientesLoading"
stripedRows
scrollable
paginator
:rows="10"
:rowsPerPageOptions="[10, 25, 50]"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} paciente(s)"
class="aed-patient-dt"
:rowClass="(r) => (r.status && r.status !== 'Ativo' ? 'patient-row-blocked' : '')"
>
<Column header="Paciente" sortable sortField="nome" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
<Avatar v-else :label="patientInitials(data.nome)" shape="circle" />
<div class="min-w-0">
<div class="font-medium truncate">{{ data.nome || 'Sem nome' }}</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)] truncate">
<span v-if="data.email">{{ data.email }}</span>
<span v-if="data.email && data.telefone"> </span>
<span v-if="data.telefone">{{ data.telefone }}</span>
<span v-if="!data.email && !data.telefone"></span>
</div>
</div>
</div>
</template>
</Column>
<Column header="Status" sortable sortField="status" style="width: 9rem">
<template #body="{ data }">
<Tag v-if="data.status === 'Arquivado'" value="Arquivado" severity="danger" />
<Tag v-else-if="data.status === 'Inativo'" value="Inativo" severity="warn" />
<Tag v-else-if="data.status === 'Ativo'" value="Ativo" severity="success" />
<Tag v-else-if="data.status" :value="String(data.status)" severity="secondary" />
<span v-else class="text-[var(--text-color-secondary)] opacity-60"></span>
</template>
</Column>
<Column header="Ação" style="width: 10rem; min-width: 10rem" :exportable="false" frozen alignFrozen="right">
<template #body="{ data }">
<Button
v-if="!data.status || data.status === 'Ativo'"
label="Selecionar"
icon="pi pi-check"
size="small"
outlined
rounded
@click="selectPaciente(data)"
/>
<Button
v-else
icon="pi pi-lock"
size="small"
outlined
rounded
severity="secondary"
disabled
v-tooltip.top="data.status === 'Arquivado' ? 'Paciente arquivado não é possível agendar' : 'Paciente inativo não é possível agendar'"
/>
</template>
</Column>
<template #empty>
<div class="py-8 text-center">
<i class="pi pi-search text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-[1rem]">Nenhum paciente encontrado</div>
<div class="text-[0.75rem] opacity-60 mt-1">Tente outro termo de busca.</div>
<Button v-if="pacienteSearch" class="mt-3" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar busca" size="small" @click="pacienteSearch = ''" />
</div>
</template>
</DataTable>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="pacientePickerOpen = false" />
</template>
</Dialog>
<!-- -->
<!-- Time Picker Dialog -->
<!-- -->
<!-- -->
<!-- "Ver lançamentos da sessão" sub-dialog -->
<!-- -->
<Dialog v-model:visible="sessionRecordsDialogOpen" modal :draggable="false" header="Lançamentos da sessão" :style="{ width: '640px', maxWidth: '96vw' }">
<div v-if="sessionRecordsLoading" class="py-6 text-center text-sm opacity-70">
<i class="pi pi-spin pi-spinner mr-1" /> Carregando
</div>
<div v-else-if="!sessionRecordsList.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="aed-records-list">
<div v-for="(r, idx) in sessionRecordsList" :key="r.id" class="aed-record" :class="{ 'aed-record--child': idx > 0 }">
<div class="aed-record__head">
<i v-if="idx > 0" class="pi pi-arrow-right-and-arrow-left-up-down aed-record__indent" />
<span class="aed-record__desc">{{ r.description || (idx === 0 ? 'Sessão' : 'Cobrança extra') }}</span>
<Tag :value="_recordStatusLabels[r.status] || r.status" :severity="_recordStatusSeverity(r.status)" class="text-xs ml-auto" />
</div>
<div class="aed-record__body">
<div class="aed-record__row">
<i class="pi pi-money-bill" />
<span class="aed-record__amount">{{ _fmtRecordBRL(r.final_amount || r.amount) }}</span>
</div>
<div v-if="r.payment_method" class="aed-record__row">
<i class="pi pi-credit-card" />
<span>{{ _recordMethodLabels[r.payment_method] || r.payment_method }}</span>
</div>
<div class="aed-record__row">
<i class="pi pi-calendar" />
<span>Vencimento: {{ _fmtRecordDate(r.due_date) }}</span>
</div>
<div v-if="r.paid_at" class="aed-record__row">
<i class="pi pi-check-circle" />
<span>Pago em {{ _fmtRecordDate(r.paid_at) }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined @click="sessionRecordsDialogOpen = false" />
</template>
</Dialog>
<Dialog v-model:visible="timePickerOpen" modal :draggable="false" :style="{ width: '420px', maxWidth: '96vw' }" :breakpoints="{ '640px': '98vw' }" @show="onTimePickerShow" @hide="onTimePickerHide">
<template #header>
<div class="aed-svc-dlg-head">
<div class="aed-svc-dlg-head__icon">
<i class="pi pi-clock" />
</div>
<div class="aed-svc-dlg-head__text">
<div class="aed-svc-dlg-head__title">
{{ selectedCommitment?.name ? `Nova ${selectedCommitment.name}` : 'Nova sessão' }}
</div>
<div class="aed-svc-dlg-head__sub">Início da sessão e duração</div>
</div>
</div>
</template>
<div class="flex flex-col gap-4">
<!-- Mini calendar (estilo MelissaAgenda compacto, com botao Hoje no header) -->
<div class="mc-mini">
<div class="mc-mini__head">
<span class="mc-mini__title"><i class="pi pi-calendar" /> {{ miniMesAno }}</span>
<div class="mc-mini__nav">
<button type="button" class="mc-mini__icon" v-tooltip.top="'Mês anterior'" @click="miniPrev"><i class="pi pi-chevron-left" /></button>
<button type="button" class="mc-mini__today" @click="miniToday">Hoje</button>
<button type="button" class="mc-mini__icon" v-tooltip.top="'Próximo mês'" @click="miniNext"><i class="pi pi-chevron-right" /></button>
</div>
</div>
<div class="mc-mini__weekdays">
<span v-for="(d, i) in ['D','S','T','Q','Q','S','S']" :key="i">{{ d }}</span>
</div>
<div class="mc-mini__grid">
<button
v-for="(d, i) in miniDias"
:key="i"
type="button"
class="mc-mini__day"
:class="{ 'is-outro': d.outro, 'is-hoje': d.isHoje, 'is-selected': d.isSel }"
@click="selecionarDiaMini(d)"
>
{{ d.dia }}
</button>
</div>
</div>
<!-- Card: Horários disponíveis (presencial e online unificado) -->
<div v-if="availableSlots.length" class="aed-card">
<div class="aed-card__header">
<span class="aed-card__title">Horários disponíveis</span>
<div class="flex gap-1">
<button v-for="p in activePeriodos" :key="p.label" class="periodo-chip" :class="{ 'periodo-chip--active': selectedPeriodo === p }" @click="selectedPeriodo = selectedPeriodo === p ? null : p">
<i :class="`pi ${p.icon}`" />
{{ p.label }}
</button>
</div>
</div>
<div class="aed-card__body">
<!-- Info online: lista os slots pré-configurados e orienta o usuário -->
<template v-if="form.modalidade === 'online'">
<Message v-if="!onlineAtivo" severity="warn" :closable="false" class="mb-2"> Atendimento online não está ativado. Ative em <strong>Configurações Online</strong>. </Message>
<div v-else class="online-info-bar mb-2">
<i class="pi pi-video shrink-0" />
<span v-if="loadingOnlineSlots" class="opacity-60">Carregando horários online</span>
<span v-else-if="onlineSlots.length">
Horários online configurados para hoje:
<strong>{{ onlineSlots.map((s) => s.hhmm).join(', ') }}</strong>
<span class="online-info-bar__hint"> marcados com <span class="online-dot-inline"></span></span>
</span>
<span v-else class="opacity-70"> Nenhum horário online configurado para este dia você pode agendar em qualquer horário da sua jornada. </span>
<!-- Botão cog: abre popover educativo sobre horário online + link
pras configurações. Padrão espelhado do Card Extras. 2026-05-13. -->
<Button
icon="pi pi-cog"
v-tooltip.top="'Sobre horários online'"
severity="secondary"
text
rounded
size="small"
class="online-info-bar__cog ml-auto h-6 w-6"
@click="(e) => onlineHelpRef?.toggle(e)"
/>
<Popover ref="onlineHelpRef">
<div class="aed-help-pop">
<div class="aed-help-pop__title"><i class="pi pi-video" /> Como funcionam os horários online</div>
<ul class="aed-help-pop__list">
<li>Você define <b>slots específicos</b> da semana para atendimento <b>online</b> (ex.: terça às 14h, quinta às 19h).</li>
<li>No dialog, esses horários aparecem destacados com <span class="online-dot-inline"></span>.</li>
<li>Quando <b>não </b> slot online configurado para o dia, você ainda pode agendar em qualquer horário da sua jornada não terá destaque visual.</li>
</ul>
<div class="aed-help-pop__link">
<Button label="Configurar horários online" icon="pi pi-external-link" size="small" text @click="goToAgendaConfig" />
</div>
</div>
</Popover>
</div>
</template>
<!-- Grid de pills -->
<div class="slots-grid">
<button
v-for="s in filteredSlots"
:key="s.hhmm"
class="slot-pill"
:class="{
'slot-pill--busy': s.busy,
'slot-pill--current': form.startTime === s.hhmm,
'slot-pill--online-cfg': form.modalidade === 'online' && s.isOnlineConfigured
}"
:title="s.busy ? 'Horário ocupado' : form.modalidade === 'online' && s.isOnlineConfigured ? `${s.hhmm} ${s.endHhmm} · horário online configurado` : `${s.hhmm} ${s.endHhmm}`"
@click="!s.busy && selectSlot(s.hhmm)"
>
{{ s.hhmm }}
<span v-if="form.modalidade === 'online' && s.isOnlineConfigured" class="slot-online-dot" />
</button>
</div>
<div v-if="filteredSlots.length === 0" class="text-xs opacity-50 mt-2">Nenhum horário disponível neste período.</div>
</div>
</div>
<div v-else-if="form.dia" class="text-xs opacity-50">Nenhum horário disponível para este dia (verifique a jornada nas configurações).</div>
<!-- Início + Término lado a lado (Término readonly, derivado de fimDateTime) -->
<div class="flex gap-3 items-end">
<div class="time-picker-wrap">
<label class="time-label">Início</label>
<DatePicker v-model="startTimeDate" timeOnly hourFormat="24" :stepMinute="5" showIcon iconDisplay="input" fluid placeholder="HH:MM">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
</DatePicker>
</div>
<div class="time-picker-wrap">
<label class="time-label">Término</label>
<InputText :model-value="fimDateTime ? fmtTime(fimDateTime) : ''" readonly placeholder="—" fluid />
</div>
</div>
<!-- Card: Duração da sessão (pills + Select estilo V2) -->
<div class="aed-card">
<div class="aed-card__header">
<span class="aed-card__title">Duração da sessão</span>
</div>
<div class="aed-card__body">
<div class="aed-duration-row">
<button
v-for="d in duracaoQuickChips"
:key="d"
type="button"
class="aed-pill"
:class="{ 'aed-pill--active': form.duracaoMin === d }"
:disabled="isDynamic && commitmentItems.length > 0"
@click="form.duracaoMin = d"
>
{{ d }}m
</button>
<Select
v-model="form.duracaoMin"
:options="duracaoOptions"
optionLabel="label"
optionValue="value"
optionGroupLabel="label"
optionGroupChildren="items"
size="small"
class="aed-duration-other"
placeholder="Outra"
:disabled="isDynamic && commitmentItems.length > 0"
/>
</div>
<small v-if="isDynamic && commitmentItems.length > 0" class="text-color-secondary text-xs mt-2 block"> Calculado pelos serviços adicionados </small>
</div>
</div>
<Message v-if="timeConflict" severity="warn" class="mt-2" :closable="false">
<i class="pi pi-exclamation-triangle mr-1" />
{{ timeConflict }}
</Message>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="cancelTimePicker" />
<Button label="Confirmar" icon="pi pi-check" @click="confirmTimePicker" />
</template>
</Dialog>
<!-- Footer -->
<template v-if="step === 2" #footer>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Button v-if="!isEdit && allowBack" label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined size="small" class="rounded-full" @click="goBack" />
<Button v-if="isEdit && hasSerie" label="Encerrar série" icon="pi pi-trash" severity="danger" outlined size="small" class="rounded-full text-xs h-8" @click="onEncerrarSerie" />
<Button v-if="isEdit && !hasSerie" icon="pi pi-trash" severity="danger" outlined size="small" class="rounded-full h-9 w-9" v-tooltip.bottom="'Remover'" @click="onDelete" />
<!-- Lembrar paciente (WhatsApp on-demand) -->
<Button
v-if="isEdit && isSessionEvent && form.paciente_id"
icon="pi pi-whatsapp"
severity="success"
outlined
size="small"
class="rounded-full h-9 w-9"
v-tooltip.bottom="'Enviar lembrete WhatsApp agora'"
:loading="sendingReminder"
@click="onSendManualReminder"
/>
<!-- Google Calendar link -->
<a
v-if="isEdit && googleCalendarUrl"
:href="googleCalendarUrl"
target="_blank"
rel="noopener noreferrer"
class="gcal-btn"
v-tooltip.top="'Abre o Google Agenda com o compromisso pré-preenchido. Em breve: sincronização automática e bidirecional com sua conta Google.'"
>
<svg class="gcal-btn__icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.7" />
<path d="M3 9h18" stroke="currentColor" stroke-width="1.7" />
<path d="M8 2v4M16 2v4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
<path d="M8 13h.01M12 13h.01M16 13h.01M8 17h.01M12 17h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<span>Google Agenda</span>
</a>
</div>
<Button label="Salvar" icon="pi pi-check" size="small" class="rounded-full" :disabled="!canSave" @click="onSave" />
</div>
</template>
<!-- -->
<!-- Cadastro Rápido de Paciente -->
<!-- -->
<ComponentCadastroRapido
v-model="cadRapidoOpen"
title="Novo Paciente"
table-name="patients"
name-field="nome_completo"
email-field="email_principal"
phone-field="telefone"
:extra-payload="{ status: 'Ativo' }"
hide-view-list-button
@created="onPatientCreatedRapido"
/>
<!-- Cadastro completo (PatientCadastroDialog inline substitui
o open-em-nova-aba pra nao vazar do layout Melissa) -->
<PatientCadastroDialog v-model="cadCompletoOpen" hide-view-list-button @created="onPatientCreatedCompleto" />
<!-- Quick-create: Serviço -->
<ServiceQuickCreateDialog
v-model="serviceQuickDlgOpen"
:owner-id="ownerId"
@created="onServiceCreated"
/>
<!-- Quick-create: Convênio -->
<InsurancePlanQuickCreateDialog
v-model="insuranceQuickDlgOpen"
:owner-id="planOwnerId || ownerId"
@created="onInsuranceCreated"
/>
<!-- Quick-create: Procedimento do convênio -->
<InsurancePlanServiceQuickCreateDialog
v-model="planServiceQuickDlgOpen"
:insurance-plan-id="form.insurance_plan_id"
:insurance-plan-name="selectedPlanName"
@created="onPlanServiceCreated"
/>
<!-- -->
<!-- Service Selection Dialog (Particular OU Convênio) -->
<!-- Substituiu a UI inline antiga do card Sessão / Honorários. -->
<!-- -->
<Dialog
v-model:visible="serviceDialogOpen"
modal
:draggable="false"
:style="{ width: '560px', maxWidth: '96vw' }"
:breakpoints="{ '640px': '98vw' }"
>
<template #header>
<div class="aed-svc-dlg-head">
<div class="aed-svc-dlg-head__icon">
<i :class="billingType === 'convenio' ? 'pi pi-shield' : 'pi pi-shopping-bag'" />
</div>
<div class="aed-svc-dlg-head__text">
<div class="aed-svc-dlg-head__title">{{ billingType === 'convenio' ? 'Convênio' : 'Serviços' }}</div>
<div class="aed-svc-dlg-head__sub">
{{ billingType === 'convenio' ? 'Plano, procedimento e valor da sessão' : 'Adicionar serviços para calcular o valor da sessão' }}
</div>
</div>
</div>
</template>
<div class="pt-3">
<!-- PARTICULAR: seletor de serviço + items list (unidades/preço/desconto/total) -->
<template v-if="billingType === 'particular'">
<!-- Hint educativo: clarifica a diferença entre "unidades" (qtd
do MESMO serviço NESTA sessão) e "recorrência" (qtd de
sessões agendadas em datas diferentes). -->
<Message severity="info" :closable="false" class="mb-3">
<div class="aed-svc-hint">
<div class="aed-svc-hint__row">
<i class="pi pi-box" />
<span>
<b>Unidades</b> quantas vezes o <b>mesmo serviço</b> é cobrado <b>nesta sessão</b>.
Ex.: <i>2 aplicações da mesma técnica numa única consulta</i>, ou <i>3 doses do mesmo procedimento no mesmo atendimento</i>.
</span>
</div>
<div class="aed-svc-hint__row">
<i class="pi pi-refresh" />
<span>
<b>Recorrência</b> multiplica o valor desta sessão pela <b>quantidade de datas agendadas</b>.
Ex.: pacote semanal de 4 sessões. Configurado no card <b>Frequência</b>.
</span>
</div>
</div>
</Message>
<InputGroup class="aed-pay-inputgroup">
<Select
v-if="services.filter((s) => s.active).length"
v-model="servicePickerSel"
:options="services.filter((s) => s.active)"
optionLabel="name"
optionValue="id"
placeholder="Adicionar serviço..."
class="flex-1"
size="small"
@update:modelValue="
(id) => {
addItem(services.find((s) => s.id === id));
servicePickerSel = null;
}
"
/>
<span v-else class="flex-1 text-xs text-color-secondary py-2 px-3 italic">Sem serviços cadastrados</span>
<Button icon="pi pi-plus" v-tooltip.top="'Cadastrar novo serviço'" severity="secondary" size="small" @click="openServiceQuickCreate" />
<Button icon="pi pi-question-circle" v-tooltip.top="'Como funciona'" severity="secondary" outlined size="small" @click="(e) => servicoHelpRef?.toggle(e)" />
</InputGroup>
<Popover ref="servicoHelpRef">
<div class="aed-help-pop">
<div class="aed-help-pop__title"><i class="pi pi-info-circle" /> Como adicionar serviços</div>
<ul class="aed-help-pop__list">
<li>Clique em um item do select pra <b>adicioná-lo</b> à sessão.</li>
<li>Clicando novamente no mesmo item, o valor é <b>somado</b> (aumenta a quantidade).</li>
<li>Use o botão <i class="pi pi-plus" /> pra <b>cadastrar</b> um serviço novo.</li>
</ul>
</div>
</Popover>
<div v-if="commitmentItems.length" class="aed-svc-list mt-3">
<div v-for="(item, idx) in commitmentItems" :key="idx" class="aed-svc-card">
<!-- Header: nome + remover -->
<div class="aed-svc-card__head">
<span class="aed-svc-card__name">{{ item.service_name }}</span>
<Button icon="pi pi-times" size="small" severity="secondary" text rounded class="aed-svc-card__remove" v-tooltip.top="'Remover'" @click="removeItem(idx)" />
</div>
<!-- Preço unitário + Total -->
<div class="aed-svc-card__row">
<div class="aed-svc-card__field">
<label class="aed-svc-card__label">Preço unitário</label>
<InputNumber v-model="item.unit_price" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" size="small" inputClass="w-28" @update:modelValue="onItemChange(item)" />
</div>
<div class="aed-svc-card__total">
<span class="aed-svc-card__total-label">Total</span>
<span class="aed-svc-card__total-value">{{ fmtBRL(item.final_price) }}</span>
<span v-if="itemHasDiscount(item)" class="aed-svc-card__calc">
{{ fmtBRL(itemSubtotal(item)) }} {{ fmtBRL(itemDiscountValue(item)) }}
</span>
</div>
</div>
<!-- Linha de ações: aplicar desconto + alterar quantidade -->
<div class="aed-svc-card__actions">
<!-- Desconto: botão quando fechado, painel inline quando aberto -->
<Button
v-if="!(item._showDiscount || item.discount_pct > 0 || item.discount_flat > 0)"
label="Aplicar desconto"
icon="pi pi-percentage"
size="small"
severity="secondary"
text
class="text-xs h-7"
@click="item._showDiscount = true"
/>
<div v-else class="aed-svc-card__discount">
<InputNumber v-model="item.discount_pct" :min="0" :max="100" suffix="%" size="small" inputClass="w-14 text-center" placeholder="%" @update:modelValue="onItemChange(item)" />
<InputNumber v-model="item.discount_flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" size="small" inputClass="w-24" placeholder="R$" @update:modelValue="onItemChange(item)" />
<Button
icon="pi pi-times"
size="small"
severity="secondary"
text
rounded
v-tooltip.top="'Remover desconto'"
@click="item._showDiscount = false; item.discount_pct = 0; item.discount_flat = 0; onItemChange(item)"
/>
</div>
<!-- Quantidade: display "1 unidade ✎" / Select 1-9 quando editando -->
<div class="aed-svc-card__qty">
<template v-if="item._editingQty">
<Select
v-model="item.quantity"
:options="[1,2,3,4,5,6,7,8,9]"
size="small"
class="aed-svc-card__qty-select"
@update:modelValue="(v) => { item._editingQty = false; onItemChange(item); }"
/>
</template>
<button
v-else
type="button"
class="aed-svc-card__qty-btn"
v-tooltip.top="'Alterar quantidade'"
@click="item._editingQty = true"
>
<span>{{ item.quantity || 1 }} {{ (item.quantity || 1) === 1 ? 'unidade' : 'unidades' }}</span>
<i class="pi pi-pencil" />
</button>
</div>
</div>
</div>
</div>
</template>
<!-- CONVÊNIO: plano + procedimento + guia + valor -->
<template v-if="billingType === 'convenio'">
<InputGroup class="aed-pay-inputgroup mb-2">
<Select
v-model="form.insurance_plan_id"
:options="activePlans"
optionLabel="name"
optionValue="id"
placeholder="Selecionar convênio..."
showClear
class="flex-1"
size="small"
/>
<Button icon="pi pi-plus" v-tooltip.top="'Cadastrar novo convênio'" severity="secondary" size="small" @click="openInsuranceQuickCreate" />
<Button icon="pi pi-question-circle" v-tooltip.top="'Como funciona'" severity="secondary" outlined size="small" @click="(e) => convenioHelpRef?.toggle(e)" />
</InputGroup>
<Popover ref="convenioHelpRef">
<div class="aed-help-pop">
<div class="aed-help-pop__title"><i class="pi pi-info-circle" /> Como configurar o convênio</div>
<ul class="aed-help-pop__list">
<li><b>Selecione o convênio</b> do paciente no select.</li>
<li>Depois escolha o <b>procedimento</b> coberto e preencha <b> da guia</b> e <b>valor</b>.</li>
<li>Use o botão <i class="pi pi-plus" /> pra <b>cadastrar</b> um convênio novo na hora.</li>
</ul>
</div>
</Popover>
<template v-if="hasInsurance">
<template v-if="planServices.length > 0">
<label class="text-xs text-color-secondary mb-1 block">Procedimento</label>
<Select v-model="selectedPlanService" :options="planServices" optionLabel="name" optionValue="id" placeholder="Selecionar procedimento..." showClear class="w-full mb-2" size="small" @update:modelValue="onProcedureSelect" />
</template>
<!-- Cadastrar procedimento inline sempre visível pra
convênio selecionado. Quando 0 procedimentos: chamada
pra cadastrar o primeiro (caso bloqueante). Quando
tem 1+: oferta extra pra adicionar mais sem sair
do dialog. Abre InsurancePlanServiceQuickCreateDialog. -->
<div class="flex items-center justify-between gap-2 mb-2 p-2 rounded-lg bg-surface-100">
<span class="text-xs text-color-secondary">
<template v-if="planServices.length === 0">
Este convênio ainda não tem procedimentos cadastrados.
</template>
<template v-else>
Se quiser adicionar mais procedimentos a este convênio:
</template>
</span>
<Button label="Cadastrar" icon="pi pi-plus" size="small" severity="secondary" outlined class="rounded-full shrink-0 text-xs h-7" @click="openPlanServiceQuickCreate" />
</div>
<template v-if="selectedPlanService != null || planServices.length === 0">
<div class="flex gap-2">
<div class="flex-1">
<label class="text-xs text-color-secondary mb-1 block"> da Guia <span class="opacity-70">(opcional)</span></label>
<InputText v-model="form.insurance_guide_number" placeholder="Ex: 123456789" class="w-full" size="small" />
</div>
<div class="flex-1">
<label class="text-xs text-color-secondary mb-1 block">Valor (R$)</label>
<InputNumber v-model="form.insurance_value" mode="currency" currency="BRL" locale="pt-BR" :readonly="planServices.length > 0 && selectedPlanService != null" class="w-full" size="small" />
</div>
</div>
</template>
</template>
</template>
</div>
<template #footer>
<div class="aed-svc-footer">
<div v-if="billingType === 'particular' && commitmentItems.length" class="aed-svc-footer__total ml-auto">
<span class="text-xs text-color-secondary">Valor desta sessão:</span>
<span class="aed-svc-list__total-value">{{ fmtBRL(totalFromItems) }}</span>
</div>
<Button label="Concluir" icon="pi pi-check" @click="serviceDialogOpen = false" />
</div>
</template>
</Dialog>
<!-- -->
<!-- Frequência Dialog (config detalhada da recorrência) -->
<!-- Substituiu a UI inline antiga do card Frequência. -->
<!-- -->
<Dialog
v-model:visible="freqDialogOpen"
modal
:draggable="false"
:style="{ width: '560px', maxWidth: '96vw' }"
:breakpoints="{ '640px': '98vw' }"
>
<template #header>
<div class="aed-svc-dlg-head">
<div class="aed-svc-dlg-head__icon">
<i class="pi pi-refresh" />
</div>
<div class="aed-svc-dlg-head__text">
<div class="aed-svc-dlg-head__title">Frequência</div>
<div class="aed-svc-dlg-head__sub">Repetição da sessão em datas futuras</div>
</div>
</div>
</template>
<div class="pt-3 aed-freq-dlg">
<Message v-if="form.paciente_id && !agendaPerms.canCreateRecurrence" severity="warn" class="mb-3" :closable="false">
<i class="pi pi-ban mr-1" />
<b>{{ form.paciente_status === 'Arquivado' ? 'Paciente arquivado.' : 'Paciente inativo.' }}</b>
Criação de recorrências está bloqueada.
</Message>
<!-- Card: Tipo de frequência -->
<div class="aed-freq-section">
<div class="aed-freq-section__label">Tipo</div>
<div class="aed-freq-section__body">
<div class="flex gap-1.5 flex-wrap">
<button
v-for="f in freqOpcoes"
:key="f.value"
class="freq-tab"
:class="{
'freq-tab--active': recorrenciaType === f.value,
'opacity-40 cursor-not-allowed': f.value !== 'avulsa' && !agendaPerms.canCreateRecurrence
}"
:disabled="f.value !== 'avulsa' && !agendaPerms.canCreateRecurrence"
@click="!agendaPerms.canCreateRecurrence && f.value !== 'avulsa' ? null : (recorrenciaType = f.value)"
>
{{ f.label }}
</button>
</div>
<!-- Preview semanal/quinzenal dentro do card de Tipo -->
<div v-if="recorrenciaType === 'semanal' || recorrenciaType === 'quinzenal'" class="aed-freq-preview">
<i class="pi pi-refresh" />
<span>
{{ recorrenciaType === 'quinzenal' ? 'A cada 2 semanas, toda' : 'Toda' }}
{{ nomeDiaSemana(diaSemanaRecorrencia) }}, às {{ form.startTime || '—' }}
</span>
</div>
</div>
</div>
<!-- Card: Dias da semana ( em diasEspecificos) -->
<div v-if="recorrenciaType === 'diasEspecificos'" class="aed-freq-section">
<div class="aed-freq-section__label">Dias da semana</div>
<div class="aed-freq-section__body">
<div class="dias-semana-grid">
<button v-for="d in diasSemanaOpcoes" :key="d.value" class="dia-chip" :class="{ 'dia-chip--active': diasSelecionados.includes(d.value) }" @click="toggleDiaSelecionado(d.value)">{{ d.short }}</button>
</div>
</div>
</div>
<!-- Card: Quantidade de sessões ( quando não avulsa) -->
<div v-if="recorrenciaType !== 'avulsa'" class="aed-freq-section">
<div class="aed-freq-section__label">Quantidade de sessões</div>
<div class="aed-freq-section__body">
<div class="flex gap-1.5 flex-wrap">
<button
v-for="opt in qtdSessoesOpcoes"
:key="opt.value"
class="freq-tab"
:class="{ 'freq-tab--active': qtdSessoesMode === opt.value, 'freq-tab--two-line': !!qtdSessoesTopLabel(opt.value) }"
@click="qtdSessoesMode = opt.value"
>
<span v-if="qtdSessoesTopLabel(opt.value)" class="freq-tab__top">{{ qtdSessoesTopLabel(opt.value) }}</span>
<span class="freq-tab__bottom">{{ opt.label }}</span>
</button>
</div>
<div v-if="qtdSessoesMode === 'personalizar'" class="personalizar-box mt-2">
<InputNumber v-model="qtdSessoesCustom" :min="1" :max="200" showButtons buttonLayout="horizontal" fluid>
<template #decrementbuttonicon><i class="pi pi-minus" /></template>
<template #incrementbuttonicon><i class="pi pi-plus" /></template>
</InputNumber>
</div>
</div>
</div>
<!-- Card: Próximas ocorrências -->
<div v-if="recorrenciaType !== 'avulsa' && ocorrenciasComConflito.length" class="aed-freq-section">
<div class="aed-freq-section__label">
Próximas ocorrências
<span v-if="totalConflitos > 0" class="aed-freq-section__warn">
<i class="pi pi-exclamation-triangle" />
{{ totalConflitos }} com conflito
</span>
</div>
<div class="aed-freq-section__body">
<div class="ocorrencias-scroll">
<template v-for="(o, i) in ocorrenciasComConflito" :key="i">
<div v-if="freqShouldShowMonthSep(i)" class="aed-freq-month-sep">
<span>{{ freqMonthLabel(o.date) }}</span>
</div>
<div
class="ocorrencia-item"
:class="{
'ocorrencia-item--fora': o.conflict?.type === 'feriado' || o.conflict?.type === 'bloqueado',
'ocorrencia-item--warn': o.conflict?.type === 'folga' || o.conflict?.type === 'pausa'
}"
>
<i
class="pi"
:class="{
'pi-circle-fill': !o.conflict,
'pi-exclamation-triangle': o.conflict?.type === 'folga' || o.conflict?.type === 'pausa',
'pi-ban': o.conflict?.type === 'feriado' || o.conflict?.type === 'bloqueado'
}"
style="font-size: 9px"
/>
<span class="text-xs">{{ fmtDateBRLong(o.date) }}</span>
<span class="aed-freq-rel">{{ freqRelativeLabel(o.date) }}</span>
<span v-if="o.conflict" class="ocorrencia-conflict-label">{{ o.conflict.label }}</span>
</div>
</template>
</div>
<Message v-if="totalConflitos > 0" severity="warn" class="mt-2" :closable="false">
<span class="text-xs">{{ totalConflitos }} sessão(ões) com conflito serão marcadas automaticamente para ajuste.</span>
</Message>
</div>
</div>
</div>
<template #footer>
<div class="aed-svc-footer">
<div v-if="recorrenciaType !== 'avulsa' && totalOcorrencias" class="aed-svc-footer__total ml-auto">
<span class="text-xs text-color-secondary">Sessões previstas:</span>
<span class="aed-svc-list__total-value">{{ totalOcorrencias }}</span>
</div>
<Button label="Concluir" icon="pi pi-check" @click="freqDialogOpen = false" />
</div>
</template>
</Dialog>
</Dialog>
<!-- RESUMO flutuante (desktop only mobile usa o card inline no fim do form)
Teleporta pra body pra escapar do containing block do Dialog (transform
centering quebraria position:fixed). Hidden via CSS em viewports
<960px (la o card inline aparece). -->
<Teleport to="body">
<Transition name="resumo-fade">
<div v-if="visible && step === 2 && !anyChildDialogOpen" class="aed-resumo-wrap" :style="{ top: resumoStyle.top, zIndex: 100000 }">
<aside class="side-card agenda-resumo agenda-resumo--floating" :style="{ maxHeight: resumoStyle.maxHeight }">
<div class="side-card__title">Resumo</div>
<div class="flex items-center gap-2 mb-2">
<span v-if="selectedCommitment?.bg_color" class="commit-badge" :style="{ background: `#${selectedCommitment.bg_color}20`, color: `#${selectedCommitment.bg_color}`, borderColor: `#${selectedCommitment.bg_color}40` }">{{
selectedCommitmentName
}}</span>
<Tag v-else :value="selectedCommitmentName" severity="info" />
<Tag v-if="isSessionEvent" :value="labelStatusSessao(form.status)" :severity="statusSeverity(form.status)" :class="statusExtraClass(form.status)" />
</div>
<div class="summary-row">
<i class="pi pi-user summary-icon" />
<span class="truncate">{{ form.paciente_nome || (isSessionEvent ? 'Sem paciente' : '—') }}</span>
</div>
<div class="summary-row">
<i class="pi pi-calendar summary-icon" />
<span class="truncate">{{ form.dia ? fmtDateBR(form.dia) : '—' }}</span>
</div>
<div class="summary-row">
<i class="pi pi-clock summary-icon" />
<span>{{ form.startTime || '—' }} {{ fimDateTime ? fmtTime(fimDateTime) : '—' }}</span>
</div>
<div v-if="paymentSummary" class="summary-row aed-pay-summary-row" :class="paymentSummary.cls">
<i class="pi summary-icon" :class="paymentSummary.icon" />
<span class="truncate">{{ paymentSummary.label }}</span>
</div>
<div class="summary-row">
<i class="pi pi-map-marker summary-icon" />
<span><span class="capitalize">{{ form.modalidade || '—' }}</span><span v-if="isPacote"> · Pacote</span></span>
</div>
<div v-if="isSessionEvent" class="summary-row">
<i class="pi pi-wallet summary-icon" />
<span>{{ walletLabel }}</span>
</div>
<div v-if="isSessionEvent && pacoteTotal != null" class="summary-row aed-summary-row--calc">
<i class="pi pi-calculator summary-icon" />
<span>{{ freqValorConta }}</span>
</div>
<!-- Message de conflito de horario (so desktop mobile usa a inline embaixo do time-hero) -->
<Message v-if="timeConflict" severity="warn" class="time-conflict-msg time-conflict-msg--floating" :closable="false">
<i class="pi pi-exclamation-triangle mr-1" />
{{ timeConflict }}
</Message>
</aside>
<!-- Message de jornada FORA do card resumo (so desktop mobile usa
a inline no topo do form). Dentro do wrapper, fica logo abaixo
do aside, com gap controlado pelo flex column do wrapper. -->
<Message v-if="jornadaDialog" :severity="jornadaDialog.isOff ? 'warn' : 'info'" class="aed-msg-jornada aed-msg-jornada--floating" :closable="false">
<i :class="jornadaDialog.isOff ? 'pi pi-moon mr-1' : 'pi pi-clock mr-1'" />
{{ jornadaDialog.text }}
</Message>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.agenda-event-composer :deep(.p-dialog-content) {
padding: 0.75rem;
}
/* ── Step transition: width do dialog + slide-fade do conteudo ──
Step 1 (escolha de tipo) abre em 420px; Step 2 (formulario)
expande pra 1000px. Transition cubic-bezier mais "easy-out"
pra sensacao de elasticidade na expansao. */
.agenda-event-composer {
transition: width 320ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* Conteudo do step: fade + slide horizontal sutil. mode=out-in
garante que step 1 sai antes de step 2 entrar (sem overlap). */
.step-fade-enter-active,
.step-fade-leave-active {
transition: opacity 200ms ease, transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
}
.step-fade-enter-from {
opacity: 0;
transform: translateX(16px);
}
.step-fade-leave-to {
opacity: 0;
transform: translateX(-12px);
}
/* ═══════════════════════════════════════════════════════════════════
Polish visual disruptivo (V2) — overrides aplicados sobre o template
existente sem mexer na lógica. Foco:
1. Header do dialog com gradiente sutil + tipografia hierárquica
2. Botões de quick-create destacados (mais "convidativos" no fluxo)
3. Footer sticky com Salvar primário + sombra superior leve
4. Dialog mask sem blur (animação/efeito nativo PrimeVue)
5. Borda dos cards laterais com cor de seção (paciente/quando/quê)
═══════════════════════════════════════════════════════════════════ */
/* Backdrop do Dialog do composer (sem blur — animacao/efeito nativo
do PrimeVue). */
.agenda-event-composer :deep(.p-dialog-mask) {
background: rgba(15, 23, 42, 0.32);
}
/* "trocar tipo" — badge clicavel inline com o titulo (padrao .commit-badge
+ bg/border neutros + hover primary) */
.commit-badge.header-swap-btn {
gap: 0.3rem;
background: var(--surface-100, color-mix(in srgb, var(--surface-card) 92%, var(--text-color-secondary) 8%));
color: var(--text-color-secondary);
border-color: var(--surface-border);
font-weight: 600;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
}
.commit-badge.header-swap-btn .pi {
font-size: 0.62rem;
}
.commit-badge.header-swap-btn:hover {
background: color-mix(in srgb, var(--p-primary-color) 12%, var(--surface-card));
color: var(--p-primary-color);
border-color: color-mix(in srgb, var(--p-primary-color) 35%, var(--surface-border));
}
/* Header do Dialog — gradient sutil + título com peso firme */
.agenda-event-composer :deep(.p-dialog-header) {
background:
radial-gradient(ellipse 600px 80px at 0% 0%, color-mix(in srgb, var(--p-primary-color) 8%, transparent), transparent 70%),
var(--surface-card, #fff);
border-bottom: 1px solid var(--surface-border);
}
.agenda-event-composer :deep(.p-dialog-title) {
font-weight: 700;
letter-spacing: -0.01em;
}
/* Footer sticky com sombra leve no topo, Salvar primary destacado */
.agenda-event-composer :deep(.p-dialog-footer) {
border-top: 1px solid var(--surface-border);
background: var(--surface-card, #fff);
box-shadow: 0 -4px 18px -10px color-mix(in srgb, var(--p-primary-color) 25%, transparent);
}
/* Select Gratuito/Particular/Convenio no header do card Sessão/Honorários.
Altura 28px (1.75rem) — mesma do botão "Ajustar horário" (h-7) e do
toggle "Presencial/Online" no Paciente. Mantém consistência visual
entre os 3 cards principais. */
.aed-pay-mod-select.p-select,
.aed-pay-mod-select :deep(.p-select) {
height: 1.75rem;
min-height: 1.75rem;
}
.aed-pay-mod-select :deep(.p-select-label) {
padding: 0 0.7rem !important;
font-size: 0.78rem;
line-height: 1.75rem;
text-transform: none;
letter-spacing: 0;
font-weight: 500;
}
.aed-pay-mod-select :deep(.p-select-dropdown) {
width: 1.75rem;
}
/* Body do card Sessão/Honorários SEM padding — segue o pattern dos
cards Paciente e Data e Horário, onde o padding mora nos filhos
(.patient-hero__empty/__selected, .time-hero) pra os elementos
ocuparem borda a borda. */
.aed-pay-body {
padding: 0 !important;
}
/* Resumo compacto do billing quando configurado — padding proprio
(igual .patient-hero__selected). */
.aed-pay-summary {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.9rem;
}
.aed-pay-summary__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.aed-pay-summary__count {
font-size: 0.78rem;
color: var(--text-color-secondary);
}
.aed-pay-summary__value {
font-size: 1.05rem;
font-weight: 700;
color: var(--p-primary-color);
}
.aed-freq-conta {
font-size: 0.8rem;
color: var(--text-color-secondary);
font-variant-numeric: tabular-nums;
margin-top: 0.15rem;
}
.aed-summary-row--calc {
opacity: 0.75;
font-size: 0.78rem;
}
.aed-pay-summary__discount {
color: var(--p-red-500, #ef4444);
font-weight: 500;
}
/* Aviso "Sessão gratuita" — leve, info, sem call-to-action. Padding
próprio (body do card é zero), bordas removidas — fica encostado
no padrão dos outros cards (.patient-hero__selected). */
.aed-pay-gratuito {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.9rem;
font-size: 0.85rem;
color: var(--text-color);
}
.aed-pay-gratuito i {
color: var(--p-primary-color);
font-size: 0.95rem;
}
/* Hint contextual abaixo do card Sessão / Honorários. Visual leve
pra não competir com o card; cor neutra (não é warning nem error,
apenas esclarecimento). */
.aed-billing-hint {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.78rem;
color: var(--text-color-secondary);
background: color-mix(in srgb, var(--surface-border), transparent 60%);
border-left: 3px solid var(--p-primary-color);
border-radius: 0 6px 6px 0;
line-height: 1.4;
}
.aed-billing-hint > i {
color: var(--p-primary-color);
font-size: 0.85rem;
margin-top: 1px;
flex-shrink: 0;
}
.aed-billing-hint b {
color: var(--text-color);
}
/* Padding interno do card "Campos Extras (compromisso)" — mesmo
tratamento do aed-pay-body. Sem isso os inputs ficam grudados nas
bordas. */
.aed-extras-body {
padding: 0.85rem 0.85rem 0.65rem !important;
}
/* Padding interno do card "Frequência" — mesmo tratamento dos outros
bodies. Override do .field-card__body { padding: 0 } padrao. */
.aed-freq-body {
padding: 0.85rem !important;
}
/* InputGroup do Particular/Convenio — botoes "+" e "?" grudam
no select sem o gap separado de antes. Os addons herdam altura
automaticamente do select via PrimeVue. */
.aed-pay-inputgroup :deep(.p-button) {
flex-shrink: 0;
}
.aed-pay-inputgroup :deep(.p-button:last-child) {
/* botao "?" outlined no canto direito — visual mais sutil
pra nao competir com o "+" primario. */
color: var(--text-color-secondary);
}
/* Popover de ajuda — bullets compactos, max-width pra nao crescer
demais em telas grandes. padding interno (4px) pra evitar conteudo
grudado nas bordas do balao do Popover. */
.aed-help-pop {
max-width: 320px;
font-size: 0.8rem;
line-height: 1.45;
padding: 10px;
}
.aed-help-pop__link {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--surface-border);
display: flex;
justify-content: flex-end;
}
.aed-help-pop__title {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
color: var(--p-primary-color);
margin-bottom: 0.5rem;
font-size: 0.85rem;
}
.aed-help-pop__list {
margin: 0;
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
color: var(--text-color);
}
.aed-help-pop__list li {
list-style: disc;
}
.aed-help-pop__list .pi {
font-size: 0.75rem;
color: var(--p-primary-color);
margin: 0 0.1rem;
}
/* Expand animation pro body do card Pagamento quando troca de
Gratuito pra Particular/Convenio. max-height generoso (500px)
cobre o caso convenio com todos os passos abertos sem cortar. */
.aed-pay-expand-enter-active,
.aed-pay-expand-leave-active {
transition: opacity 180ms ease, max-height 220ms ease, transform 180ms ease, padding 180ms ease;
overflow: hidden;
}
.aed-pay-expand-enter-from,
.aed-pay-expand-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0 !important;
padding-bottom: 0 !important;
transform: translateY(-4px);
}
.aed-pay-expand-enter-to,
.aed-pay-expand-leave-from {
opacity: 1;
max-height: 500px;
transform: translateY(0);
}
/* Botão "+" de cadastro rápido (serviço/convênio) — outlined sutil que
convida pra ação sem competir com o Select ao lado. */
.aed-quick-btn {
flex-shrink: 0;
border-radius: 10px !important;
border-style: dashed !important;
border-color: color-mix(in srgb, var(--p-primary-color) 40%, var(--surface-border)) !important;
color: var(--p-primary-color) !important;
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
}
.aed-quick-btn:hover {
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent) !important;
border-style: solid !important;
transform: translateY(-1px);
}
/* "side-card" (paciente/financeiro/quando) — borda esquerda colorida pra
diferenciar visualmente as seções. Cores diferentes ajudam a localizar
info rapidamente sem precisar ler o título. */
.agenda-event-composer :deep(.side-card) {
position: relative;
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 12px;
padding: 14px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.agenda-event-composer :deep(.side-card)::before {
content: '';
position: absolute;
top: 12px;
bottom: 12px;
left: 0;
width: 3px;
border-radius: 0 3px 3px 0;
background: color-mix(in srgb, var(--p-primary-color) 60%, transparent);
opacity: 0.7;
}
.agenda-event-composer :deep(.side-card):hover {
border-color: color-mix(in srgb, var(--p-primary-color) 25%, var(--surface-border));
box-shadow: 0 2px 12px -4px color-mix(in srgb, var(--p-primary-color) 18%, transparent);
}
.agenda-event-composer :deep(.side-card):hover::before {
opacity: 1;
}
/* ── tag: remarcado (roxo — sem severity nativo no PrimeVue) ─ */
:deep(.tag-remarcado) {
background: #a855f7 !important;
color: #fff !important;
}
/* ── header dot ─────────────────────────────────── */
.header-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--p-primary-500, #6366f1);
flex-shrink: 0;
}
/* ── step 1: commitment list (rows finas) ───────── */
.commitment-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.commitment-row {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
text-align: left;
padding: 0.5rem 0.75rem;
border: 1px solid var(--surface-border);
border-radius: 8px;
background: var(--surface-card);
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease;
}
.commitment-row:hover {
background: color-mix(in srgb, var(--card-color, var(--p-primary-500)) 6%, var(--surface-card));
border-color: color-mix(in srgb, var(--card-color, var(--p-primary-500)) 45%, var(--surface-border));
}
.commitment-row:hover .commitment-row__chevron {
transform: translateX(2px);
opacity: 0.9;
}
.commitment-row__icon {
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 6px;
background: color-mix(in srgb, var(--p-primary-500) 12%, transparent);
color: var(--p-primary-500);
font-size: 0.8rem;
flex-shrink: 0;
}
.commitment-row__body {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex: 1;
}
.commitment-row__name {
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
line-height: 1.2;
}
.commitment-row__desc {
font-size: 0.75rem;
color: var(--text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
line-height: 1.2;
}
.commitment-row__chevron {
color: var(--text-color-secondary);
opacity: 0.5;
font-size: 0.75rem;
flex-shrink: 0;
transition: transform 120ms ease, opacity 120ms ease;
}
.commitment-row--blocked {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
border-color: color-mix(in srgb, var(--red-500) 35%, var(--surface-border));
background: color-mix(in srgb, var(--red-50, #fef2f2) 30%, var(--surface-card));
}
.commitment-row--blocked .commitment-row__chevron {
color: var(--red-500);
opacity: 0.8;
}
/* ── step 2: layout em coluna unica (todos os cards stacked) ──
Antes era grid 2-col em desktop, 1-col em mobile. Agora sempre
1-col (form + Escopo + Financeiro + Resumo, nessa ordem). No
desktop o Resumo migra pro painel flutuante (Teleport p/ body)
ancorado a direita do dialog — ver .agenda-resumo--floating. */
.composer-grid {
display: flex;
flex-direction: column;
gap: 0;
margin-top: 2px;
}
/* ── occurrence mode: layout enxuto pro 2º dialog empilhado ─── */
.composer-occurrence {
display: flex;
flex-direction: column;
gap: 0;
margin-top: 2px;
}
.aed-occ-data {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
}
.aed-occ-data__row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
min-height: 1.5rem;
}
.aed-occ-data__icon {
width: 1rem;
color: var(--text-color-secondary);
flex-shrink: 0;
text-align: center;
}
.aed-occ-data__label {
color: var(--text-color-secondary);
font-weight: 500;
min-width: 5.5rem;
flex-shrink: 0;
}
.aed-occ-data__value {
color: var(--text-color);
font-weight: 600;
min-width: 0;
}
/* ── preview antes/depois (occurrenceMode + scope > somente_este) ── */
.aed-preview-card {
border: 1.5px solid var(--surface-border);
border-radius: 6px;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
overflow: hidden;
}
.aed-preview-card__header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--surface-ground), transparent 30%);
border-bottom: 1px solid var(--surface-border);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-color-secondary);
}
.aed-preview-card__cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.aed-preview-col {
padding: 0.5rem;
min-width: 0;
}
.aed-preview-col + .aed-preview-col {
border-left: 1px solid var(--surface-border);
}
.aed-preview-col__title {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-color-secondary);
margin-bottom: 0.4rem;
padding-left: 0.25rem;
}
.aed-preview-col--after .aed-preview-col__title {
color: var(--p-primary-500);
}
.aed-preview-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.aed-preview-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.45rem;
border: 1px solid var(--surface-border);
border-radius: 4px;
background: var(--surface-card);
font-size: 0.72rem;
}
.aed-preview-row--past {
opacity: 0.55;
}
.aed-preview-row--changed {
border-color: color-mix(in srgb, var(--p-primary-500) 50%, var(--surface-border));
background: color-mix(in srgb, var(--p-primary-500) 6%, var(--surface-card));
}
.aed-preview-row__num {
font-weight: 700;
font-size: 0.7rem;
color: var(--text-color-secondary);
min-width: 1.2rem;
flex-shrink: 0;
}
.aed-preview-row__date {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.05;
min-width: 0;
}
.aed-preview-row__weekday {
font-size: 0.58rem;
font-weight: 700;
text-transform: uppercase;
color: var(--text-color-secondary);
}
.aed-preview-row__daynum {
font-size: 0.85rem;
font-weight: 700;
color: var(--text-color);
}
.aed-preview-row__month {
font-size: 0.55rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-color-secondary);
}
.aed-preview-row__time {
margin-left: auto;
font-size: 0.72rem;
font-weight: 600;
color: var(--text-color);
flex-shrink: 0;
}
/* ── SelectButton "Cobrança ao salvar" (Opção C1, 2026-05-13) ─── */
.aed-charge-mode-row {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0.5rem 0.75rem 0.75rem;
padding: 0.7rem 0.85rem;
border: 1px dashed var(--surface-border);
border-radius: 6px;
background: color-mix(in srgb, var(--p-primary-500) 3%, var(--surface-card));
}
.aed-charge-mode-label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color);
line-height: 1.3;
}
.aed-charge-mode-label i.pi-bolt {
color: var(--p-primary-500);
}
.aed-charge-mode-buttons {
align-self: flex-start;
}
/* Card "Cobrança" — fullwidth dedicado, 2026-05-14. Reusa pattern field-card. */
.aed-charge-card__body {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.85rem !important;
}
/* Mensagem de mudança de frequência → cobrança. Warning amarelo, persiste
até user fechar o dialog OU trocar de tipo de novo. Slide+fade in/out +
pulse de ~2.4s a cada troca (re-dispara via :key incrementado). */
.aed-charge-change-msg {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.6rem 0.8rem;
border-radius: 6px;
background: color-mix(in srgb, var(--amber-500, #f59e0b) 12%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--amber-500, #f59e0b) 50%, transparent);
font-size: 0.78rem;
color: var(--amber-800, #92400e);
line-height: 1.4;
animation: aed-charge-msg-pulse 0.8s ease-in-out 3;
}
@keyframes aed-charge-msg-pulse {
0%, 100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--amber-500, #f59e0b) 0%, transparent);
transform: scale(1);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--amber-500, #f59e0b) 22%, transparent);
transform: scale(1.012);
}
}
.aed-charge-change-msg i {
color: var(--amber-600, #d97706);
font-size: 0.95rem;
flex-shrink: 0;
margin-top: 1px;
}
.aed-charge-warn-enter-active,
.aed-charge-warn-leave-active {
transition: opacity 220ms ease, transform 220ms ease, max-height 220ms ease;
overflow: hidden;
max-height: 200px;
}
.aed-charge-warn-enter-from,
.aed-charge-warn-leave-to {
opacity: 0;
transform: translateY(-6px);
max-height: 0;
}
.aed-charge-mode-hint {
font-size: 0.8125rem;
color: var(--text-color-secondary);
line-height: 1.45;
}
.aed-payment-settle {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.2rem;
}
.aed-payment-settle__label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-color);
flex-shrink: 0;
}
.aed-payment-settle__select {
min-width: 16rem;
flex: 1;
}
/* ── paciente hero ──────────────────────────────── */
.patient-hero {
border: 1.5px solid var(--surface-border);
border-radius: 6px;
overflow: hidden;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
}
.patient-hero__label {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.6rem 0 0.9rem;
height: 40px;
min-height: 40px;
max-height: 40px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-color-secondary);
border-bottom: 1px solid var(--surface-border);
background: color-mix(in srgb, var(--surface-ground), transparent 30%);
}
/* Toggle Presencial/Online — substitui o label "Paciente" e o SelectButton
antigo em todas as resolucoes. Mesmo visual do botao "Ajustar horário":
pi-pencil + 1 palavra por vez (clicou alterna Presencial<->Online). */
.patient-hero__label-text {
display: none;
}
.patient-hero__modal-btn {
display: inline-flex;
}
/* Cancela o text-transform/letter-spacing herdados do .patient-hero__label
(que sao UPPERCASE pra "PACIENTE"). O botao precisa do estilo proprio. */
.patient-hero__modal-btn :deep(.p-button-label) {
text-transform: none;
letter-spacing: 0;
font-weight: 500;
}
/* Modalidade SelectButton inline no header do paciente — altura
bate com o botao "Cadastro Rapido" h-7 (1.75rem = 28px) */
.patient-hero__mod-select :deep(.p-togglebutton) {
height: 1.75rem !important;
min-height: 1.75rem;
padding: 0 0.7rem !important;
font-size: 0.7rem !important;
text-transform: none;
letter-spacing: 0;
font-weight: 600;
}
/* Botao "Cadastro completo" (pi-id-card) ao lado do SelectButton —
icone maior + cor primary, altura igual ao SelectButton (28px).
Classe vai direto no elemento .p-button da PrimeVue (sem :deep) */
.agenda-event-composer .patient-hero__cad-completo-btn {
height: 1.75rem !important;
width: 1.75rem !important;
padding: 0 !important;
color: var(--p-primary-color) !important;
}
.agenda-event-composer .patient-hero__cad-completo-btn :deep(.p-button-icon) {
font-size: 1.15rem !important;
}
.agenda-event-composer .patient-hero__cad-completo-btn:hover {
background: color-mix(in srgb, var(--p-primary-color) 12%, transparent) !important;
}
/* Time conflict Message — inline (mobile) hidden no desktop;
floating versao aparece dentro do Teleport do Resumo. */
@media (min-width: 1200px) {
.time-conflict-msg--inline {
display: none !important;
}
}
.time-conflict-msg--floating {
margin-top: 0.65rem;
}
/* Jornada Message — segue o mesmo padrao do timeConflict: inline no
topo do form em mobile (<1200px), e teleportada pra dentro do aside
do Resumo em desktop (>=1200px). */
@media (min-width: 1200px) {
.aed-msg-jornada--inline {
display: none !important;
}
}
.aed-msg-jornada--floating {
/* Margin-top removido — gap do .aed-resumo-wrap (flex column) ja faz
o espaçamento entre o aside (Resumo) e essa Message. */
margin-top: 0;
}
/* Card genérico para seções (data/horário, etc.) */
.field-card {
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
}
.field-card__header {
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
height: 40px;
min-height: 40px;
max-height: 40px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-color-secondary);
border-bottom: 1px solid var(--surface-border);
background: color-mix(in srgb, var(--surface-ground), transparent 30%);
cursor: pointer;
}
.field-card__body {
padding: 0;
}
.status-select-btn :deep(.p-selectbutton) {
display: flex;
width: 100%;
}
.status-select-btn :deep(.p-togglebutton) {
flex: 1;
justify-content: center;
border-radius: 0 !important;
border: none !important;
border-right: 1px solid var(--surface-border) !important;
padding: 0.65rem 0.5rem;
}
.status-select-btn :deep(.p-togglebutton:last-child) {
border-right: none !important;
}
.patient-hero__empty {
display: flex;
align-items: center;
gap: 0.85rem;
width: 100%;
padding: 0.9rem;
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.patient-hero__empty:hover {
background: color-mix(in srgb, var(--p-primary-500) 5%, transparent);
}
.patient-hero__empty-icon {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500) 12%, transparent);
color: var(--p-primary-500);
font-size: 1.1rem;
}
.patient-hero__selected {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.75rem 0.9rem;
}
.patient-avatar-bg :deep(.p-avatar) {
background: color-mix(in srgb, var(--p-primary-500) 18%, transparent);
color: var(--p-primary-500);
}
/* Mini links "Editar / Limpar" embaixo do nome do paciente —
substituem os botoes redondos do canto direito. Pequenos,
inline, com cores semanticas (primary p/ Editar, danger p/ Limpar). */
.patient-hero__link {
background: transparent;
border: 0;
padding: 0;
font-size: 0.95rem;
color: var(--p-primary-color);
cursor: pointer;
font-family: inherit;
text-decoration: none;
transition: color 0.12s;
}
.patient-hero__link:hover {
text-decoration: underline;
}
.patient-hero__link--danger {
color: var(--p-red-500, #ef4444);
}
.patient-hero__link-sep {
font-size: 0.95rem;
color: var(--text-color-secondary);
opacity: 0.5;
}
/* ── time hero ──────────────────────────────────── */
/* Layout em 2 linhas: linha 1 (Data • Duração), linha 2 (Início → Término). */
.time-hero {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.7rem 0.9rem;
cursor: pointer;
transition: background 0.12s;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
}
.time-hero:hover {
background: color-mix(in srgb, var(--p-primary-500) 4%, var(--surface-card));
}
/* Readonly em modo edição — sem cursor pointer nem hover. */
.time-hero.time-hero--readonly {
cursor: default;
}
.time-hero.time-hero--readonly:hover {
background: color-mix(in srgb, var(--surface-card), transparent 10%);
}
.time-hero__line {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.time-hero__date {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-color);
}
.time-hero__bullet {
color: var(--text-color-secondary);
opacity: 0.5;
}
.time-hero__duration {
font-size: 0.85rem;
color: var(--text-color-secondary);
}
.time-hero__time {
font-size: 1.05rem;
font-weight: 700;
color: var(--p-primary-color);
font-variant-numeric: tabular-nums;
}
.time-hero__arrow {
color: var(--text-color-secondary);
opacity: 0.6;
}
/* ── fields grid ────────────────────────────────── */
.fields-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem;
}
.fields-grid .col-span-full {
grid-column: 1 / -1;
}
@media (max-width: 640px) {
.fields-grid {
grid-template-columns: 1fr;
}
}
/* Row 50/50 para pares de cards (Paciente|Data+Horario,
Sessao/Honorarios|Frequencia). Mobile (<768) empilha 1 coluna.
Cancela mb-4 dos cards filhos pra a margem ser controlada
pelo proprio row. */
.aed-row-50 {
display: grid;
grid-template-columns: 1fr;
gap: 0.85rem;
/* margin-bottom removido em 2026-05-19 (user request) — o espaçamento
agora vem do mt do próximo .field-card.mb-4 (ver regra abaixo). */
}
@media (min-width: 768px) {
.aed-row-50 {
grid-template-columns: 1fr 1fr;
}
}
.aed-row-50 > .field-card,
.aed-row-50 > .patient-hero {
margin-bottom: 0 !important;
}
/* mt-4 em .field-card.mb-4 (user request 2026-05-19) — pra compensar
a remoção do mb do .aed-row-50 e dar respiro vertical entre os
cards do composer. Restrito aos wrappers do composer pra não
afetar field-cards de outros lugares. */
.composer-left .field-card.mb-4,
.composer-right .field-card.mb-4,
.composer-occurrence .field-card.mb-4 {
margin-top: 1rem;
}
/* ── side panel ─────────────────────────────────── */
.composer-right {
/* layout single-col agora — sticky removido (nao faz sentido) */
display: flex;
flex-direction: column;
gap: 0;
}
/* ── Resumo: mobile inline / desktop flutuante via Teleport ────
Threshold em 1200px pra garantir espaco real ao lado do Dialog
600px (600/2 + 14 gap + 280 card + 6 margem = ~600px de cada
lado do centro = viewport >= 1200). Abaixo disso o card cai
pro fim do form como inline, evitando overflow lateral. */
.agenda-resumo--mobile {
display: block;
}
.aed-resumo-wrap,
.agenda-resumo--floating {
display: none;
}
@media (min-width: 1200px) {
.agenda-resumo--mobile {
display: none;
}
/* Wrapper ancorado a direita do dialog. Hospeda o card de Resumo +
Message de jornada (que fica FORA do card, abaixo). position+top+left
saem daqui; aside agora é elemento normal no flow do wrapper.
top é injetado via :style reativo (resumoStyle.top), sincronizado
com o .p-dialog via ResizeObserver. */
.aed-resumo-wrap {
display: flex;
flex-direction: column;
gap: 0.55rem;
position: fixed;
left: calc(50% + 314px);
width: 280px;
}
.agenda-resumo--floating {
display: block;
/* max-height aplicado via :style reativo (resumoStyle.maxHeight) pra
que scroll interno acompanhe a altura do dialog. */
overflow-y: auto;
background: color-mix(in srgb, var(--surface-card) 88%, transparent);
border: 1px solid var(--surface-border);
border-radius: 14px;
padding: 14px 16px;
box-shadow: 0 16px 48px -8px rgba(0, 0, 0, 0.28), 0 4px 12px -2px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
}
/* Accent stripe lateral (igual aos side-cards do dialog — pra
sensacao de "mesmo familiar visual"). Usa a cor do commitment
quando disponivel via CSS var. */
.agenda-resumo--floating::before {
content: '';
position: absolute;
top: 12px;
bottom: 12px;
left: 0;
width: 3px;
border-radius: 0 3px 3px 0;
background: color-mix(in srgb, var(--p-primary-color) 60%, transparent);
opacity: 0.7;
}
}
/* Espaco extra entre cards no painel flutuante (summary-rows
compactos, mas o badge no topo precisa respirar) */
.agenda-resumo--floating {
position: relative; /* host pro ::before quando ativado */
}
/* Fade-up no enter (entra subindo + fade in) / fade-down no leave
(sai descendo + fade out).
Enter tem delay de 360ms — espera o dialog terminar a sua propria
transition (step-fade leave 200ms + width 320ms = ~360ms total)
antes do resumo aparecer. Sem isso, o resumo dispara junto com o
click no commitment, antes do dialog mudar de tamanho/conteudo.
Leave roda imediato (sem delay) — quando step volta pra 1 ou um
sub-dialog abre, o resumo precisa sumir RAPIDO pra dar lugar. */
.resumo-fade-enter-active {
transition: opacity 220ms cubic-bezier(0.16, 1, 0.3, 1) 360ms,
transform 220ms cubic-bezier(0.16, 1, 0.3, 1) 360ms;
}
.resumo-fade-leave-active {
transition: opacity 160ms ease,
transform 160ms ease;
}
.resumo-fade-enter-from {
opacity: 0;
transform: translateY(12px);
}
.resumo-fade-leave-to {
opacity: 0;
transform: translateY(12px);
}
.side-card {
border: 1px solid var(--surface-border);
border-radius: 6px;
padding: 0.9rem 1rem;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
}
.side-card__title {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-color-secondary);
margin-bottom: 0.65rem;
}
/* resumo rows */
.summary-row {
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 0.82rem;
color: var(--text-color);
padding: 0.3rem 0;
border-bottom: 1px solid color-mix(in srgb, var(--surface-border), transparent 40%);
}
.summary-row:last-child {
border-bottom: none;
}
.summary-icon {
font-size: 0.8rem;
color: var(--text-color-secondary);
opacity: 0.7;
width: 1rem;
flex-shrink: 0;
}
/* Linha "Cobrança" do Resumo — espelha as 3 cores da agenda
(verde pago / amber pendente / vermelho atrasada / neutro none). */
.aed-pay-summary-row .summary-icon {
opacity: 1;
font-weight: 600;
}
.aed-pay-summary-row--paid {
color: #047857; /* emerald-700 */
}
.aed-pay-summary-row--paid .summary-icon {
color: #10b981; /* emerald-500 */
}
.aed-pay-summary-row--pending {
color: #b45309; /* amber-700 */
}
.aed-pay-summary-row--pending .summary-icon {
color: #f59e0b; /* amber-500 */
}
.aed-pay-summary-row--overdue {
color: #b91c1c; /* red-700 */
}
.aed-pay-summary-row--overdue .summary-icon {
color: #ef4444; /* red-500 */
}
.aed-pay-summary-row--none {
color: var(--text-color-secondary);
}
.aed-pay-summary-row--none .summary-icon {
color: var(--text-color-secondary);
opacity: 0.85;
}
/* commit badge */
.commit-badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.65rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
border: 1px solid transparent;
white-space: nowrap;
}
/* ── serie banner ───────────────────────────────── */
.serie-banner {
border-radius: 6px;
padding: 0.75rem 0.9rem;
background: color-mix(in srgb, var(--blue-500, #3b82f6) 8%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--blue-400, #60a5fa) 30%, transparent);
}
.serie-banner__head {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
font-weight: 700;
color: var(--blue-600, #2563eb);
}
.serie-banner__detail {
font-size: 0.78rem;
color: var(--text-color-secondary);
margin-top: 0.25rem;
}
/* escopo de edição */
.scope-option {
display: flex;
align-items: center;
gap: 0.55rem;
cursor: pointer;
}
/* ── freq tabs ──────────────────────────────────── */
.freq-tab {
padding: 0.6rem 1.15rem;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 600;
border: 1px solid var(--surface-border);
background: var(--surface-card);
color: var(--text-color);
cursor: pointer;
transition:
background 0.12s,
color 0.12s,
border-color 0.12s;
}
.freq-tab:not(.freq-tab--active):hover {
border-color: var(--p-primary-color);
color: var(--p-primary-color);
background: color-mix(in srgb, var(--p-primary-color) 6%, var(--surface-card));
}
.freq-tab--active {
background: var(--p-primary-500, #6366f1);
border-color: var(--p-primary-500, #6366f1);
color: #fff;
}
.freq-tab--active:hover {
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 88%, black);
}
/* Variante 2 linhas — semanal/quinzenal exibem mes equivalente em cima. */
.freq-tab--two-line {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
line-height: 1.15;
padding: 0.55rem 1.1rem;
}
.freq-tab__top {
font-size: 0.95rem;
font-weight: 700;
}
.freq-tab__bottom {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.8;
}
/* ── recorrência preview ────────────────────────── */
.recorrencia-preview {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
background: color-mix(in srgb, var(--p-primary-500) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--p-primary-400) 25%, transparent);
}
/* ocorrências */
.ocorrencias-preview {
border-top: 1px solid var(--surface-border);
padding-top: 0.65rem;
margin-top: 0.65rem;
}
.ocorrencias-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.ocorrencias-aviso {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
font-weight: 600;
color: var(--orange-600, #ea580c);
}
.ocorrencias-scroll {
display: flex;
flex-direction: column;
gap: 0.2rem;
max-height: 300px;
overflow-y: auto;
padding-right: 0.2rem;
}
.ocorrencia-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.3rem;
border-radius: 0.4rem;
color: var(--text-color);
transition: background 0.1s;
}
.ocorrencia-item i {
color: var(--p-primary-400);
flex-shrink: 0;
}
.ocorrencia-item--fora {
background: color-mix(in srgb, var(--red-500) 8%, transparent);
color: var(--red-600, #dc2626);
}
.ocorrencia-item--fora i {
color: var(--red-400, #f87171);
}
.ocorrencia-fora-label {
margin-left: auto;
font-size: 0.65rem;
font-weight: 700;
color: var(--red-500, #ef4444);
white-space: nowrap;
flex-shrink: 0;
}
.ocorrencia-item--warn {
background: color-mix(in srgb, var(--orange-500) 8%, transparent);
color: var(--orange-700, #c2410c);
}
.ocorrencia-item--warn i {
color: var(--orange-400, #fb923c);
}
.ocorrencia-conflict-label {
margin-left: auto;
font-size: 0.65rem;
font-weight: 700;
color: inherit;
white-space: nowrap;
flex-shrink: 0;
opacity: 0.8;
}
/* ── time picker ────────────────────────────────── */
.time-controls {
display: flex;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
}
.time-picker-wrap {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 120px;
flex: 1;
}
.time-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-secondary);
}
.time-fim {
display: flex;
flex-direction: column;
gap: 0.35rem;
justify-content: flex-end;
padding-bottom: 0.45rem;
}
.time-fim-value {
font-size: 1rem;
font-weight: 700;
color: var(--text-color);
white-space: nowrap;
}
/* Período filter chips */
.periodo-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
border: 1px solid var(--surface-border);
background: var(--surface-card);
color: var(--text-color-secondary);
cursor: pointer;
transition:
background 0.12s,
color 0.12s;
}
.periodo-chip--active {
background: var(--primary-500, #6366f1);
color: #fff;
border-color: var(--primary-500, #6366f1);
}
/* Slots grid */
.slots-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 140px;
overflow-y: auto;
}
.slot-pill {
padding: 4px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
border: 1px solid var(--surface-border);
background: var(--surface-card);
color: var(--text-color);
cursor: pointer;
transition:
background 0.1s,
color 0.1s;
letter-spacing: -0.01em;
}
.slot-pill:hover:not(.slot-pill--busy) {
background: var(--primary-100, #e0e7ff);
border-color: var(--primary-400, #818cf8);
color: var(--primary-700, #3730a3);
}
.slot-pill--current {
background: var(--primary-500, #6366f1) !important;
color: #fff !important;
border-color: var(--primary-500, #6366f1) !important;
}
.slot-pill--busy {
opacity: 0.4;
cursor: not-allowed;
text-decoration: line-through;
}
/* Online slots card */
/* Info bar online (substitui o card separado) */
.online-info-bar {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 12px;
border-radius: 0.75rem;
background: color-mix(in srgb, var(--blue-50, #eff6ff) 70%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--blue-300, #93c5fd) 35%, transparent);
font-size: 0.78rem;
color: var(--blue-800, #1e40af);
}
.online-info-bar .pi-video {
color: var(--blue-500, #3b82f6);
margin-top: 1px;
}
.online-info-bar__hint {
opacity: 0.7;
}
/* Dot inline para o texto de dica */
.online-dot-inline {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--blue-500, #3b82f6);
vertical-align: middle;
}
/* Pill marcado como configurado para online */
.slot-pill--online-cfg {
border-color: color-mix(in srgb, var(--blue-400, #60a5fa) 60%, transparent) !important;
background: color-mix(in srgb, var(--blue-50, #eff6ff) 80%, var(--surface-card)) !important;
color: var(--blue-700, #1d4ed8) !important;
}
.slot-pill--online-cfg:hover:not(.slot-pill--busy) {
background: var(--blue-100, #dbeafe) !important;
}
/* Combo selected + online-cfg: precedência pro selected (mais especificidade
que .slot-pill--online-cfg sozinha). Sem isso, o azul-claro do online
ganhava sobre o primary do selected. 2026-05-13. */
.slot-pill--current.slot-pill--online-cfg {
background: var(--primary-500, #6366f1) !important;
color: #fff !important;
border-color: var(--primary-500, #6366f1) !important;
}
/* Dot azul dentro do pill quando é slot online configurado */
.slot-online-dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--blue-500, #3b82f6);
margin-left: 3px;
vertical-align: middle;
flex-shrink: 0;
}
/* ── rec startdate row ──────────────────────────── */
.rec-startdate-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.45rem 0.65rem;
border-radius: 6px;
background: color-mix(in srgb, var(--surface-ground), transparent 30%);
border: 1px solid var(--surface-border);
}
/* ── freq chips (frequência principal) ──────────── */
.freq-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.freq-chip {
padding: 0.3rem 0.7rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
border: 1px solid var(--surface-border);
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
transition:
background 0.1s,
color 0.1s,
border-color 0.1s;
white-space: nowrap;
}
.freq-chip:hover {
border-color: var(--p-primary-400);
color: var(--p-primary-500);
}
.freq-chip--active {
background: var(--p-primary-500, #6366f1);
border-color: var(--p-primary-500, #6366f1);
color: #fff;
}
/* ── dias da semana grid ────────────────────────── */
.dias-semana-grid {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.dia-chip {
padding: 0.3rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
border: 1px solid var(--surface-border);
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
transition:
background 0.1s,
color 0.1s,
border-color 0.1s;
min-width: 2.6rem;
text-align: center;
}
.dia-chip:hover {
border-color: var(--p-primary-400);
color: var(--p-primary-500);
}
.dia-chip--active {
background: var(--p-primary-500, #6366f1);
border-color: var(--p-primary-500, #6366f1);
color: #fff;
}
/* ── personalizar box ───────────────────────────── */
.personalizar-box {
border: 1px solid var(--surface-border);
border-radius: 6px;
padding: 0.75rem;
background: color-mix(in srgb, var(--surface-ground), transparent 40%);
display: flex;
flex-direction: column;
gap: 0.65rem;
}
/* ── patient list ───────────────────────────────── */
/* DataTable do picker de paciente (.aed-patient-dt). Linha cujo status !==
Ativo recebe .patient-row-blocked — opacidade reduzida e cursor not-allowed,
pareando com o botao de acao desabilitado. */
.aed-patient-dt :deep(.patient-row-blocked) {
opacity: 0.6;
}
.aed-patient-dt :deep(.patient-row-blocked) > td {
cursor: not-allowed;
}
/* ─── Time Picker — mini calendar (estilo MelissaAgenda) ───
Versao compacta, sem dots/feriados. Tokens PrimeVue pra herdar tema. */
.mc-mini {
width: 100%;
max-width: 270px;
margin: 0 auto;
padding: 0.65rem;
border: 1px solid var(--surface-border);
border-radius: 8px;
background: var(--surface-card);
}
.mc-mini__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.mc-mini__title {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--text-color);
text-transform: capitalize;
}
.mc-mini__title i {
font-size: 0.72rem;
color: var(--text-color-secondary);
}
.mc-mini__nav {
display: inline-flex;
align-items: center;
gap: 0.15rem;
}
.mc-mini__icon,
.mc-mini__today {
background: transparent;
border: 0;
color: var(--text-color-secondary);
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-family: inherit;
font-size: 0.72rem;
transition: background 0.12s, color 0.12s;
}
.mc-mini__icon { padding: 0.25rem 0.4rem; }
.mc-mini__icon i { font-size: 0.7rem; }
.mc-mini__icon:hover,
.mc-mini__today:hover {
background: color-mix(in srgb, var(--p-primary-color) 10%, transparent);
color: var(--p-primary-color);
}
.mc-mini__weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.1rem;
margin-bottom: 0.2rem;
}
.mc-mini__weekdays > span {
text-align: center;
font-size: 0.6rem;
font-weight: 600;
color: var(--text-color-secondary);
opacity: 0.6;
padding: 0.2rem 0;
text-transform: uppercase;
}
.mc-mini__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.15rem;
}
.mc-mini__day {
background: transparent;
border: 0;
font-family: inherit;
font-size: 0.72rem;
color: var(--text-color);
aspect-ratio: 1;
padding: 0;
border-radius: 6px;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.mc-mini__day:hover:not(.is-selected) {
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent);
}
.mc-mini__day.is-outro {
color: var(--text-color-secondary);
opacity: 0.4;
}
.mc-mini__day.is-hoje:not(.is-selected) {
color: var(--p-primary-color);
font-weight: 700;
}
.mc-mini__day.is-selected {
background: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
font-weight: 600;
}
/* ─── Time Picker — cards (Horarios disponiveis / Duracao da sessao) ───
Padrao visual consistente: borda neutra, header com titulo + acoes,
corpo com padding. Combina com a borda primary do dialog inteiro. */
.aed-card {
border: 1px solid var(--surface-border);
border-radius: 8px;
background: var(--surface-card);
overflow: hidden;
}
.aed-card__header {
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--surface-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.aed-card__title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color);
}
.aed-card__body {
padding: 0.85rem;
}
/* Pills + Select de duracao — pattern do AgendaEventDialogV2 */
.aed-duration-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}
.aed-pill {
border: 1px solid var(--surface-border);
background: transparent;
padding: 0.4rem 0.9rem;
border-radius: 999px;
font-size: 0.78rem;
color: var(--text-color);
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.aed-pill:hover:not(:disabled) {
border-color: var(--p-primary-400, #818cf8);
color: var(--p-primary-500, #6366f1);
}
.aed-pill--active {
background: var(--p-primary-500, #6366f1);
border-color: var(--p-primary-500, #6366f1);
color: #fff;
font-weight: 600;
}
.aed-pill:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.aed-duration-other {
width: 130px;
}
/* ── serie panel (Recorrências Aplicadas) ─────────── */
.serie-panel {
border: 1px solid var(--surface-border);
border-radius: 6px;
overflow: hidden;
}
.serie-panel__header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.9rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-color-secondary);
background: color-mix(in srgb, var(--surface-ground), transparent 20%);
border-bottom: 1px solid var(--surface-border);
}
.serie-panel__stats {
display: flex;
gap: 0.3rem;
font-size: 0.7rem;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
opacity: 0.75;
margin-left: 0.25rem;
}
.serie-panel__empty {
padding: 0.85rem;
font-size: 0.82rem;
color: var(--text-color-secondary);
text-align: center;
}
.serie-pills-wrap {
display: flex;
flex-direction: column;
max-height: 340px;
overflow-y: auto;
}
.serie-pill {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.9rem;
border-bottom: 1px solid color-mix(in srgb, var(--surface-border), transparent 55%);
transition: background 0.1s;
border-left: 3px solid transparent;
}
.serie-pill:last-child {
border-bottom: none;
}
.serie-pill:hover {
background: color-mix(in srgb, var(--surface-ground), transparent 10%);
}
.serie-pill--current {
background: color-mix(in srgb, var(--p-primary-500) 7%, transparent);
border-left-color: var(--p-primary-500);
}
.serie-pill--past {
opacity: 0.5;
}
.serie-pill--realizado {
border-left-color: var(--green-400, #4ade80);
}
.serie-pill--faltou {
border-left-color: var(--red-400, #f87171);
}
.serie-pill--cancelado {
border-left-color: var(--surface-border);
}
.serie-pill--remarcado {
border-left-color: var(--orange-400, #fb923c);
}
.serie-pill__num {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--p-primary-color) 12%, transparent);
color: var(--p-primary-color);
font-size: 0.72rem;
font-weight: 700;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
padding: 0 0.4rem;
}
.serie-pill__date {
display: flex;
flex-direction: column;
align-items: center;
min-width: 2.5rem;
flex-shrink: 0;
line-height: 1.15;
}
.serie-pill__weekday {
font-size: 0.58rem;
font-weight: 700;
text-transform: uppercase;
color: var(--text-color-secondary);
letter-spacing: 0.04em;
}
.serie-pill__daynum {
font-size: 1rem;
font-weight: 800;
color: var(--text-color);
}
.serie-pill__month {
font-size: 0.58rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-color-secondary);
}
.serie-pill__time {
font-size: 0.72rem;
color: var(--text-color-secondary);
flex-shrink: 0;
min-width: 2.8rem;
}
.serie-pill__status-sel {
flex: 1;
min-width: 0;
max-width: 125px;
}
.serie-pill__status-badge {
flex: 1;
min-width: 0;
font-size: 0.72rem;
font-weight: 600;
color: var(--text-color-secondary);
text-transform: capitalize;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.serie-pill__cur-badge {
font-size: 0.58rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--p-primary-500);
background: color-mix(in srgb, var(--p-primary-500) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--p-primary-500) 30%, transparent);
border-radius: 999px;
padding: 0.1rem 0.45rem;
flex-shrink: 0;
white-space: nowrap;
}
.serie-pill__del {
flex-shrink: 0;
width: 2rem;
}
/* ── Service cards (dialog Sessão / Honorários) ─────────
Layout novo (2026-05-11): cada serviço adicionado vira um card
individual com nome em destaque, preço unitário + total, e ações
colapsáveis (Aplicar desconto, Alterar quantidade). */
.aed-svc-list {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.aed-svc-card {
border: 1px solid var(--surface-border);
border-radius: 10px;
background: color-mix(in srgb, var(--surface-card), transparent 0%);
padding: 0.85rem 0.95rem;
transition: border-color 0.12s, box-shadow 0.12s;
}
.aed-svc-card:hover {
border-color: color-mix(in srgb, var(--p-primary-color) 40%, var(--surface-border));
}
.aed-svc-card__head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.aed-svc-card__name {
flex: 1;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-color);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.aed-svc-card__remove {
flex-shrink: 0;
}
.aed-svc-card__row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.6rem;
}
.aed-svc-card__field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.aed-svc-card__label {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.aed-svc-card__total {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.1rem;
}
.aed-svc-card__total-label {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.aed-svc-card__total-value {
font-size: 1.1rem;
font-weight: 700;
color: var(--p-primary-color);
font-variant-numeric: tabular-nums;
}
.aed-svc-card__calc {
font-size: 0.7rem;
color: var(--p-red-500, #ef4444);
font-variant-numeric: tabular-nums;
}
.aed-svc-card__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
flex-wrap: wrap;
padding-top: 0.5rem;
border-top: 1px dashed var(--surface-border);
}
.aed-svc-card__discount {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.aed-svc-card__qty {
display: inline-flex;
align-items: center;
}
.aed-svc-card__qty-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: 1px dashed var(--surface-border);
border-radius: 999px;
padding: 0.25rem 0.7rem;
font-size: 0.75rem;
color: var(--text-color-secondary);
cursor: pointer;
font-family: inherit;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.aed-svc-card__qty-btn:hover {
border-color: var(--p-primary-color);
color: var(--p-primary-color);
background: color-mix(in srgb, var(--p-primary-color) 6%, transparent);
}
.aed-svc-card__qty-btn i {
font-size: 0.7rem;
}
.aed-svc-card__qty-select :deep(.p-select) {
height: 1.75rem;
min-height: 1.75rem;
}
.aed-svc-card__qty-select :deep(.p-select-label) {
padding: 0 0.5rem !important;
font-size: 0.8rem;
line-height: 1.75rem;
min-width: 1.5rem;
}
.aed-svc-list__total-value {
font-size: 1.05rem;
font-weight: 700;
color: var(--p-primary-color);
font-variant-numeric: tabular-nums;
}
/* Footer do dialog Serviços — total fixo no canto esquerdo, botão
Concluir no direito. Usa o area nativa do PrimeVue Dialog footer
(que ja fica fixa no bottom). */
.aed-svc-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1rem;
width: 100%;
}
.aed-svc-footer__total {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.85rem;
height: 2.25rem;
border: 1px dashed var(--p-primary-color);
border-radius: 8px;
background: color-mix(in srgb, var(--p-primary-color) 5%, transparent);
line-height: 1;
}
.aed-svc-footer__total .aed-svc-list__total-value {
font-size: 0.95rem;
}
/* Sections do dialog Frequência — cada bloco (Tipo, Dias, Quantidade,
Próximas ocorrências) vira um sub-card com label superior + body.
Estilo coerente com o .field-card principal mas mais compacto. */
.aed-freq-dlg {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.aed-freq-section {
border: 1px solid var(--surface-border);
border-radius: 10px;
overflow: hidden;
background: var(--surface-card);
}
.aed-freq-section__label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0 0.85rem;
height: 32px;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-color-secondary);
border-bottom: 1px solid var(--surface-border);
background: color-mix(in srgb, var(--surface-ground), transparent 30%);
}
.aed-freq-section__warn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
text-transform: none;
letter-spacing: 0;
font-size: 0.7rem;
color: var(--p-amber-500, #f59e0b);
}
.aed-freq-section__body {
padding: 0.85rem;
}
/* Separador de mês na lista de ocorrências — linha discreta com
nome do mês centralizado. Aparece antes do primeiro item de cada mês. */
.aed-freq-month-sep {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin: 0.55rem 0 0.25rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-color-secondary);
}
.aed-freq-month-sep::before,
.aed-freq-month-sep::after {
content: '';
flex: 1;
height: 1px;
background: var(--surface-border);
}
.aed-freq-month-sep:first-child {
margin-top: 0;
}
/* "Em X dias/semanas/meses" do lado da data — escala discreta */
.aed-freq-rel {
margin-left: auto;
font-size: 0.7rem;
color: var(--text-color-secondary);
opacity: 0.85;
font-style: italic;
}
/* Preview semanal/quinzenal dentro do card Tipo. */
.aed-freq-preview {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
margin-top: 0.65rem;
border-radius: 8px;
background: color-mix(in srgb, var(--p-primary-color) 6%, transparent);
border: 1px dashed color-mix(in srgb, var(--p-primary-color) 40%, transparent);
font-size: 0.82rem;
color: var(--text-color);
}
.aed-freq-preview i {
color: var(--p-primary-color);
font-size: 0.85rem;
}
/* Hint educativo "Unidades vs Recorrência" no topo do dialog Serviços —
2 linhas com icone + explicacao, fonte pequena. */
.aed-svc-hint {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.9rem;
line-height: 1.45;
}
.aed-svc-hint__row {
display: flex;
align-items: flex-start;
gap: 0.55rem;
}
.aed-svc-hint__row > i {
flex-shrink: 0;
margin-top: 0.2rem;
font-size: 0.95rem;
}
/* Header bonito do dialog Serviços/Convênio — icone redondo + titulo +
subtitulo. Mesmo pattern usado no AgendaEventDialog "Nova {commitment}"
e no MelissaPaciente dialog de Nova Sessao. */
.aed-svc-dlg-head {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.aed-svc-dlg-head__icon {
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
color: var(--p-primary-color);
font-size: 1rem;
flex-shrink: 0;
}
.aed-svc-dlg-head__text {
display: flex;
flex-direction: column;
min-width: 0;
}
.aed-svc-dlg-head__title {
font-size: 1rem;
font-weight: 700;
color: var(--text-color);
line-height: 1.2;
}
.aed-svc-dlg-head__sub {
font-size: 0.78rem;
color: var(--text-color-secondary);
line-height: 1.3;
}
/* ── Commitment items (serviços vinculados ao evento) — legacy CSS,
ainda usado pelos AgendaEventDialogV2/outras integrações ── */
.commitment-items-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
border: 1px solid var(--surface-border);
border-radius: 6px;
padding: 0.5rem;
}
.commitment-item-row {
margin-bottom: 4px;
}
.commitment-item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.commitment-item-name {
flex: 1;
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.commitment-item-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-end;
padding-bottom: 8px;
border-bottom: 1px solid var(--p-content-border-color);
margin-bottom: 8px;
}
.commitment-item-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.commitment-item-field--final {
margin-left: auto;
}
.commitment-item-label {
font-size: 0.65rem;
color: var(--p-text-muted-color);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.commitment-item-price {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
min-width: 5rem;
text-align: right;
}
.commitment-items-total {
display: flex;
justify-content: space-between;
padding-top: 0.35rem;
margin-top: 0.25rem;
border-top: 1px solid var(--surface-border);
}
/* ── Google Calendar button ─────────────────────── */
.gcal-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.9rem;
border-radius: 6px;
border: 1px solid #4285f4;
background: transparent;
color: #4285f4;
font-size: 0.85rem;
font-weight: 500;
text-decoration: none;
transition:
background 0.15s ease,
color 0.15s ease;
cursor: pointer;
white-space: nowrap;
}
.gcal-btn:hover {
background: #4285f4;
color: #fff;
}
.gcal-btn__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* ── Dialog "Ver lançamentos da sessão" (2026-05-14) ── */
.aed-records-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.aed-record {
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;
}
.aed-record--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));
}
.aed-record__head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.aed-record__indent {
color: var(--text-color-secondary);
font-size: 0.7rem;
transform: scaleY(-1);
}
.aed-record__desc {
font-size: 0.88rem;
font-weight: 600;
color: var(--text-color);
}
.aed-record__body {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.aed-record__row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: var(--text-color-secondary);
}
.aed-record__row i {
font-size: 0.72rem;
}
.aed-record__amount {
font-weight: 600;
color: var(--text-color);
}
</style>