c23d0a574f
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>
5761 lines
244 KiB
Vue
5761 lines
244 KiB
Vue
<!--
|
||
|--------------------------------------------------------------------------
|
||
| 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> já 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 só 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>Nº 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 já 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 — só 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 (só 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) —
|
||
só 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
|
||
só 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 já emitidas permanecem inalteradas</b> —
|
||
para ajustá-las, acesse o Financeiro.
|
||
</div>
|
||
</Message>
|
||
</div>
|
||
|
||
<!-- ── FREQUÊNCIA (só 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 JÁ É 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 (avulsa↔recorrente), 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 há cobrança paga/pendente (lock-edit) —
|
||
a Message do panel já cobre. -->
|
||
<div v-if="isSessionEvent && !occFinancialRecord && billingType === 'convenio'" class="aed-billing-hint mb-4">
|
||
<i class="pi pi-info-circle" />
|
||
<span><b>Nº 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 há</b> slot online configurado para o dia, você ainda pode agendar em qualquer horário da sua jornada — só 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 + nº 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>nº 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
|
||
já 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">Nº 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 (só 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 (só 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>
|