Files
agenciapsilmno/src/features/agenda/components/AgendaEventDialog.vue
T
Leonardo 6d9b36d592 A66 WIP: AgendaEventDialog quebrado em 5 composables + 265 specs + V2 esqueleto
Sub-sessao 1 entregue (composables):
- agendaEventHelpers (262L) — utilitarios puros (date, format, parse)
- useAgendaEventComposer (485L) — montagem do form + validacao
- useAgendaEventActions (387L) — save/delete/cancel/move actions
- useAgendaEventPickerBilling (378L) — pickers (terapeuta, servico,
  convenio) + calculo de billing
- useAgendaEventLifecycle (474L) — open/close/dirty state + autosave
- 5 specs em __tests__/ (75+76+28+43+43 = 265 testes), 495/495 passing

AgendaEventDialog: 3522 -> 2632 linhas (-25%) consumindo os composables.
Backup byte-identico em AgendaEventDialog.vue.bak pra rollback.

Sub-sessao 2 entregue (esqueleto, NAO TESTADO):
- AgendaEventDialogV2 (~1100L, 3 zonas: PACIENTE/QUANDO/O QUE)
- Preview em /preview/agenda-dialog-v2 com 5 cenarios
- Rota em routes.misc.js
- User testou e nao gostou do design — aguarda feedback especifico
  pra iteracao na sub-sessao 3 (migracao nos 9 consumers).

Dialogs auxiliares novos pro AgendaEventDialog:
- InsurancePlanQuickCreateDialog (criar convenio inline)
- ServiceQuickCreateDialog (criar tipo de sessao inline)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:13:22 -03:00

