6d9b36d592
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>
2633 lines
114 KiB
Vue
2633 lines
114 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 } 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 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" />
|
||
<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 — só 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 (só 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 — Nº 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">Nº 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 (só 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, só quando há 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 (só 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>
|