Melissa Agenda: paridade com AgendaTerapeuta + responsivo mobile
Composable useMelissaAgenda (~1150 linhas, exclusivo Melissa): - Orquestra useAgendaEvents + useRecurrence + useDeterminedCommitments + useFeriados + useCommitmentServices - 7 cases de save (avulso, recorrente C, somente_este D, este_e_seguintes E, todos F, todos_sem_excecao G + tratamento de exclusion constraint) - 3 cases de delete (somente_este, este_e_seguintes, todos com encerrar série) - onCreateEvento (botão Agendar), onSelectTime com cap de 120min, persistMoveOrResize com confirm dialog descritivo e bold em datas/horas - Bloqueio: openBloqueioDialog(mode) com 4 modos MelissaLayout: - Provide composable via MELISSA_AGENDA_KEY (inject em MelissaAgenda) - Renderiza AgendaEventDialog + BloqueioDialog + ConfirmDialog - Slot #message v-html pra renderizar HTML em messages do confirm - onEditEvento liga panel ao dialog completo (B3 não-stub) MelissaAgenda: - Drop useMelissaEventosRange — eventos vêm do composable injetado - Drag/resize/select-to-create habilitados quando há composable - Cluster Paciente + Agendar (50/50 primary) - Toolbar: timeMode (24/12/Meu) + onlySessions + bloquear-menu (desktop) - Header: Pacientes (mobile-only, abre drawer) + Configurações + Fechar - Mobile <lg: aside + widgets viram drawer off-canvas (slide esquerda); calendar fullwidth; "Ações" menu mobile concentra timeMode/onlySessions/ bloquear; backdrop com click-outside MelissaEventoPanel (B3 estático-revisado): - Substitui panel inline que crashava em campos inexistentes - Action bar agrupada (status / paciente / geral) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,8 @@
|
||||
* Mock visual (sem FullCalendar real). Quando integrar de verdade,
|
||||
* trocar a área central pelo FullCalendar com tema glass.
|
||||
*/
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, inject } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import FullCalendar from '@fullcalendar/vue3';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
@@ -22,6 +23,7 @@ import listPlugin from '@fullcalendar/list';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||
import { useMelissaEventosRange } from './composables/useMelissaEventos';
|
||||
import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
|
||||
@@ -45,11 +47,86 @@ const props = defineProps({
|
||||
const emit = defineEmits(['select-evento', 'close', 'patient-created']);
|
||||
|
||||
// ── Estado ─────────────────────────────────────────────────────
|
||||
const router = useRouter();
|
||||
const busca = ref('');
|
||||
const pacienteSelecionadoId = ref(null);
|
||||
const calendarView = ref('semana'); // 'dia' | 'semana' | 'mes' | 'lista'
|
||||
const refDate = ref(new Date()); // data de referência (sincronizada com o FC via datesSet)
|
||||
|
||||
// ── Filtros adicionais (espelham AgendaTerapeutaPage) ─────────
|
||||
// timeMode: '24' (00–24h) | '12' (06–18h) | 'my' (intervalo da workRules)
|
||||
const timeMode = ref('my');
|
||||
const timeModeOptions = [
|
||||
{ label: '24h', value: '24' },
|
||||
{ label: '12h', value: '12' },
|
||||
{ label: 'Meu', value: 'my' }
|
||||
];
|
||||
// onlySessions: true filtra eventos não-paciente do FC + lista
|
||||
const onlySessions = ref(false);
|
||||
const onlySessionsOptions = [
|
||||
{ label: 'Apenas Sessões', value: true },
|
||||
{ label: 'Tudo', value: false }
|
||||
];
|
||||
|
||||
// ── Drawer mobile ────────────────────────────────────────────
|
||||
// Quando largura <1024px, .ma-side e .ma-widgets viram off-canvas
|
||||
// dentro de .ma-drawer. drawerOpen controla translateX via CSS.
|
||||
const drawerOpen = ref(false);
|
||||
const isMobile = ref(false);
|
||||
|
||||
let _mqMobile = null;
|
||||
function _onMqChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
// Cruzou pra desktop → fecha o drawer pra evitar layout zoado
|
||||
if (!e.matches) drawerOpen.value = false;
|
||||
}
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
_mqMobile.addEventListener('change', _onMqChange);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqChange);
|
||||
});
|
||||
|
||||
function toggleDrawer() {
|
||||
drawerOpen.value = !drawerOpen.value;
|
||||
}
|
||||
function fecharDrawer() {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Configurações da agenda (botão no header) ─────────────────
|
||||
function goSettings() {
|
||||
router.push('/configuracoes/agenda');
|
||||
}
|
||||
|
||||
// ── Menu mobile "Ações" (popover) ─────────────────────────────
|
||||
// Concentra timeMode/onlySessions/bloquear em um único trigger mobile
|
||||
// pra não inflar a toolbar em telas pequenas.
|
||||
const mobileActionsRef = ref(null);
|
||||
const mobileActionsItems = computed(() => [
|
||||
{
|
||||
label: onlySessions.value ? 'Mostrar tudo' : 'Apenas sessões',
|
||||
icon: onlySessions.value ? 'pi pi-list' : 'pi pi-filter',
|
||||
command: () => { onlySessions.value = !onlySessions.value; }
|
||||
},
|
||||
{ separator: true },
|
||||
{ label: 'Horário 24h', icon: timeMode.value === '24' ? 'pi pi-check' : 'pi pi-clock', command: () => { timeMode.value = '24'; } },
|
||||
{ label: 'Horário 12h', icon: timeMode.value === '12' ? 'pi pi-check' : 'pi pi-clock', command: () => { timeMode.value = '12'; } },
|
||||
{ label: 'Meu horário', icon: timeMode.value === 'my' ? 'pi pi-check' : 'pi pi-clock', command: () => { timeMode.value = 'my'; } },
|
||||
{ separator: true },
|
||||
{ label: 'Bloquear por horário', icon: 'pi pi-clock', command: () => M?.openBloqueioDialog?.('horario') },
|
||||
{ label: 'Bloquear por período', icon: 'pi pi-calendar-clock', command: () => M?.openBloqueioDialog?.('periodo') },
|
||||
{ label: 'Bloquear por dia', icon: 'pi pi-calendar-times', command: () => M?.openBloqueioDialog?.('dia') },
|
||||
{ label: 'Bloqueio por feriados', icon: 'pi pi-star', command: () => M?.openBloqueioDialog?.('feriados') }
|
||||
]);
|
||||
function openMobileActions(event) {
|
||||
mobileActionsRef.value?.toggle(event);
|
||||
}
|
||||
|
||||
// ── Pacientes filtrados + ordenação (novos no topo) ──────────
|
||||
// Pacientes criados nas últimas NOVO_THRESHOLD_DIAS ficam no topo da lista,
|
||||
// destacados com bg primary suave. Após esse período voltam pra ordem
|
||||
@@ -125,11 +202,16 @@ const refDateIsToday = computed(() => {
|
||||
return hoje >= inicio && hoje < fim;
|
||||
});
|
||||
|
||||
// Range visível atual (passa pro composable que refetcha)
|
||||
const viewStart = ref(new Date());
|
||||
const viewEnd = ref(new Date());
|
||||
// Composable injetado pelo MelissaLayout — fonte única de eventos do FC
|
||||
// (real + ocorrências virtuais via useRecurrence). viewStart/End vivem no
|
||||
// composable; mutamos elas no datesSet do FC pra disparar o refetch lá.
|
||||
//
|
||||
// Fallback: se MelissaAgenda for usado fora do MelissaLayout (preview
|
||||
// standalone), cai pra refs locais e composable legado useMelissaEventosRange.
|
||||
const M = inject(MELISSA_AGENDA_KEY, null);
|
||||
|
||||
// Inicializa range de uma semana ao redor de hoje (FC vai sobrescrever no datesSet de mount)
|
||||
const _viewStartLocal = ref(new Date());
|
||||
const _viewEndLocal = ref(new Date());
|
||||
{
|
||||
const hoje = new Date();
|
||||
const dow = hoje.getDay();
|
||||
@@ -139,12 +221,19 @@ const viewEnd = ref(new Date());
|
||||
segunda.setHours(0, 0, 0, 0);
|
||||
const domingoNext = new Date(segunda);
|
||||
domingoNext.setDate(segunda.getDate() + 7);
|
||||
viewStart.value = segunda;
|
||||
viewEnd.value = domingoNext;
|
||||
_viewStartLocal.value = segunda;
|
||||
_viewEndLocal.value = domingoNext;
|
||||
refDate.value = hoje;
|
||||
}
|
||||
|
||||
const { eventos: eventosSemana, refetch: refetchEventosFc } = useMelissaEventosRange(viewStart, viewEnd);
|
||||
const viewStart = M?.viewStart || _viewStartLocal;
|
||||
const viewEnd = M?.viewEnd || _viewEndLocal;
|
||||
|
||||
// Quando há M (caso normal), eventos vêm dele. Sem M (standalone), cai pro
|
||||
// composable legado que SÓ traz eventos reais (sem recorrência virtual).
|
||||
const _legacyRange = M ? null : useMelissaEventosRange(viewStart, viewEnd);
|
||||
const eventosSemana = M?.eventos ?? _legacyRange?.eventos ?? ref([]);
|
||||
const refetchEventosFc = M?.refetch ?? _legacyRange?.refetch ?? (() => {});
|
||||
|
||||
// Mapa eventos.dateKey → array (pra widgets de stats e sessões hoje)
|
||||
const eventosPorDia = computed(() => {
|
||||
@@ -186,21 +275,67 @@ function statLabel(key) {
|
||||
// Pattern espelha AgendaTerapeutaPage:601.
|
||||
const fcEvents = computed(() => {
|
||||
const pred = statFiltroAtivo.value ? STAT_FILTERS[statFiltroAtivo.value] : () => true;
|
||||
// onlySessions filtra eventos sem paciente vinculado (compromissos
|
||||
// pessoais como "Análise" ainda usam tipo='sessao' no banco — patient_id
|
||||
// é o discriminador real). Espelha AgendaTerapeutaPage:446.
|
||||
const sessionPred = onlySessions.value
|
||||
? (ev) => !!(ev.patient_id || ev.paciente_id)
|
||||
: () => true;
|
||||
return [
|
||||
...eventosSemana.value.filter(pred).map((ev) => ({
|
||||
id: ev.id,
|
||||
title: ev.label,
|
||||
start: ev.inicio_em,
|
||||
end: ev.fim_em,
|
||||
backgroundColor: `${ev.color}26`, // ~15% opacity
|
||||
borderColor: ev.color,
|
||||
textColor: 'white',
|
||||
extendedProps: ev
|
||||
})),
|
||||
...eventosSemana.value
|
||||
.filter(pred)
|
||||
.filter(sessionPred)
|
||||
.map((ev) => ({
|
||||
id: ev.id,
|
||||
title: ev.label,
|
||||
start: ev.inicio_em,
|
||||
end: ev.fim_em,
|
||||
backgroundColor: `${ev.color}26`, // ~15% opacity
|
||||
borderColor: ev.color,
|
||||
textColor: 'white',
|
||||
extendedProps: ev
|
||||
})),
|
||||
...feriadoFcEvents.value
|
||||
];
|
||||
});
|
||||
|
||||
// ── slotMinTime / slotMaxTime baseado em timeMode ─────────────
|
||||
// 24: 00–24h. 12: 06–18h. my: range das workRules (snap em 30min).
|
||||
function _hhmmToMin(t) {
|
||||
const [h, m] = String(t || '00:00').split(':').map(Number);
|
||||
return (h || 0) * 60 + (m || 0);
|
||||
}
|
||||
function _floorTo30(hhmmss) {
|
||||
const m = _hhmmToMin(String(hhmmss).slice(0, 5));
|
||||
const f = m - (m % 30);
|
||||
return `${String(Math.floor(f / 60)).padStart(2, '0')}:${String(f % 60).padStart(2, '0')}:00`;
|
||||
}
|
||||
function _ceilTo30(hhmmss) {
|
||||
const m = _hhmmToMin(String(hhmmss).slice(0, 5));
|
||||
const c = m % 30 === 0 ? m : m + (30 - (m % 30));
|
||||
return `${String(Math.floor(c / 60)).padStart(2, '0')}:${String(c % 60).padStart(2, '0')}:00`;
|
||||
}
|
||||
function _myHoursRange() {
|
||||
const rules = (workRules.value || []).filter((r) => r.ativo !== false);
|
||||
if (!rules.length) return { start: '08:00:00', end: '20:00:00' };
|
||||
const starts = rules.map((r) => _floorTo30(r.hora_inicio));
|
||||
const ends = rules.map((r) => _ceilTo30(r.hora_fim));
|
||||
return {
|
||||
start: starts.reduce((a, b) => (a < b ? a : b)),
|
||||
end: ends.reduce((a, b) => (a > b ? a : b))
|
||||
};
|
||||
}
|
||||
const slotMinTime = computed(() => {
|
||||
if (timeMode.value === '24') return '00:00:00';
|
||||
if (timeMode.value === '12') return '06:00:00';
|
||||
return _myHoursRange().start;
|
||||
});
|
||||
const slotMaxTime = computed(() => {
|
||||
if (timeMode.value === '24') return '24:00:00';
|
||||
if (timeMode.value === '12') return '18:00:00';
|
||||
return _myHoursRange().end;
|
||||
});
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
@@ -226,8 +361,11 @@ const fcOptions = computed(() => ({
|
||||
initialView: VIEW_MAP[calendarView.value] || 'timeGridWeek',
|
||||
initialDate: refDate.value,
|
||||
nowIndicator: true,
|
||||
editable: false, // preview: sem drag/resize ainda
|
||||
selectable: false, // preview: sem click-drag pra criar
|
||||
// Drag/resize/select habilitam apenas com M (composable disponível) —
|
||||
// standalone (sem M) fica readonly por compat (preview puro).
|
||||
editable: !!M,
|
||||
selectable: !!M,
|
||||
selectMirror: true,
|
||||
weekends: true,
|
||||
// Header da semana ("seg 20/04") vira link clicável → vai pra view dia.
|
||||
// Default do FC mandaria pra dayGridDay; forçamos timeGridDay (nossa "dia").
|
||||
@@ -239,8 +377,8 @@ const fcOptions = computed(() => ({
|
||||
allDaySlot: false, // tira a faixa "Dia inteiro" — recupera altura
|
||||
// Slots de 30min com label em CADA linha (08:00, :30, 09:00, :30...)
|
||||
// Snap continua em 15min pra futura criação/drag de eventos.
|
||||
slotMinTime: '08:00:00',
|
||||
slotMaxTime: '20:00:00',
|
||||
slotMinTime: slotMinTime.value,
|
||||
slotMaxTime: slotMaxTime.value,
|
||||
slotDuration: '00:30:00',
|
||||
snapDuration: '00:15:00',
|
||||
slotLabelInterval: '00:30:00',
|
||||
@@ -261,6 +399,23 @@ const fcOptions = computed(() => ({
|
||||
const ev = info.event.extendedProps;
|
||||
if (ev) emit('select-evento', ev);
|
||||
},
|
||||
// Drag → reagenda evento (mesmo dia, hora diferente OU outro dia)
|
||||
eventDrop: (info) => {
|
||||
if (!M) { info.revert?.(); return; }
|
||||
M.persistMoveOrResize(info, 'Sessão movida');
|
||||
},
|
||||
// Resize → muda duração da sessão
|
||||
eventResize: (info) => {
|
||||
if (!M) { info.revert?.(); return; }
|
||||
M.persistMoveOrResize(info, 'Duração alterada');
|
||||
},
|
||||
// Click-drag em área vazia → abre dialog pra criar evento novo, com
|
||||
// start/end pré-preenchidos. AgendaEventDialog cuida do resto (seleção
|
||||
// de paciente, modalidade, recorrência).
|
||||
select: (info) => {
|
||||
if (!M) return;
|
||||
M.onSelectTime({ start: info.start, end: info.end });
|
||||
},
|
||||
eventContent: (arg) => {
|
||||
const ext = arg.event.extendedProps || {};
|
||||
const titulo = arg.event.title || '—';
|
||||
@@ -291,6 +446,20 @@ function setView(v) {
|
||||
fcApi()?.changeView(VIEW_MAP[v]);
|
||||
}
|
||||
|
||||
// ── Menu de Bloqueio (toolbar) ─────────────────────────────────
|
||||
// Espelha o blockMenuItems da AgendaTerapeutaPage. Cada modo abre o
|
||||
// BloqueioDialog renderizado no MelissaLayout (via composable M).
|
||||
const bloqueioMenuRef = ref(null);
|
||||
const bloqueioMenuItems = computed(() => [
|
||||
{ label: 'Bloquear por horário', icon: 'pi pi-clock', command: () => M?.openBloqueioDialog?.('horario') },
|
||||
{ label: 'Bloquear por período', icon: 'pi pi-calendar-clock', command: () => M?.openBloqueioDialog?.('periodo') },
|
||||
{ label: 'Bloquear por dia', icon: 'pi pi-calendar-times', command: () => M?.openBloqueioDialog?.('dia') },
|
||||
{ label: 'Bloqueio por feriados', icon: 'pi pi-star', command: () => M?.openBloqueioDialog?.('feriados') }
|
||||
]);
|
||||
function openBloqueioMenu(event) {
|
||||
bloqueioMenuRef.value?.toggle(event);
|
||||
}
|
||||
|
||||
function fmtHora(h) {
|
||||
const horas = Math.floor(h);
|
||||
const mins = Math.round((h - horas) * 60);
|
||||
@@ -632,6 +801,20 @@ const kebabItems = computed(() => {
|
||||
{ label: 'Editar', icon: 'pi pi-pencil', command: () => { editPatientId.value = String(p.id); cadastroFullDialog.value = true; } }
|
||||
];
|
||||
});
|
||||
|
||||
// ── API exposta pro parent (MelissaLayout) ────────────────────
|
||||
// MelissaEventoPanel emite ações que o parent precisa orquestrar com
|
||||
// a Agenda — aqui ficam os métodos invocáveis via ref.
|
||||
function openProntuario(patient) {
|
||||
if (!patient) return;
|
||||
prontuarioPatient.value = { ...patient };
|
||||
prontuarioOpen.value = true;
|
||||
}
|
||||
defineExpose({
|
||||
refetch: refetchEventosFc,
|
||||
openProntuario,
|
||||
setView
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -641,14 +824,48 @@ const kebabItems = computed(() => {
|
||||
<i class="pi pi-calendar text-emerald-300" />
|
||||
<span>Agenda</span>
|
||||
</div>
|
||||
<button class="ma-close" title="Voltar ao resumo (Esc)" @click="emit('close')">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
<div class="ma-page__actions">
|
||||
<!-- Pacientes — mobile only, abre o drawer com aside+widgets -->
|
||||
<button
|
||||
class="ma-head-btn ma-head-btn--mobile-only"
|
||||
v-tooltip.bottom="'Pacientes & widgets'"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<i class="pi pi-users" />
|
||||
<span>Pacientes</span>
|
||||
</button>
|
||||
<!-- Configurações da agenda — abre /configuracoes/agenda -->
|
||||
<button
|
||||
class="ma-head-btn"
|
||||
v-tooltip.bottom="'Configurações da agenda'"
|
||||
@click="goSettings"
|
||||
>
|
||||
<i class="pi pi-cog" />
|
||||
</button>
|
||||
<!-- Fechar (Esc) — volta pro resumo -->
|
||||
<button class="ma-close" title="Voltar ao resumo (Esc)" @click="emit('close')">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="ma-body">
|
||||
<!-- ════ COL 1: Hoje + Pacientes ════ -->
|
||||
<aside class="ma-side">
|
||||
<!-- Backdrop: só visível em mobile com drawer aberto -->
|
||||
<Transition name="ma-drawer-fade">
|
||||
<div
|
||||
v-if="isMobile && drawerOpen"
|
||||
class="ma-drawer__backdrop"
|
||||
@click="fecharDrawer"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- ════ DRAWER (envolve COL 1 + COL 3) ════
|
||||
Desktop: display:contents — children flow como flex items
|
||||
do .ma-body (side, cal, widgets via order CSS).
|
||||
Mobile (<lg): position:fixed off-canvas, slide da esquerda. -->
|
||||
<div class="ma-drawer" :class="{ 'is-open': drawerOpen }">
|
||||
<!-- ════ COL 1: Hoje + Pacientes ════ -->
|
||||
<aside class="ma-side">
|
||||
<!-- Hoje (stats + lista de sessões) — movido da col 3 -->
|
||||
<div class="ma-w ma-w--side">
|
||||
<div class="ma-w__head">
|
||||
@@ -714,16 +931,28 @@ const kebabItems = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
<div class="ma-side__list">
|
||||
<!-- Item fake fixo "Adicionar paciente" — abre popover
|
||||
com 3 opções (rápido, completo, link de cadastro). -->
|
||||
<button
|
||||
class="ma-pat ma-pat--add"
|
||||
title="Adicionar paciente"
|
||||
@click="openCreatePopover($event)"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<span>Adicionar paciente</span>
|
||||
</button>
|
||||
<!-- Cluster de ações primárias — Paciente abre popover
|
||||
com 3 opções (rápido, completo, link); Agendar abre
|
||||
AgendaEventDialog vazio (M.onCreateEvento). 50/50. -->
|
||||
<div class="ma-side__actions">
|
||||
<button
|
||||
class="ma-act-btn ma-act-btn--primary"
|
||||
v-tooltip.top="'Adicionar paciente'"
|
||||
@click="openCreatePopover($event)"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<span>Paciente</span>
|
||||
</button>
|
||||
<button
|
||||
class="ma-act-btn ma-act-btn--primary"
|
||||
v-tooltip.top="'Agendar evento'"
|
||||
:disabled="!M"
|
||||
@click="M?.onCreateEvento?.()"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<span>Agendar</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="p in pacientesFiltrados"
|
||||
@@ -785,16 +1014,65 @@ const kebabItems = computed(() => {
|
||||
</div>
|
||||
<div class="ma-cal__period" :title="currentTitle">{{ currentTitle }}</div>
|
||||
</div>
|
||||
<div class="ma-cal__view">
|
||||
<div class="ma-cal__right">
|
||||
<!-- Filtros desktop — escondem em mobile (<lg) onde
|
||||
vão pra dentro do botão "Ações". SelectButton é
|
||||
auto-resolvido via PrimeVueResolver. -->
|
||||
<div class="ma-cal__filters">
|
||||
<SelectButton
|
||||
v-model="timeMode"
|
||||
:options="timeModeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="false"
|
||||
size="small"
|
||||
/>
|
||||
<SelectButton
|
||||
v-model="onlySessions"
|
||||
:options="onlySessionsOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="false"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bloquear: ícone-only com Menu popup. Some em
|
||||
mobile (vai pra dentro de "Ações"). Disabled
|
||||
em modo standalone (sem composable M). -->
|
||||
<button
|
||||
v-for="opt in [{v:'dia',l:'Dia'},{v:'semana',l:'Semana'},{v:'mes',l:'Mês'},{v:'lista',l:'Lista'}]"
|
||||
:key="opt.v"
|
||||
class="ma-cal__view-btn"
|
||||
:class="{ 'is-active': calendarView === opt.v }"
|
||||
@click="setView(opt.v)"
|
||||
class="ma-cal__icon ma-cal__icon--desktop-only"
|
||||
:disabled="!M"
|
||||
v-tooltip.top="'Bloquear horário/dia'"
|
||||
@click="openBloqueioMenu"
|
||||
>
|
||||
{{ opt.l }}
|
||||
<i class="pi pi-ban" />
|
||||
</button>
|
||||
<Menu ref="bloqueioMenuRef" :model="bloqueioMenuItems" :popup="true" />
|
||||
|
||||
<!-- Ações — mobile only. Concentra timeMode +
|
||||
onlySessions + bloquear quando a toolbar fica
|
||||
apertada em telas <lg. -->
|
||||
<button
|
||||
class="ma-cal__icon ma-cal__icon--mobile-only"
|
||||
v-tooltip.top="'Ações'"
|
||||
@click="openMobileActions"
|
||||
>
|
||||
<i class="pi pi-ellipsis-v" />
|
||||
</button>
|
||||
<Menu ref="mobileActionsRef" :model="mobileActionsItems" :popup="true" />
|
||||
|
||||
<div class="ma-cal__view">
|
||||
<button
|
||||
v-for="opt in [{v:'dia',l:'Dia'},{v:'semana',l:'Semana'},{v:'mes',l:'Mês'},{v:'lista',l:'Lista'}]"
|
||||
:key="opt.v"
|
||||
class="ma-cal__view-btn"
|
||||
:class="{ 'is-active': calendarView === opt.v }"
|
||||
@click="setView(opt.v)"
|
||||
>
|
||||
{{ opt.l }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -874,6 +1152,7 @@ const kebabItems = computed(() => {
|
||||
/>
|
||||
|
||||
</aside>
|
||||
</div> <!-- /.ma-drawer (envolve side + cal + widgets) -->
|
||||
</div>
|
||||
|
||||
<!-- Popover + dialogs de cadastro (montados fora do scroll/aside
|
||||
@@ -1007,8 +1286,51 @@ const kebabItems = computed(() => {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
position: relative; /* âncora do backdrop fixed-relative em mobile */
|
||||
}
|
||||
|
||||
/* Drawer wrapper — display:contents desktop, virtual (não gera box).
|
||||
Em mobile (<lg) só serve como marcador `.is-open` pra CSS abaixo. */
|
||||
.ma-drawer {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Header actions cluster — Pacientes (mobile) + Configurações + Fechar. */
|
||||
.ma-page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.ma-head-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
padding: 7px 11px;
|
||||
border-radius: 9px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.ma-head-btn:hover { background: var(--m-bg-soft-hover); }
|
||||
.ma-head-btn > i { font-size: 0.85rem; }
|
||||
|
||||
/* Filtros desktop (timeMode + onlySessions). PrimeVue SelectButton
|
||||
absorve seu próprio styling — só precisamos do gap. */
|
||||
.ma-cal__filters {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Default: mobile-only fica oculto, desktop-only aparece (ambos os
|
||||
.ma-cal__icon de bloquear e ações). Inverte em @media abaixo. */
|
||||
.ma-cal__icon--mobile-only,
|
||||
.ma-head-btn--mobile-only { display: none; }
|
||||
|
||||
/* ═══ COL 1: Aside Pacientes ═════════════════════════════════ */
|
||||
.ma-side {
|
||||
width: 280px;
|
||||
@@ -1160,23 +1482,50 @@ const kebabItems = computed(() => {
|
||||
.ma-pat__kebab { display: grid; place-items: center; }
|
||||
}
|
||||
|
||||
/* Item fake "Adicionar paciente" — tracejado com + e label.
|
||||
Sempre primeiro na lista; abre PatientCreatePopover ao click. */
|
||||
.ma-pat--add {
|
||||
justify-content: center;
|
||||
/* Cluster de ações primárias da aside — Paciente + Agendar lado-a-lado,
|
||||
50/50, primary filled. Substitui o antigo .ma-pat--add tracejado. */
|
||||
.ma-side__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
color: var(--m-text-muted);
|
||||
border: 1.5px dashed var(--m-border-strong);
|
||||
background: transparent;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ma-pat--add:hover {
|
||||
color: var(--m-accent);
|
||||
border-color: var(--m-accent);
|
||||
background: var(--m-accent-soft);
|
||||
.ma-act-btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0; /* permite shrink no flex */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
.ma-act-btn > i {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.ma-act-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ma-act-btn--primary {
|
||||
color: white;
|
||||
background: var(--m-accent);
|
||||
border: 1px solid var(--m-accent);
|
||||
}
|
||||
.ma-act-btn--primary:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--m-accent) 88%, white);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.ma-act-btn--primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.ma-act-btn:focus-visible {
|
||||
outline: 2px solid var(--m-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.ma-pat--add i { font-size: 0.78rem; }
|
||||
.ma-pat__avatar {
|
||||
@@ -1301,6 +1650,13 @@ const kebabItems = computed(() => {
|
||||
text-overflow: ellipsis;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
/* Lado direito da toolbar — bloquear-menu + view-switcher lado a lado. */
|
||||
.ma-cal__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ma-cal__view {
|
||||
display: flex;
|
||||
background: var(--m-bg-soft);
|
||||
@@ -2034,4 +2390,86 @@ html:not(.app-dark) .ma-cal__fc :deep(.fc-timegrid-now-indicator-line) {
|
||||
border-width: 2px;
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Responsivo <lg (≤1023px)
|
||||
───────────────────────────────────────────────────────────────
|
||||
- .ma-cal vira fullwidth
|
||||
- .ma-side e .ma-widgets viram drawer off-canvas (slide da esquerda)
|
||||
- .ma-drawer.is-open ativa o slide-in
|
||||
- Filtros desktop (timeMode/onlySessions/bloquear icon) somem;
|
||||
"Ações" + Pacientes header buttons aparecem
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
@media (max-width: 1023px) {
|
||||
.ma-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Drawer: panels separados mas com transform sincronizado.
|
||||
Stack vertical: side ocupa 55% top, widgets 45% bottom. */
|
||||
.ma-drawer .ma-side,
|
||||
.ma-drawer .ma-widgets {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
width: min(360px, 88vw);
|
||||
z-index: 50;
|
||||
background: var(--m-bg-medium, rgba(20, 20, 20, 0.92));
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border-right: 1px solid var(--m-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ma-drawer .ma-side {
|
||||
top: 0;
|
||||
height: 55%;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
}
|
||||
.ma-drawer .ma-widgets {
|
||||
top: 55%;
|
||||
height: 45%;
|
||||
}
|
||||
.ma-drawer.is-open .ma-side,
|
||||
.ma-drawer.is-open .ma-widgets {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Calendar: full width, sem border-right (drawer agora é off-canvas) */
|
||||
.ma-cal {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Toolbar mobile — filtros desktop somem, "Ações" e bloquear-mobile aparecem.
|
||||
Pacientes button no header também. */
|
||||
.ma-cal__filters,
|
||||
.ma-cal__icon--desktop-only { display: none; }
|
||||
.ma-cal__icon--mobile-only { display: grid; place-items: center; }
|
||||
.ma-head-btn--mobile-only { display: inline-flex; }
|
||||
|
||||
/* Toolbar pode estourar com tantos elementos — permite wrap. */
|
||||
.ma-cal__toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Backdrop: fica entre o drawer e o resto. Click fecha. */
|
||||
.ma-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 49;
|
||||
}
|
||||
.ma-drawer-fade-enter-active,
|
||||
.ma-drawer-fade-leave-active {
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
.ma-drawer-fade-enter-from,
|
||||
.ma-drawer-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user