2633 lines
114 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/AgendaEventDialog.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch, nextTick } 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 AgendaEventoFinanceiroPanel from '@/components/agenda/AgendaEventoFinanceiroPanel.vue';
import ServiceQuickCreateDialog from './ServiceQuickCreateDialog.vue';
import InsurancePlanQuickCreateDialog from './InsurancePlanQuickCreateDialog.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: '' }
});
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;
// ── 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' }
];
// 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: 'Gratuito', value: 'gratuito' },
{ label: 'Particular', value: 'particular' },
{ label: 'Convênio', value: 'convenio' }
];
// ──────────────────────────────────────────────────────────────────
// A66 sub-sessão 1C-i — watchers + handlers de save/delete via factory.
// 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,
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,
serieCountByStatus,
pillDeleteMenuItems,
loadSerieEvents,
onPillEditClick,
onPillStatusChange,
onPillDeleteClick,
onPillDelete,
selectSlot,
openServiceQuickCreate,
onServiceCreated,
openInsuranceQuickCreate,
onInsuranceCreated,
onSendManualReminder
} = _lifecycle;
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);
// samePatientConflict → useAgendaEventActions (1C-i) — watcher
// [paciente_id, dia] já está dentro do composable, samePatientConflict
// vem do destructuring acima.
const modalidadeOptions = [
{ label: 'Presencial', value: 'presencial' },
{ label: 'Online', value: '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;
}
// ── 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:
const filteredPatients = computed(() => {
const q = String(pacienteSearch.value || '')
.trim()
.toLowerCase();
// Somente pacientes Ativos podem ser selecionados para novos agendamentos
const list = (patients.value || []).filter((p) => p.status === 'Ativo');
if (!q) return list;
return 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);
});
});
// 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`);
}
function goToConveniosConfig() {
visible.value = false;
const prefix = route.path.startsWith('/admin') ? '/admin' : '/therapist';
router.push(`${prefix}/configuracoes/convenios`);
}
// ── duração / slots ────────────────────────────────────────
// 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)
</script>
<template>
<Dialog v-model:visible="visible" modal :draggable="false" :dismissableMask="true" :style="{ width: '1000px', maxWidth: '96vw' }" :breakpoints="{ '960px': '96vw', '640px': '98vw' }" class="agenda-event-composer" pt:mask:class="backdrop-blur-xs">
<template #header>
<div class="w-full flex items-center justify-between gap-3">
<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="font-semibold truncate text-base">{{ headerTitle }}</div>
<div v-if="step === 2" class="text-xs text-color-secondary truncate">{{ previewRange }}</div>
</div>
</div>
<!-- actions moved to footer -->
</div>
</template>
<!-- ConfirmDialog renderizado na página pai para evitar conflito de z-index com o Dialog -->
<!-- -->
<!-- STEP 1 escolha o tipo -->
<!-- -->
<div v-if="step === 1" class="p-2">
<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-grid">
<button
v-for="c in commitmentCards"
:key="c.id"
class="commitment-card"
:class="{ 'commitment-card--blocked': isDayBlocked && isNativeSession(c) }"
:style="c.bg_color ? { '--card-color': `#${c.bg_color}`, boxShadow: `0 4px 20px #${c.bg_color}30` } : {}"
:disabled="isDayBlocked && isNativeSession(c)"
@click="isDayBlocked && isNativeSession(c) ? null : selectCommitment(c)"
>
<div class="commitment-card__inner">
<div class="commitment-card__icon" :style="c.bg_color ? { background: `#${c.bg_color}20`, color: `#${c.bg_color}` } : {}">
<i :class="isNativeSession(c) ? 'pi pi-user' : 'pi pi-calendar'" />
</div>
<div class="min-w-0 flex-1">
<div class="font-semibold truncate">{{ c.name }}</div>
<div v-if="c.description" class="text-xs text-color-secondary line-clamp-2 mt-0.5">
{{ c.description }}
</div>
<div class="flex flex-wrap gap-1 mt-2">
<Tag v-if="isNativeSession(c)" value="Exige paciente" severity="info" />
<Tag v-if="isDayBlocked && isNativeSession(c)" value="Dia bloqueado" severity="danger" icon="pi pi-lock" />
</div>
</div>
<i class="pi pi-chevron-right text-color-secondary opacity-50 shrink-0 mt-1" />
</div>
</button>
</div>
</div>
<!-- -->
<!-- STEP 2 formulário + painel lateral -->
<!-- -->
<div v-else class="composer-grid">
<!-- COLUNA ESQUERDA campos -->
<div 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" :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 && 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 (destaque total) -->
<div v-if="isSessionEvent" class="patient-hero mb-4">
<div class="patient-hero__label">
<div class="flex items-center gap-1.5">
<i class="pi pi-user" />
<span>Paciente</span>
</div>
<div class="flex items-center gap-1 ml-auto">
<Button label="Cadastro Rápido" icon="pi pi-user-plus" size="small" severity="secondary" outlined class="rounded-full text-xs h-7" @click="cadRapidoOpen = true" />
<Button icon="pi pi-external-link" size="small" severity="secondary" text class="rounded-full h-7 w-7" v-tooltip.top="'Cadastro completo (nova aba)'" @click="abrirCadastroCompleto" />
</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">
<span class="text-xs text-color-secondary">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 class="flex gap-1 shrink-0">
<Button v-if="!patientLocked" icon="pi pi-pencil" severity="secondary" outlined size="small" class="rounded-full h-8 w-8" v-tooltip.top="'Trocar'" @click="openPacientePicker" />
<Button v-if="!patientLocked" icon="pi pi-times" severity="secondary" text size="small" class="rounded-full h-8 w-8" v-tooltip.top="'Limpar'" @click="clearPaciente" />
<span v-if="patientLocked" v-tooltip.top="'Paciente não pode ser alterado após criação'" class="flex items-center gap-1 text-xs text-color-secondary px-2"><i class="pi pi-lock" /></span>
</div>
</div>
<!-- Modalidade dentro do card de paciente -->
<div class="patient-hero__modalidade">
<span class="patient-hero__mod-label">Modalidade</span>
<SelectButton v-model="form.modalidade" :options="modalidadeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
</div>
<Message v-if="samePatientConflict && isSessionEvent" severity="warn" class="mt-2" :closable="false">
Paciente tem sessão neste dia:
<b>{{ fmtTime(new Date(samePatientConflict.inicio_em)) }} {{ fmtTime(new Date(samePatientConflict.fim_em)) }}</b>
</Message>
</div>
<!-- DATA E HORÁRIO -->
<div class="field-card mb-4">
<div class="field-card__header">
<i class="pi pi-calendar" />
<span>Data e Horário</span>
<div class="flex items-center gap-1 ml-auto">
<Button label="Ajustar horário" icon="pi pi-pencil" size="small" severity="secondary" outlined class="rounded-full text-xs h-7" @click="timePickerOpen = true" />
</div>
</div>
<div class="field-card__body">
<div class="time-hero" @click="timePickerOpen = true">
<div class="time-hero__block">
<span class="time-hero__label">Data</span>
<span class="time-hero__value">{{ form.dia ? fmtDateBR(form.dia) : '—' }}</span>
</div>
<div class="time-hero__sep" />
<div class="time-hero__block">
<span class="time-hero__label">Início</span>
<span class="time-hero__value">{{ form.startTime || '—' }}</span>
</div>
<div class="time-hero__sep" />
<div class="time-hero__block">
<span class="time-hero__label">Duração</span>
<span class="time-hero__value">{{ fmtDuracao(form.duracaoMin) }}</span>
</div>
<div class="time-hero__sep" />
<div class="time-hero__block">
<span class="time-hero__label">Término</span>
<span class="time-hero__value">{{ fimDateTime ? fmtTime(fimDateTime) : '—' }}</span>
</div>
</div>
<Message v-if="timeConflict" severity="warn" class="m-2" :closable="false">
<i class="pi pi-exclamation-triangle mr-1" />
{{ timeConflict }}
</Message>
</div>
<!-- /field-card__body -->
</div>
<!-- /field-card -->
<!-- STATUS DA SESSÃO -->
<div v-if="isSessionEvent && isEdit" 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>
<!-- 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>
<!-- Campos extras do compromisso -->
<template v-if="selectedCommitmentFields.length">
<div v-for="f in selectedCommitmentFields" :key="f.key">
<FloatLabel variant="on">
<Textarea v-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>
</template>
<!-- Observação (somente quando não é sessão para sessões fica no card direito) -->
<div v-if="!isSessionEvent" class="col-span-full">
<FloatLabel variant="on">
<Textarea id="aed-observacoes" v-model="form.observacoes" class="w-full" variant="filled" rows="3" autoResize />
<label for="aed-observacoes">Observação</label>
</FloatLabel>
</div>
</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 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) }]"
>
<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>
<Select v-model="ev._status" :options="statusOptions" optionLabel="label" optionValue="value" size="small" variant="filled" class="serie-pill__status-sel" @change="onPillStatusChange(ev)" />
<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>
<!-- COLUNA DIREITA painel -->
<div class="composer-right">
<!-- RESUMO -->
<div class="side-card mb-3">
<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 class="summary-row">
<i class="pi pi-map-marker summary-icon" />
<span class="capitalize">{{ form.modalidade || '—' }}</span>
</div>
<div v-if="isSessionEvent" class="summary-row">
<i class="pi pi-wallet summary-icon" />
<span>{{ displayPrice != null ? fmtBRL(displayPrice) : '—' }}</span>
</div>
</div>
<!-- ESCOPO DE EDIÇÃO em modo edição de série -->
<div v-if="isEdit && hasSerie" 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>Todos</b> ou <b>Todos sem exceção</b>. </span>
</Message>
</div>
<!-- FINANCEIRO ( sessão) -->
<div v-if="isSessionEvent" class="side-card mb-3">
<!-- SelectButton: Gratuito / Particular / Convênio -->
<SelectButton v-model="billingType" :options="billingTypeOptions" optionLabel="label" optionValue="value" class="w-full mb-3" />
<!-- PARTICULAR: seletor de serviço -->
<template v-if="billingType === 'particular'">
<div class="flex gap-2 mb-2">
<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'"
size="small"
outlined
severity="secondary"
class="aed-quick-btn"
@click="openServiceQuickCreate"
/>
</div>
</template>
<!-- CONVÊNIO: fluxo progressivo -->
<template v-if="billingType === 'convenio'">
<!-- PASSO 1 Select do plano + quick-create (sempre visível) -->
<div class="flex gap-2 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'"
size="small"
outlined
severity="secondary"
class="aed-quick-btn"
@click="openInsuranceQuickCreate"
/>
</div>
<!-- PASSO 2 Procedimento (visível quando plano selecionado) -->
<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>
<div v-else class="flex items-center justify-between gap-2 mb-2 p-2 rounded-lg bg-surface-100">
<span class="text-xs text-color-secondary">Este convênio não tem procedimentos cadastrados.</span>
<Button label="Cadastrar" icon="pi pi-plus" size="small" severity="secondary" outlined class="rounded-full shrink-0 text-xs h-7" @click="goToConveniosConfig" />
</div>
<!-- PASSO 3 da Guia + Valor (visível após selecionar procedimento ou sem procedimentos) -->
<template v-if="selectedPlanService != null || planServices.length === 0">
<div class="flex gap-2">
<div class="flex-1">
<label class="text-xs text-color-secondary mb-1 block"> da Guia</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>
<!-- RECORRÊNCIA ( sessão) -->
<div v-if="isSessionEvent" class="side-card">
<div class="mb-3">
<!-- Lista de itens adicionados -->
<div v-if="commitmentItems.length" class="commitment-items-list mb-2">
<div v-for="(item, idx) in commitmentItems" :key="idx" class="commitment-item-row">
<!-- linha 1: nome + remover -->
<div class="commitment-item-header">
<span class="commitment-item-name">{{ item.service_name }}</span>
<Button icon="pi pi-times" size="small" severity="danger" text @click="removeItem(idx)" />
</div>
<!-- linha 2: qtd | preço unit (editável) | desconto % | desconto fixo | total -->
<div class="commitment-item-controls">
<div class="commitment-item-field">
<label class="commitment-item-label">Qtd</label>
<InputNumber v-model="item.quantity" :min="1" :max="99" size="small" inputClass="w-12 text-center" @update:modelValue="onItemChange(item)" />
</div>
<div class="commitment-item-field">
<label class="commitment-item-label">Preço unit.</label>
<InputNumber v-model="item.unit_price" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" size="small" inputClass="w-24" @update:modelValue="onItemChange(item)" />
</div>
<div class="commitment-item-field">
<label class="commitment-item-label">Desc %</label>
<InputNumber v-model="item.discount_pct" :min="0" :max="100" suffix="%" size="small" inputClass="w-16 text-center" @update:modelValue="onItemChange(item)" />
</div>
<div class="commitment-item-field">
<label class="commitment-item-label">Desc R$</label>
<InputNumber v-model="item.discount_flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" size="small" inputClass="w-20" @update:modelValue="onItemChange(item)" />
</div>
<div class="commitment-item-field commitment-item-field--final">
<label class="commitment-item-label">Total</label>
<span class="commitment-item-price">{{ fmtBRL(item.final_price) }}</span>
</div>
</div>
</div>
<div class="commitment-items-total">
<span class="text-sm text-color-secondary">Total da sessão</span>
<span class="font-semibold">{{ fmtBRL(totalFromItems) }}</span>
</div>
</div>
</div>
<!-- Observação -->
<div class="mb-3">
<FloatLabel variant="on">
<Textarea id="aed-observacoes-side" v-model="form.observacoes" class="w-full" variant="filled" rows="3" autoResize :disabled="isArchivedPastEdit" />
<label for="aed-observacoes-side">Observação</label>
</FloatLabel>
</div>
<!-- COBRANÇA DA SESSÃO -->
<AgendaEventoFinanceiroPanel v-if="isSessionEvent && isEdit && eventRow?.id" :evento="eventRow" class="mb-3" />
<!-- Opção de recorrência para sessão SEM série (criação ou avulsa) -->
<template v-if="!hasSerie">
<div class="side-card__title mb-2">Frequência</div>
<Message v-if="isSessionEvent && 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>
<!-- Data de início (= form.dia) com botão Hoje -->
<div class="rec-startdate-row mb-3">
<div class="flex items-center gap-1.5 min-w-0">
<i class="pi pi-calendar text-primary-500 shrink-0" />
<span class="text-sm font-medium truncate">{{ form.dia ? fmtDateBR(form.dia) : 'Selecione a data' }}</span>
</div>
<Button label="Hoje" size="small" severity="secondary" outlined class="rounded-full shrink-0" @click="setHoje" />
</div>
<!-- Chips de frequência -->
<div class="freq-chips mb-3">
<button
v-for="f in freqOpcoes"
:key="f.value"
class="freq-chip"
:class="{
'freq-chip--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>
<!-- Info semanal/quinzenal -->
<div v-if="recorrenciaType === 'semanal' || recorrenciaType === 'quinzenal'" class="recorrencia-preview mb-3">
<i class="pi pi-refresh text-primary-500" />
<span class="text-sm font-medium">
{{ recorrenciaType === 'quinzenal' ? 'A cada 2 semanas, toda' : 'Toda' }}
{{ nomeDiaSemana(diaSemanaRecorrencia) }}, às {{ form.startTime || '—' }}
</span>
</div>
<!-- Seletor de dias da semana (diasEspecificos) -->
<div v-if="recorrenciaType === 'diasEspecificos'" class="mb-3">
<label class="block text-xs font-semibold text-color-secondary mb-1.5">Dias da semana</label>
<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>
<!-- Quantidade de sessões (apenas quando não é avulsa) -->
<div v-if="recorrenciaType !== 'avulsa'" class="mb-3">
<label class="block text-xs font-semibold text-color-secondary mb-1.5">Quantidade de sessões</label>
<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 }" @click="qtdSessoesMode = opt.value">{{ opt.label }}</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>
<!-- Preview das ocorrências -->
<div v-if="recorrenciaType !== 'avulsa' && ocorrenciasComConflito.length" class="ocorrencias-preview">
<div class="ocorrencias-header">
<span class="text-xs font-semibold text-color-secondary">{{ totalOcorrencias }} sessões previstas</span>
<span v-if="totalConflitos > 0" class="ocorrencias-aviso">
<i class="pi pi-exclamation-triangle" />
{{ totalConflitos }} com conflito
</span>
</div>
<div class="ocorrencias-scroll">
<div
v-for="(o, i) in ocorrenciasComConflito"
:key="i"
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 v-if="o.conflict" class="ocorrencia-conflict-label">{{ o.conflict.label }}</span>
</div>
</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>
<!-- Modo de valor da série informativo, quando serviços + recorrência -->
<div v-if="recorrenciaType !== 'avulsa' && commitmentItems.length > 0" class="mb-1 mt-3">
<label class="block text-xs font-semibold text-color-secondary mb-1.5">Como interpretar o valor</label>
<SelectButton
v-model="serieValorMode"
:options="[
{ label: 'Por sessão', value: 'multiplicar' },
{ label: 'Pacote fechado', value: 'dividir' }
]"
optionLabel="label"
optionValue="value"
size="small"
class="w-full mb-2"
/>
<Message v-if="serieValorAviso" severity="info" :closable="false">
<span class="text-xs">{{ serieValorAviso }}</span>
</Message>
</div>
</template>
</div>
</div>
</div>
<!-- -->
<!-- Patient Picker Dialog -->
<!-- -->
<Dialog v-model:visible="pacientePickerOpen" modal header="Selecionar paciente" :draggable="false" :style="{ width: '860px', maxWidth: '96vw' }" :breakpoints="{ '960px': '96vw', '640px': '98vw' }">
<div class="flex flex-col gap-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>
<div v-if="pacientesLoading" class="text-sm text-color-secondary">Carregando pacientes</div>
<div v-else-if="filteredPatients.length === 0" class="text-sm text-color-secondary">Nenhum paciente encontrado.</div>
<div v-else class="patient-list">
<button v-for="p in filteredPatients" :key="p.id" class="patient-item" @click="selectPaciente(p)">
<Avatar v-if="p.avatar_url" :image="p.avatar_url" shape="circle" class="shrink-0" />
<Avatar v-else :label="patientInitials(p.nome)" shape="circle" class="shrink-0" />
<div class="min-w-0 flex-1">
<div class="font-semibold truncate">{{ p.nome || 'Sem nome' }}</div>
<div class="text-xs text-color-secondary truncate">
<span v-if="p.email">{{ p.email }}</span>
<span v-if="p.email && p.telefone"> </span>
<span v-if="p.telefone">{{ p.telefone }}</span>
</div>
</div>
<div class="shrink-0 flex items-center gap-2">
<Tag v-if="p.status" :value="String(p.status)" severity="secondary" />
<Button icon="pi pi-check" class="rounded-full" size="small" />
</div>
</button>
</div>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="pacientePickerOpen = false" />
</template>
</Dialog>
<!-- -->
<!-- Time Picker Dialog -->
<!-- -->
<Dialog v-model:visible="timePickerOpen" modal header="Data e Horário" :draggable="false" :style="{ width: '560px', maxWidth: '96vw' }" :breakpoints="{ '640px': '98vw' }">
<div class="flex flex-col gap-4">
<!-- Data -->
<div>
<label class="block text-sm font-medium mb-2">Data</label>
<DatePicker v-model="form.dia" dateFormat="dd/mm/yy" showIcon fluid inline />
</div>
<!-- Horários disponíveis (presencial e online unificado) -->
<div v-if="availableSlots.length">
<!-- 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>
</div>
</template>
<!-- Header: rótulo + period chips ( períodos com slots) -->
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium">Horários disponíveis</label>
<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>
<!-- 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-1">Nenhum horário disponível neste período.</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>
<Divider class="my-1" />
<!-- Início + Duração -->
<div class="time-controls">
<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">Duração</label>
<Select
v-model="form.duracaoMin"
:options="duracaoOptions"
optionLabel="label"
optionValue="value"
optionGroupLabel="label"
optionGroupChildren="items"
variant="filled"
class="w-full"
:disabled="isDynamic && commitmentItems.length > 0"
/>
<small v-if="isDynamic && commitmentItems.length > 0" class="text-color-secondary text-xs mt-1 block"> Calculado pelos serviços adicionados </small>
</div>
</div>
<!-- Término em destaque -->
<div v-if="fimDateTime" class="time-fim-card">
<div class="time-fim-card__label">
<i class="pi pi-flag-fill" />
Término
</div>
<div class="time-fim-card__value">{{ fmtTime(fimDateTime) }}</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="Confirmar" icon="pi pi-check" @click="timePickerOpen = false" />
</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' }"
@created="onPatientCreatedRapido"
/>
<!-- 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"
/>
</Dialog>
</template>
<style scoped>
.agenda-event-composer :deep(.p-dialog-content) {
padding: 0.75rem;
}
/* ═══════════════════════════════════════════════════════════════════
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 com backdrop-blur uniforme
5. Borda dos cards laterais com cor de seção (paciente/quando/quê)
═══════════════════════════════════════════════════════════════════ */
/* Backdrop unificado pra Dialog do composer */
.agenda-event-composer :deep(.p-dialog-mask) {
backdrop-filter: blur(6px) saturate(110%);
-webkit-backdrop-filter: blur(6px) saturate(110%);
background: rgba(15, 23, 42, 0.32);
}
/* 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);
}
/* 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 grid ────────────────────── */
.commitment-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
@media (max-width: 640px) {
.commitment-grid {
grid-template-columns: 1fr;
}
}
.commitment-card {
width: 100%;
text-align: left;
border: 1px solid var(--surface-border);
border-radius: 6px;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
transition:
box-shadow 0.12s ease,
transform 0.12s ease,
border-color 0.12s;
overflow: hidden;
}
.commitment-card:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--card-color, var(--p-primary-500)) 60%, transparent);
}
.commitment-card__inner {
display: flex;
align-items: flex-start;
gap: 0.85rem;
padding: 1rem;
}
.commitment-card__icon {
display: grid;
place-items: center;
width: 2.2rem;
height: 2.2rem;
border-radius: 0.75rem;
background: color-mix(in srgb, var(--p-primary-500) 12%, transparent);
color: var(--p-primary-500);
font-size: 1rem;
flex-shrink: 0;
}
.commitment-card--blocked {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
border-color: color-mix(in srgb, var(--red-500) 40%, var(--surface-border));
background: color-mix(in srgb, var(--red-50, #fef2f2) 30%, var(--surface-card));
}
/* ── step 2: grid layout ────────────────────────── */
.composer-grid {
display: grid;
grid-template-columns: 1.3fr 0.7fr;
gap: 1rem;
align-items: start;
margin-top: 2px;
}
@media (max-width: 960px) {
.composer-grid {
grid-template-columns: 1fr;
}
}
/* ── 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.4rem 0.6rem 0.4rem 0.9rem;
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%);
}
/* Modalidade dentro do card de paciente */
.patient-hero__modalidade {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px 10px;
border-top: 1px solid var(--surface-border);
}
.patient-hero__mod-label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-color-secondary);
white-space: nowrap;
}
/* 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: 7px 12px;
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);
}
/* ── time hero ──────────────────────────────────── */
.time-hero {
display: flex;
align-items: stretch;
cursor: pointer;
transition:
border-color 0.12s,
box-shadow 0.12s;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
}
.time-hero:hover {
border-color: var(--p-primary-400, #818cf8);
box-shadow: 0 2px 12px color-mix(in srgb, var(--p-primary-500) 15%, transparent);
}
.time-hero__block {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.2rem;
padding: 0.7rem 0.5rem;
min-width: 0;
}
.time-hero__label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-color-secondary);
white-space: nowrap;
}
.time-hero__value {
font-size: 0.92rem;
font-weight: 700;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.time-hero__sep {
width: 1px;
background: var(--surface-border);
margin: 0.5rem 0;
}
.time-hero__edit {
display: flex;
align-items: center;
padding: 0 0.75rem;
color: var(--text-color-secondary);
opacity: 0.5;
font-size: 0.8rem;
}
/* ── 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;
}
}
/* ── side panel ─────────────────────────────────── */
.composer-right {
position: sticky;
top: 0;
}
.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;
}
/* 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.25rem 0.75rem;
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;
}
.freq-tab:hover {
border-color: var(--p-primary-400);
color: var(--p-primary-500);
}
.freq-tab--active {
background: var(--p-primary-500, #6366f1);
border-color: var(--p-primary-500, #6366f1);
color: #fff;
}
/* ── 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;
}
/* Término em destaque */
.time-fim-card {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: 6px;
background: color-mix(in srgb, var(--primary-500, #6366f1) 8%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--primary-400, #818cf8) 25%, transparent);
}
.time-fim-card__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--primary-500, #6366f1);
opacity: 0.75;
}
.time-fim-card__value {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--primary-600, #4f46e5);
margin-left: auto;
}
/* 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;
}
/* 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 ───────────────────────────────── */
.patient-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
max-height: 56vh;
overflow: auto;
padding-right: 0.25rem;
}
.patient-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
text-align: left;
padding: 0.85rem 0.95rem;
border: 1px solid var(--surface-border);
border-radius: 6px;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
transition:
box-shadow 0.12s ease,
transform 0.12s ease;
}
.patient-item:hover {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
/* ── 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__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__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;
}
/* ── Commitment items (serviços vinculados ao evento) ── */
.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;
}
</style>