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,
|
* Mock visual (sem FullCalendar real). Quando integrar de verdade,
|
||||||
* trocar a área central pelo FullCalendar com tema glass.
|
* 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 FullCalendar from '@fullcalendar/vue3';
|
||||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||||
@@ -22,6 +23,7 @@ import listPlugin from '@fullcalendar/list';
|
|||||||
import interactionPlugin from '@fullcalendar/interaction';
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||||
import { useMelissaEventosRange } from './composables/useMelissaEventos';
|
import { useMelissaEventosRange } from './composables/useMelissaEventos';
|
||||||
|
import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
|
||||||
import { useFeriados } from '@/composables/useFeriados';
|
import { useFeriados } from '@/composables/useFeriados';
|
||||||
import { useTenantStore } from '@/stores/tenantStore';
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
|
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
|
||||||
@@ -45,11 +47,86 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['select-evento', 'close', 'patient-created']);
|
const emit = defineEmits(['select-evento', 'close', 'patient-created']);
|
||||||
|
|
||||||
// ── Estado ─────────────────────────────────────────────────────
|
// ── Estado ─────────────────────────────────────────────────────
|
||||||
|
const router = useRouter();
|
||||||
const busca = ref('');
|
const busca = ref('');
|
||||||
const pacienteSelecionadoId = ref(null);
|
const pacienteSelecionadoId = ref(null);
|
||||||
const calendarView = ref('semana'); // 'dia' | 'semana' | 'mes' | 'lista'
|
const calendarView = ref('semana'); // 'dia' | 'semana' | 'mes' | 'lista'
|
||||||
const refDate = ref(new Date()); // data de referência (sincronizada com o FC via datesSet)
|
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 filtrados + ordenação (novos no topo) ──────────
|
||||||
// Pacientes criados nas últimas NOVO_THRESHOLD_DIAS ficam no topo da lista,
|
// 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
|
// 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;
|
return hoje >= inicio && hoje < fim;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Range visível atual (passa pro composable que refetcha)
|
// Composable injetado pelo MelissaLayout — fonte única de eventos do FC
|
||||||
const viewStart = ref(new Date());
|
// (real + ocorrências virtuais via useRecurrence). viewStart/End vivem no
|
||||||
const viewEnd = ref(new Date());
|
// 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 hoje = new Date();
|
||||||
const dow = hoje.getDay();
|
const dow = hoje.getDay();
|
||||||
@@ -139,12 +221,19 @@ const viewEnd = ref(new Date());
|
|||||||
segunda.setHours(0, 0, 0, 0);
|
segunda.setHours(0, 0, 0, 0);
|
||||||
const domingoNext = new Date(segunda);
|
const domingoNext = new Date(segunda);
|
||||||
domingoNext.setDate(segunda.getDate() + 7);
|
domingoNext.setDate(segunda.getDate() + 7);
|
||||||
viewStart.value = segunda;
|
_viewStartLocal.value = segunda;
|
||||||
viewEnd.value = domingoNext;
|
_viewEndLocal.value = domingoNext;
|
||||||
refDate.value = hoje;
|
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)
|
// Mapa eventos.dateKey → array (pra widgets de stats e sessões hoje)
|
||||||
const eventosPorDia = computed(() => {
|
const eventosPorDia = computed(() => {
|
||||||
@@ -186,21 +275,67 @@ function statLabel(key) {
|
|||||||
// Pattern espelha AgendaTerapeutaPage:601.
|
// Pattern espelha AgendaTerapeutaPage:601.
|
||||||
const fcEvents = computed(() => {
|
const fcEvents = computed(() => {
|
||||||
const pred = statFiltroAtivo.value ? STAT_FILTERS[statFiltroAtivo.value] : () => true;
|
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 [
|
return [
|
||||||
...eventosSemana.value.filter(pred).map((ev) => ({
|
...eventosSemana.value
|
||||||
id: ev.id,
|
.filter(pred)
|
||||||
title: ev.label,
|
.filter(sessionPred)
|
||||||
start: ev.inicio_em,
|
.map((ev) => ({
|
||||||
end: ev.fim_em,
|
id: ev.id,
|
||||||
backgroundColor: `${ev.color}26`, // ~15% opacity
|
title: ev.label,
|
||||||
borderColor: ev.color,
|
start: ev.inicio_em,
|
||||||
textColor: 'white',
|
end: ev.fim_em,
|
||||||
extendedProps: ev
|
backgroundColor: `${ev.color}26`, // ~15% opacity
|
||||||
})),
|
borderColor: ev.color,
|
||||||
|
textColor: 'white',
|
||||||
|
extendedProps: ev
|
||||||
|
})),
|
||||||
...feriadoFcEvents.value
|
...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) {
|
function escHtml(s) {
|
||||||
return String(s ?? '')
|
return String(s ?? '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -226,8 +361,11 @@ const fcOptions = computed(() => ({
|
|||||||
initialView: VIEW_MAP[calendarView.value] || 'timeGridWeek',
|
initialView: VIEW_MAP[calendarView.value] || 'timeGridWeek',
|
||||||
initialDate: refDate.value,
|
initialDate: refDate.value,
|
||||||
nowIndicator: true,
|
nowIndicator: true,
|
||||||
editable: false, // preview: sem drag/resize ainda
|
// Drag/resize/select habilitam apenas com M (composable disponível) —
|
||||||
selectable: false, // preview: sem click-drag pra criar
|
// standalone (sem M) fica readonly por compat (preview puro).
|
||||||
|
editable: !!M,
|
||||||
|
selectable: !!M,
|
||||||
|
selectMirror: true,
|
||||||
weekends: true,
|
weekends: true,
|
||||||
// Header da semana ("seg 20/04") vira link clicável → vai pra view dia.
|
// 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").
|
// 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
|
allDaySlot: false, // tira a faixa "Dia inteiro" — recupera altura
|
||||||
// Slots de 30min com label em CADA linha (08:00, :30, 09:00, :30...)
|
// 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.
|
// Snap continua em 15min pra futura criação/drag de eventos.
|
||||||
slotMinTime: '08:00:00',
|
slotMinTime: slotMinTime.value,
|
||||||
slotMaxTime: '20:00:00',
|
slotMaxTime: slotMaxTime.value,
|
||||||
slotDuration: '00:30:00',
|
slotDuration: '00:30:00',
|
||||||
snapDuration: '00:15:00',
|
snapDuration: '00:15:00',
|
||||||
slotLabelInterval: '00:30:00',
|
slotLabelInterval: '00:30:00',
|
||||||
@@ -261,6 +399,23 @@ const fcOptions = computed(() => ({
|
|||||||
const ev = info.event.extendedProps;
|
const ev = info.event.extendedProps;
|
||||||
if (ev) emit('select-evento', ev);
|
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) => {
|
eventContent: (arg) => {
|
||||||
const ext = arg.event.extendedProps || {};
|
const ext = arg.event.extendedProps || {};
|
||||||
const titulo = arg.event.title || '—';
|
const titulo = arg.event.title || '—';
|
||||||
@@ -291,6 +446,20 @@ function setView(v) {
|
|||||||
fcApi()?.changeView(VIEW_MAP[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) {
|
function fmtHora(h) {
|
||||||
const horas = Math.floor(h);
|
const horas = Math.floor(h);
|
||||||
const mins = Math.round((h - horas) * 60);
|
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; } }
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -641,14 +824,48 @@ const kebabItems = computed(() => {
|
|||||||
<i class="pi pi-calendar text-emerald-300" />
|
<i class="pi pi-calendar text-emerald-300" />
|
||||||
<span>Agenda</span>
|
<span>Agenda</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="ma-close" title="Voltar ao resumo (Esc)" @click="emit('close')">
|
<div class="ma-page__actions">
|
||||||
<i class="pi pi-times" />
|
<!-- Pacientes — mobile only, abre o drawer com aside+widgets -->
|
||||||
</button>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div class="ma-body">
|
<div class="ma-body">
|
||||||
<!-- ════ COL 1: Hoje + Pacientes ════ -->
|
<!-- Backdrop: só visível em mobile com drawer aberto -->
|
||||||
<aside class="ma-side">
|
<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 -->
|
<!-- Hoje (stats + lista de sessões) — movido da col 3 -->
|
||||||
<div class="ma-w ma-w--side">
|
<div class="ma-w ma-w--side">
|
||||||
<div class="ma-w__head">
|
<div class="ma-w__head">
|
||||||
@@ -714,16 +931,28 @@ const kebabItems = computed(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="ma-side__list">
|
<div class="ma-side__list">
|
||||||
<!-- Item fake fixo "Adicionar paciente" — abre popover
|
<!-- Cluster de ações primárias — Paciente abre popover
|
||||||
com 3 opções (rápido, completo, link de cadastro). -->
|
com 3 opções (rápido, completo, link); Agendar abre
|
||||||
<button
|
AgendaEventDialog vazio (M.onCreateEvento). 50/50. -->
|
||||||
class="ma-pat ma-pat--add"
|
<div class="ma-side__actions">
|
||||||
title="Adicionar paciente"
|
<button
|
||||||
@click="openCreatePopover($event)"
|
class="ma-act-btn ma-act-btn--primary"
|
||||||
>
|
v-tooltip.top="'Adicionar paciente'"
|
||||||
<i class="pi pi-plus" />
|
@click="openCreatePopover($event)"
|
||||||
<span>Adicionar paciente</span>
|
>
|
||||||
</button>
|
<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
|
<div
|
||||||
v-for="p in pacientesFiltrados"
|
v-for="p in pacientesFiltrados"
|
||||||
@@ -785,16 +1014,65 @@ const kebabItems = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="ma-cal__period" :title="currentTitle">{{ currentTitle }}</div>
|
<div class="ma-cal__period" :title="currentTitle">{{ currentTitle }}</div>
|
||||||
</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
|
<button
|
||||||
v-for="opt in [{v:'dia',l:'Dia'},{v:'semana',l:'Semana'},{v:'mes',l:'Mês'},{v:'lista',l:'Lista'}]"
|
class="ma-cal__icon ma-cal__icon--desktop-only"
|
||||||
:key="opt.v"
|
:disabled="!M"
|
||||||
class="ma-cal__view-btn"
|
v-tooltip.top="'Bloquear horário/dia'"
|
||||||
:class="{ 'is-active': calendarView === opt.v }"
|
@click="openBloqueioMenu"
|
||||||
@click="setView(opt.v)"
|
|
||||||
>
|
>
|
||||||
{{ opt.l }}
|
<i class="pi pi-ban" />
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -874,6 +1152,7 @@ const kebabItems = computed(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
</div> <!-- /.ma-drawer (envolve side + cal + widgets) -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Popover + dialogs de cadastro (montados fora do scroll/aside
|
<!-- Popover + dialogs de cadastro (montados fora do scroll/aside
|
||||||
@@ -1007,8 +1286,51 @@ const kebabItems = computed(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
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 ═════════════════════════════════ */
|
/* ═══ COL 1: Aside Pacientes ═════════════════════════════════ */
|
||||||
.ma-side {
|
.ma-side {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
@@ -1160,23 +1482,50 @@ const kebabItems = computed(() => {
|
|||||||
.ma-pat__kebab { display: grid; place-items: center; }
|
.ma-pat__kebab { display: grid; place-items: center; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Item fake "Adicionar paciente" — tracejado com + e label.
|
/* Cluster de ações primárias da aside — Paciente + Agendar lado-a-lado,
|
||||||
Sempre primeiro na lista; abre PatientCreatePopover ao click. */
|
50/50, primary filled. Substitui o antigo .ma-pat--add tracejado. */
|
||||||
.ma-pat--add {
|
.ma-side__actions {
|
||||||
justify-content: center;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px;
|
margin-bottom: 8px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
.ma-pat--add:hover {
|
.ma-act-btn {
|
||||||
color: var(--m-accent);
|
flex: 1 1 0;
|
||||||
border-color: var(--m-accent);
|
min-width: 0; /* permite shrink no flex */
|
||||||
background: var(--m-accent-soft);
|
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--add i { font-size: 0.78rem; }
|
||||||
.ma-pat__avatar {
|
.ma-pat__avatar {
|
||||||
@@ -1301,6 +1650,13 @@ const kebabItems = computed(() => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
text-transform: capitalize;
|
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 {
|
.ma-cal__view {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--m-bg-soft);
|
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;
|
border-width: 2px;
|
||||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.35);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
<script setup>
|
||||||
|
/*
|
||||||
|
* MelissaEventoPanel — Painel de detalhes do evento selecionado.
|
||||||
|
* --------------------------------------------------
|
||||||
|
* Substitui o panel inline que vivia em MelissaLayout (era prone a crash
|
||||||
|
* por referenciar campos inexistentes no normalize: .valor, .participantes,
|
||||||
|
* .supervisorNome, .local).
|
||||||
|
*
|
||||||
|
* Renderiza apenas campos REAIS do useMelissaEventos.normalizeEvent:
|
||||||
|
* tipo, status, modalidade, descricao, pacienteNome, patient_id,
|
||||||
|
* color, label, startH, endH, inicio_em, fim_em
|
||||||
|
*
|
||||||
|
* Actions emitidas (parent decide o que fazer):
|
||||||
|
* - close
|
||||||
|
* - concluir / faltou / cancelar (mudança de status)
|
||||||
|
* - remarcar / edit (abre dialog de edição — TODO no parent)
|
||||||
|
* - abrir-prontuario / whatsapp / historico (paciente actions)
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
evento: { type: Object, required: true },
|
||||||
|
busy: { type: Boolean, default: false } // bloqueia botões enquanto UPDATE roda
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'close',
|
||||||
|
'concluir',
|
||||||
|
'faltou',
|
||||||
|
'cancelar',
|
||||||
|
'remarcar',
|
||||||
|
'edit',
|
||||||
|
'abrir-prontuario',
|
||||||
|
'whatsapp',
|
||||||
|
'historico'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ev = computed(() => props.evento || {});
|
||||||
|
|
||||||
|
const tipoLabel = computed(() => {
|
||||||
|
const t = String(ev.value.tipo || '').toLowerCase();
|
||||||
|
if (t === 'sessao') return 'Sessão';
|
||||||
|
if (t === 'supervisao' || t === 'supervisão') return 'Supervisão';
|
||||||
|
if (t === 'reuniao' || t === 'reunião') return 'Reunião';
|
||||||
|
if (t === 'bloqueio') return 'Bloqueio';
|
||||||
|
return t || 'Evento';
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabel = computed(() => {
|
||||||
|
const s = String(ev.value.status || '').toLowerCase();
|
||||||
|
if (!s || s === 'agendado') return 'Agendado';
|
||||||
|
if (s === 'realizado' || s === 'realizada') return 'Realizada';
|
||||||
|
if (s === 'faltou') return 'Faltou';
|
||||||
|
if (s === 'cancelado' || s === 'cancelada') return 'Cancelada';
|
||||||
|
if (s === 'remarcar') return 'A remarcar';
|
||||||
|
return ev.value.status;
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusSlug = computed(() => {
|
||||||
|
const s = String(ev.value.status || '').toLowerCase();
|
||||||
|
if (s === 'realizada') return 'realizado';
|
||||||
|
if (s === 'cancelada') return 'cancelado';
|
||||||
|
return s || 'agendado';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sessão com paciente vinculado mostra o grupo de actions de paciente
|
||||||
|
const isSessaoComPaciente = computed(
|
||||||
|
() => ev.value.tipo === 'sessao' && (ev.value.patient_id || ev.value.pacienteNome)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Status finais não permitem mudar pra outro status (UI mais clara)
|
||||||
|
const statusEhFinal = computed(() => /realizad|faltou|cancelad/i.test(ev.value.status || ''));
|
||||||
|
|
||||||
|
function fmtHora(decimal) {
|
||||||
|
if (decimal === null || decimal === undefined || Number.isNaN(decimal)) return '—';
|
||||||
|
const h = Math.floor(decimal);
|
||||||
|
const m = Math.round((decimal - h) * 60);
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function duracaoMin() {
|
||||||
|
const s = ev.value.startH;
|
||||||
|
const e = ev.value.endH;
|
||||||
|
if (typeof s !== 'number' || typeof e !== 'number') return null;
|
||||||
|
return Math.max(0, Math.round((e - s) * 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
function modalidadeIcon(mod) {
|
||||||
|
const m = String(mod || '').toLowerCase();
|
||||||
|
if (m === 'online') return 'pi pi-video';
|
||||||
|
return 'pi pi-map-marker';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="evento-layer" @click.self="emit('close')">
|
||||||
|
<div class="evento-panel">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="evento-head">
|
||||||
|
<div class="evento-head__main">
|
||||||
|
<div class="evento-pill" :style="{ backgroundColor: ev.color }" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="evento-tipo">{{ tipoLabel }}</div>
|
||||||
|
<div class="evento-titulo">
|
||||||
|
{{ isSessaoComPaciente ? ev.pacienteNome : (ev.label || ev.titulo || '—') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="glass-btn evento-close"
|
||||||
|
v-tooltip.left="'Fechar (Esc)'"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conteúdo (só campos reais) -->
|
||||||
|
<div class="evento-content">
|
||||||
|
<div class="evento-row">
|
||||||
|
<i class="pi pi-clock" />
|
||||||
|
<span>
|
||||||
|
{{ fmtHora(ev.startH) }} – {{ fmtHora(ev.endH) }}
|
||||||
|
<span v-if="duracaoMin() !== null" class="evento-row__sub">· {{ duracaoMin() }}min</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ev.modalidade" class="evento-row">
|
||||||
|
<i :class="modalidadeIcon(ev.modalidade)" />
|
||||||
|
<span class="capitalize">{{ ev.modalidade }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evento-row">
|
||||||
|
<i class="pi pi-info-circle" />
|
||||||
|
<span class="evento-status" :class="`is-${statusSlug}`">{{ statusLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ev.descricao" class="evento-desc">
|
||||||
|
{{ ev.descricao }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action bar — agrupada por contexto -->
|
||||||
|
<footer class="evento-actions">
|
||||||
|
<!-- Grupo Status — só pra sessão e quando ainda não é status final -->
|
||||||
|
<div v-if="isSessaoComPaciente && !statusEhFinal" class="evento-actions__group">
|
||||||
|
<button
|
||||||
|
class="evento-act evento-act--ok"
|
||||||
|
:disabled="busy"
|
||||||
|
v-tooltip.top="'Marcar como realizada'"
|
||||||
|
@click="emit('concluir')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-check-circle" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="evento-act evento-act--warn"
|
||||||
|
:disabled="busy"
|
||||||
|
v-tooltip.top="'Marcar como falta'"
|
||||||
|
@click="emit('faltou')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-user-minus" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="evento-act"
|
||||||
|
:disabled="busy"
|
||||||
|
v-tooltip.top="'Remarcar'"
|
||||||
|
@click="emit('remarcar')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-calendar-clock" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="evento-act evento-act--danger"
|
||||||
|
:disabled="busy"
|
||||||
|
v-tooltip.top="'Cancelar'"
|
||||||
|
@click="emit('cancelar')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-ban" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grupo Paciente — só pra sessão com paciente vinculado -->
|
||||||
|
<div v-if="isSessaoComPaciente" class="evento-actions__group">
|
||||||
|
<button
|
||||||
|
class="evento-act"
|
||||||
|
:disabled="busy"
|
||||||
|
v-tooltip.top="'Abrir prontuário'"
|
||||||
|
@click="emit('abrir-prontuario')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-file" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="evento-act"
|
||||||
|
:disabled="busy"
|
||||||
|
v-tooltip.top="'Conversar (WhatsApp)'"
|
||||||
|
@click="emit('whatsapp')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-whatsapp" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="evento-act"
|
||||||
|
:disabled="busy"
|
||||||
|
v-tooltip.top="'Histórico de sessões'"
|
||||||
|
@click="emit('historico')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-history" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grupo Geral — Editar sempre disponível -->
|
||||||
|
<div class="evento-actions__group">
|
||||||
|
<button
|
||||||
|
class="evento-act"
|
||||||
|
:disabled="busy"
|
||||||
|
v-tooltip.top="'Editar evento'"
|
||||||
|
@click="emit('edit')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-pencil" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Camada full-screen com backdrop blur — mantém pattern .evento-layer
|
||||||
|
que vivia inline no MelissaLayout (assim o lift transition no parent
|
||||||
|
continua funcionando sem alteração). */
|
||||||
|
.evento-layer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(20px) saturate(150%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
background: var(--m-bg-medium, rgba(20, 20, 20, 0.85));
|
||||||
|
backdrop-filter: blur(28px) saturate(170%);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(170%);
|
||||||
|
border: 1px solid var(--m-border, rgba(255, 255, 255, 0.12));
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
|
||||||
|
color: var(--m-text);
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
padding: 22px 22px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Header ────────────────────────────────────── */
|
||||||
|
.evento-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.evento-head__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.evento-pill {
|
||||||
|
width: 4px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.evento-tipo {
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.evento-titulo {
|
||||||
|
color: var(--m-text);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
.evento-close {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--m-bg-soft, rgba(255, 255, 255, 0.08));
|
||||||
|
border: 1px solid var(--m-border, rgba(255, 255, 255, 0.12));
|
||||||
|
color: var(--m-text);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 140ms ease;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.evento-close:hover { background: var(--m-bg-soft-hover, rgba(255, 255, 255, 0.16)); }
|
||||||
|
|
||||||
|
/* ─── Conteúdo ──────────────────────────────────── */
|
||||||
|
.evento-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.evento-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--m-text);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.evento-row > i {
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.evento-row__sub {
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.evento-status {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--m-border);
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
}
|
||||||
|
.evento-status.is-realizado {
|
||||||
|
color: rgb(16, 185, 129);
|
||||||
|
border-color: rgba(16, 185, 129, 0.35);
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
}
|
||||||
|
.evento-status.is-faltou {
|
||||||
|
color: rgb(239, 68, 68);
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
}
|
||||||
|
.evento-status.is-cancelado {
|
||||||
|
color: rgb(148, 163, 184);
|
||||||
|
border-color: rgba(148, 163, 184, 0.35);
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
}
|
||||||
|
.evento-status.is-remarcar {
|
||||||
|
color: rgb(245, 158, 11);
|
||||||
|
border-color: rgba(245, 158, 11, 0.35);
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
}
|
||||||
|
.evento-desc {
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
border: 1px solid var(--m-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--m-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Action bar ────────────────────────────────── */
|
||||||
|
.evento-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--m-border);
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.evento-actions__group {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
border: 1px solid var(--m-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.evento-act {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--m-text);
|
||||||
|
border-radius: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background-color 140ms ease, color 140ms ease, transform 140ms ease;
|
||||||
|
}
|
||||||
|
.evento-act:hover:not(:disabled) {
|
||||||
|
background: var(--m-bg-soft-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.evento-act:focus-visible {
|
||||||
|
outline: 2px solid var(--m-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.evento-act:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.evento-act--ok:hover:not(:disabled) {
|
||||||
|
color: rgb(16, 185, 129);
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
.evento-act--warn:hover:not(:disabled) {
|
||||||
|
color: rgb(245, 158, 11);
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
}
|
||||||
|
.evento-act--danger:hover:not(:disabled) {
|
||||||
|
color: rgb(239, 68, 68);
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode — overlay menos escuro */
|
||||||
|
html:not(.app-dark) .evento-layer {
|
||||||
|
background: rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,15 +14,27 @@
|
|||||||
*
|
*
|
||||||
* Rota atual (sandbox): /preview/melissa
|
* Rota atual (sandbox): /preview/melissa
|
||||||
*/
|
*/
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
import { ref, computed, watch, onMounted, onBeforeUnmount, provide } from 'vue';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { useLayout } from '@/layout/composables/layout';
|
||||||
|
import { applyThemeEngine } from '@/theme/theme.options';
|
||||||
|
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
|
||||||
import MelissaCronometro from './MelissaCronometro.vue';
|
import MelissaCronometro from './MelissaCronometro.vue';
|
||||||
import MelissaCard from './MelissaCard.vue';
|
import MelissaCard from './MelissaCard.vue';
|
||||||
import MelissaBusca from './MelissaBusca.vue';
|
import MelissaBusca from './MelissaBusca.vue';
|
||||||
import MelissaMenu from './MelissaMenu.vue';
|
import MelissaMenu from './MelissaMenu.vue';
|
||||||
import MelissaAgenda from './MelissaAgenda.vue';
|
import MelissaAgenda from './MelissaAgenda.vue';
|
||||||
|
import MelissaPacientes from './MelissaPacientes.vue';
|
||||||
|
import MelissaEventoPanel from './MelissaEventoPanel.vue';
|
||||||
import { TOQUES, playToque } from './melissaToques';
|
import { TOQUES, playToque } from './melissaToques';
|
||||||
import { useMelissaPacientes } from './composables/useMelissaPacientes';
|
import { useMelissaPacientes } from './composables/useMelissaPacientes';
|
||||||
import { useMelissaEventosHoje } from './composables/useMelissaEventos';
|
import { useMelissaEventosHoje } from './composables/useMelissaEventos';
|
||||||
|
import { useMelissaAgenda, MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
|
||||||
|
import { supabase } from '@/lib/supabase/client';
|
||||||
|
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||||
|
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
|
||||||
|
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
|
||||||
|
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue';
|
||||||
|
|
||||||
// Pacientes ativos do tenant (real, via Supabase)
|
// Pacientes ativos do tenant (real, via Supabase)
|
||||||
const { pacientes: pacientesReais, refetch: refetchPacientes } = useMelissaPacientes();
|
const { pacientes: pacientesReais, refetch: refetchPacientes } = useMelissaPacientes();
|
||||||
@@ -142,15 +154,35 @@ const saudacao = computed(() => {
|
|||||||
// Background customizável
|
// Background customizável
|
||||||
// ───────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
const bgUrl = ref(''); // vazio = usa gradiente default
|
const bgUrl = ref(''); // vazio = usa gradiente default
|
||||||
const overlayOpacity = ref(0.35); // 0–0.8
|
const overlayOpacity = ref(0.35); // 0–0.8 — escurecedor sobre o bg
|
||||||
|
const bgImageOpacity = ref(1); // 0.01–1 — transparência da foto custom
|
||||||
const fileInput = ref(null);
|
const fileInput = ref(null);
|
||||||
|
|
||||||
|
// Limite de upload — protege quota do localStorage (~5MB) e evita data URL
|
||||||
|
// gigante atravessando a UI. JPG/PNG 1920×1080 cabe folgado nesse teto.
|
||||||
|
const MAX_BG_BYTES = 2 * 1024 * 1024; // 2 MB
|
||||||
|
|
||||||
function pickFile() {
|
function pickFile() {
|
||||||
fileInput.value?.click();
|
fileInput.value?.click();
|
||||||
}
|
}
|
||||||
function onFileChange(e) {
|
function onFileChange(e) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Formato inválido', detail: 'Selecione um arquivo de imagem (JPG, PNG, WEBP).', life: 4000 });
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_BG_BYTES) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Imagem muito grande',
|
||||||
|
detail: 'Máximo 2 MB. Reduza a resolução ou compressão e tente novamente.',
|
||||||
|
life: 4500
|
||||||
|
});
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (ev) => (bgUrl.value = ev.target.result);
|
reader.onload = (ev) => (bgUrl.value = ev.target.result);
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
@@ -159,23 +191,68 @@ function clearBg() {
|
|||||||
bgUrl.value = '';
|
bgUrl.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const bgStyle = computed(() => {
|
// Gradiente default — sempre renderizado no .win11-root (atrás de tudo).
|
||||||
if (bgUrl.value) {
|
// Quando o user faz upload, .win11-photo aparece por cima com opacidade
|
||||||
return {
|
// controlada pelo slider — permite blend natural com o gradiente abaixo.
|
||||||
backgroundImage: `url(${bgUrl.value})`,
|
// Cores vêm de CSS vars que flipam com dark/light AND seguem o preset
|
||||||
backgroundSize: 'cover',
|
// (ver style global no fim do arquivo: --bloom-c1/c2/base-1/base-2).
|
||||||
backgroundPosition: 'center'
|
const defaultBgStyle = {
|
||||||
};
|
backgroundImage:
|
||||||
}
|
'radial-gradient(circle at 70% 30%, var(--bloom-c1) 0%, transparent 55%), radial-gradient(circle at 25% 75%, var(--bloom-c2) 0%, transparent 50%), linear-gradient(135deg, var(--bloom-base-1) 0%, var(--bloom-base-2) 50%, var(--bloom-base-1) 100%)',
|
||||||
// Default: gradiente "Bloom"-ish (Win11) — cores vêm de CSS vars que flipam
|
backgroundSize: 'cover'
|
||||||
// automaticamente com dark/light AND seguem o preset escolhido (ver style
|
};
|
||||||
// global no fim do arquivo: --bloom-c1/c2/base-1/base-2).
|
|
||||||
return {
|
const photoStyle = computed(() => ({
|
||||||
backgroundImage:
|
backgroundImage: bgUrl.value ? `url(${bgUrl.value})` : 'none',
|
||||||
'radial-gradient(circle at 70% 30%, var(--bloom-c1) 0%, transparent 55%), radial-gradient(circle at 25% 75%, var(--bloom-c2) 0%, transparent 50%), linear-gradient(135deg, var(--bloom-base-1) 0%, var(--bloom-base-2) 50%, var(--bloom-base-1) 100%)',
|
opacity: bgImageOpacity.value
|
||||||
backgroundSize: 'cover'
|
}));
|
||||||
};
|
|
||||||
});
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
// Tema (dark/light + cor primária) — usa a infra existente do app
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
// `toggleDarkMode` flipa a classe .app-dark + layoutConfig.darkTheme.
|
||||||
|
// `applyThemeEngine` re-aplica o preset com nova primary/surface.
|
||||||
|
// `userSettings.queuePatch` persiste no DB (debounced upsert em
|
||||||
|
// user_settings — colunas theme_mode/primary_color, não toca em melissa_prefs).
|
||||||
|
const { layoutConfig, toggleDarkMode, isDarkTheme } = useLayout();
|
||||||
|
const userSettings = useUserSettingsPersistence();
|
||||||
|
onMounted(() => userSettings.init());
|
||||||
|
|
||||||
|
// Paleta enxuta — espelha primaryColors da ProfilePage. 'noir' usa
|
||||||
|
// currentColor (preto/branco conforme tema) — visualmente vira a primary
|
||||||
|
// "neutra" pro user que quer monocromático.
|
||||||
|
const PRIMARY_COLORS = [
|
||||||
|
{ name: 'noir', swatch: 'currentColor' },
|
||||||
|
{ name: 'emerald', swatch: '#10b981' },
|
||||||
|
{ name: 'green', swatch: '#22c55e' },
|
||||||
|
{ name: 'lime', swatch: '#84cc16' },
|
||||||
|
{ name: 'orange', swatch: '#f97316' },
|
||||||
|
{ name: 'amber', swatch: '#f59e0b' },
|
||||||
|
{ name: 'yellow', swatch: '#eab308' },
|
||||||
|
{ name: 'teal', swatch: '#14b8a6' },
|
||||||
|
{ name: 'cyan', swatch: '#06b6d4' },
|
||||||
|
{ name: 'sky', swatch: '#0ea5e9' },
|
||||||
|
{ name: 'blue', swatch: '#3b82f6' },
|
||||||
|
{ name: 'indigo', swatch: '#6366f1' },
|
||||||
|
{ name: 'violet', swatch: '#8b5cf6' },
|
||||||
|
{ name: 'purple', swatch: '#a855f7' },
|
||||||
|
{ name: 'fuchsia', swatch: '#d946ef' },
|
||||||
|
{ name: 'pink', swatch: '#ec4899' },
|
||||||
|
{ name: 'rose', swatch: '#f43f5e' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function setDark(shouldBeDark) {
|
||||||
|
if (isDarkTheme.value === shouldBeDark) return;
|
||||||
|
toggleDarkMode();
|
||||||
|
userSettings.queuePatch({ theme_mode: shouldBeDark ? 'dark' : 'light' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPrimary(name) {
|
||||||
|
if (!name || layoutConfig.primary === name) return;
|
||||||
|
layoutConfig.primary = name;
|
||||||
|
applyThemeEngine(layoutConfig);
|
||||||
|
userSettings.queuePatch({ primary_color: name });
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
// Settings popover (canto superior direito)
|
// Settings popover (canto superior direito)
|
||||||
@@ -202,11 +279,6 @@ function fmtHora(h) {
|
|||||||
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tipoLabel(tipo) {
|
|
||||||
const map = { sessao: 'Atendimento', supervisao: 'Supervisão', reuniao: 'Reunião' };
|
|
||||||
return map[tipo] || tipo;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contagens por tipo + frase resumo do dia
|
// Contagens por tipo + frase resumo do dia
|
||||||
const contagensDia = computed(() => {
|
const contagensDia = computed(() => {
|
||||||
const c = { sessao: 0, supervisao: 0, reuniao: 0 };
|
const c = { sessao: 0, supervisao: 0, reuniao: 0 };
|
||||||
@@ -230,11 +302,124 @@ const resumoPartes = computed(() => {
|
|||||||
|
|
||||||
// Evento selecionado (dialog de detalhes)
|
// Evento selecionado (dialog de detalhes)
|
||||||
const eventoSelecionado = ref(null);
|
const eventoSelecionado = ref(null);
|
||||||
|
const eventoBusy = ref(false); // bloqueia botões enquanto UPDATE roda
|
||||||
|
const melissaAgendaRef = ref(null); // pra chamar openProntuario + setView
|
||||||
|
const toast = useToast();
|
||||||
|
const conversationDrawerStore = useConversationDrawerStore();
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
// Agenda completa (CRUD + recorrência via AgendaEventDialog)
|
||||||
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
// Composable injetado em MelissaAgenda via provide/inject — orquestra
|
||||||
|
// useAgendaEvents + useRecurrence + useDeterminedCommitments + useFeriados
|
||||||
|
// e expõe dialog state + handlers (save/delete/série/persistMoveOrResize).
|
||||||
|
// MelissaAgenda lê M.eventos pra alimentar o FullCalendar e mutará
|
||||||
|
// M.viewStart/M.viewEnd via FC.datesSet.
|
||||||
|
const M = useMelissaAgenda();
|
||||||
|
provide(MELISSA_AGENDA_KEY, M);
|
||||||
|
|
||||||
|
// Destrutura refs/computeds pro template auto-unwrappar (refs em objetos
|
||||||
|
// aninhados NÃO unwrappam no template — só os top-level do setup).
|
||||||
|
const {
|
||||||
|
dialogOpen: agendaDialogOpen,
|
||||||
|
dialogEventRow: agendaDialogEventRow,
|
||||||
|
dialogStartISO: agendaDialogStartISO,
|
||||||
|
dialogEndISO: agendaDialogEndISO,
|
||||||
|
ownerId: agendaOwnerId,
|
||||||
|
clinicTenantId: agendaClinicTenantId,
|
||||||
|
commitmentOptions: agendaCommitmentOptions,
|
||||||
|
workRules: agendaWorkRules,
|
||||||
|
settings: agendaSettings,
|
||||||
|
allEventsForDialog: agendaAllEvents,
|
||||||
|
feriados: agendaFeriados,
|
||||||
|
bloqueioDialogOpen: agendaBloqueioOpen,
|
||||||
|
bloqueioMode: agendaBloqueioMode
|
||||||
|
} = M;
|
||||||
|
|
||||||
function abrirEvento(ev) {
|
function abrirEvento(ev) {
|
||||||
eventoSelecionado.value = ev;
|
eventoSelecionado.value = ev;
|
||||||
}
|
}
|
||||||
function fecharEvento() {
|
function fecharEvento() {
|
||||||
eventoSelecionado.value = null;
|
eventoSelecionado.value = null;
|
||||||
|
eventoBusy.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Actions do MelissaEventoPanel ──────────────────────────────
|
||||||
|
// updateStatus: muda status no DB e refetcha agenda. Pattern espelha
|
||||||
|
// AgendaTerapeutaPage (sem optimistic update por simplicidade no MVP).
|
||||||
|
async function updateEventoStatus(novoStatus, msgSucesso) {
|
||||||
|
const ev = eventoSelecionado.value;
|
||||||
|
if (!ev?.id || eventoBusy.value) return;
|
||||||
|
eventoBusy.value = true;
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('agenda_eventos')
|
||||||
|
.update({ status: novoStatus })
|
||||||
|
.eq('id', ev.id);
|
||||||
|
if (error) throw error;
|
||||||
|
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
|
||||||
|
// Refetch via composable (eventos reais + ocorrências virtuais)
|
||||||
|
M.refetch();
|
||||||
|
fecharEvento();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e?.message || 'Erro ao atualizar evento';
|
||||||
|
toast.add({ severity: 'error', summary: 'Falha ao atualizar', detail: msg, life: 4000 });
|
||||||
|
eventoBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConcluir() { updateEventoStatus('realizado', 'Sessão marcada como realizada'); }
|
||||||
|
function onFaltou() { updateEventoStatus('faltou', 'Marcada como falta'); }
|
||||||
|
function onCancelar() { updateEventoStatus('cancelado', 'Evento cancelado'); }
|
||||||
|
|
||||||
|
function onWhatsapp() {
|
||||||
|
const ev = eventoSelecionado.value;
|
||||||
|
if (!ev?.patient_id) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Paciente sem id', life: 2200 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
conversationDrawerStore.openForPatient(String(ev.patient_id));
|
||||||
|
fecharEvento();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAbrirProntuario() {
|
||||||
|
const ev = eventoSelecionado.value;
|
||||||
|
if (!ev?.patient_id) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Paciente não vinculado', life: 2200 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = pacientesReais.value.find((x) => String(x.id) === String(ev.patient_id));
|
||||||
|
if (!p) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Paciente não encontrado na lista', life: 2200 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
melissaAgendaRef.value?.openProntuario?.(p);
|
||||||
|
fecharEvento();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHistoricoSessoes() {
|
||||||
|
// MVP: vai pra view 'lista' do FC (mesmo pattern do dock contextual).
|
||||||
|
// Filtro real por paciente fica pra fase futura.
|
||||||
|
melissaAgendaRef.value?.setView?.('lista');
|
||||||
|
fecharEvento();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditEvento() {
|
||||||
|
// Abre AgendaEventDialog completo via composable. `_raw` carrega o row
|
||||||
|
// bruto que o dialog precisa (campos de recorrência, financeiro, etc.).
|
||||||
|
const ev = eventoSelecionado.value;
|
||||||
|
if (!ev?._raw) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Não foi possível abrir o editor', life: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
M.onEditEvento(ev._raw);
|
||||||
|
fecharEvento();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemarcar() {
|
||||||
|
// MVP: muda status pra 'remarcar'. Reagendar de fato (mover horário)
|
||||||
|
// sai daqui pra dentro do AgendaEventDialog, via "Editar".
|
||||||
|
updateEventoStatus('remarcar', 'Marcada pra remarcar');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtro da timeline por tipo (clicado a partir do resumo)
|
// Filtro da timeline por tipo (clicado a partir do resumo)
|
||||||
@@ -351,18 +536,59 @@ function testarToque() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
// Persistência de prefs UI (localStorage)
|
// Persistência de prefs UI
|
||||||
// Só sobrevive a refresh; vai migrar pras configs do tenant depois.
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
function saveLayoutPrefs() {
|
// Camadas:
|
||||||
const prefs = {
|
// 1. localStorage — cache rápido pra evitar flash no boot e hold do bgUrl
|
||||||
|
// (data URL pesada não vai pro DB)
|
||||||
|
// 2. user_settings.melissa_prefs (jsonb) — fonte de verdade pras prefs
|
||||||
|
// pequenas (toque, opacidades, formato hora, cards). Sobrevive a
|
||||||
|
// troca de navegador/dispositivo.
|
||||||
|
//
|
||||||
|
// Fluxo: onMounted → load localStorage (paint imediato) → load DB (sobrescreve
|
||||||
|
// se houver). Watch das refs → save localStorage (sync) + save DB (debounced).
|
||||||
|
|
||||||
|
// Sanitiza um payload de prefs (DB ou localStorage — ambos são input externo)
|
||||||
|
function applyPrefsPayload(prefs) {
|
||||||
|
if (!prefs || typeof prefs !== 'object') return;
|
||||||
|
|
||||||
|
if (typeof prefs.toqueTermino === 'string' && TOQUE_IDS.has(prefs.toqueTermino)) {
|
||||||
|
toqueTermino.value = prefs.toqueTermino;
|
||||||
|
}
|
||||||
|
const op = Number(prefs.overlayOpacity);
|
||||||
|
if (Number.isFinite(op) && op >= 0 && op <= 0.8) {
|
||||||
|
overlayOpacity.value = op;
|
||||||
|
}
|
||||||
|
const bo = Number(prefs.bgImageOpacity);
|
||||||
|
if (Number.isFinite(bo) && bo >= 0.01 && bo <= 1) {
|
||||||
|
bgImageOpacity.value = bo;
|
||||||
|
}
|
||||||
|
if (typeof prefs.use24h === 'boolean') {
|
||||||
|
use24h.value = prefs.use24h;
|
||||||
|
}
|
||||||
|
if (Array.isArray(prefs.cardsAtivos)) {
|
||||||
|
const valid = prefs.cardsAtivos.filter((id) => typeof id === 'string' && CARD_IDS.has(id));
|
||||||
|
if (valid.length > 0) cardsAtivos.value = valid;
|
||||||
|
}
|
||||||
|
if (prefs.cardsLayout === 'linha-unica' || prefs.cardsLayout === 'duas-linhas') {
|
||||||
|
cardsLayout.value = prefs.cardsLayout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPrefsSnapshot() {
|
||||||
|
return {
|
||||||
toqueTermino: toqueTermino.value,
|
toqueTermino: toqueTermino.value,
|
||||||
overlayOpacity: overlayOpacity.value,
|
overlayOpacity: overlayOpacity.value,
|
||||||
|
bgImageOpacity: bgImageOpacity.value,
|
||||||
use24h: use24h.value,
|
use24h: use24h.value,
|
||||||
bgUrl: bgUrl.value || '',
|
|
||||||
cardsAtivos: cardsAtivos.value,
|
cardsAtivos: cardsAtivos.value,
|
||||||
cardsLayout: cardsLayout.value
|
cardsLayout: cardsLayout.value
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLayoutPrefs() {
|
||||||
|
// localStorage inclui bgUrl (data URL); DB não — bgUrl fica local
|
||||||
|
const prefs = { ...currentPrefsSnapshot(), bgUrl: bgUrl.value || '' };
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(prefs));
|
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(prefs));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -374,7 +600,7 @@ function saveLayoutPrefs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLayoutPrefs() {
|
function loadLocalPrefs() {
|
||||||
let raw;
|
let raw;
|
||||||
try { raw = localStorage.getItem(LAYOUT_STORAGE_KEY); } catch { return; }
|
try { raw = localStorage.getItem(LAYOUT_STORAGE_KEY); } catch { return; }
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
@@ -383,40 +609,78 @@ function loadLayoutPrefs() {
|
|||||||
try { prefs = JSON.parse(raw); } catch { return; }
|
try { prefs = JSON.parse(raw); } catch { return; }
|
||||||
if (!prefs || typeof prefs !== 'object') return;
|
if (!prefs || typeof prefs !== 'object') return;
|
||||||
|
|
||||||
// Sanitização — cada campo é input externo, não confiar
|
applyPrefsPayload(prefs);
|
||||||
if (typeof prefs.toqueTermino === 'string' && TOQUE_IDS.has(prefs.toqueTermino)) {
|
|
||||||
toqueTermino.value = prefs.toqueTermino;
|
|
||||||
}
|
|
||||||
const op = Number(prefs.overlayOpacity);
|
|
||||||
if (Number.isFinite(op) && op >= 0 && op <= 0.8) {
|
|
||||||
overlayOpacity.value = op;
|
|
||||||
}
|
|
||||||
if (typeof prefs.use24h === 'boolean') {
|
|
||||||
use24h.value = prefs.use24h;
|
|
||||||
}
|
|
||||||
// bgUrl: aceita só data URL de imagem (evita injeção de URL externa via storage)
|
// bgUrl: aceita só data URL de imagem (evita injeção de URL externa via storage)
|
||||||
if (typeof prefs.bgUrl === 'string' && prefs.bgUrl.startsWith('data:image/')) {
|
if (typeof prefs.bgUrl === 'string' && prefs.bgUrl.startsWith('data:image/')) {
|
||||||
bgUrl.value = prefs.bgUrl;
|
bgUrl.value = prefs.bgUrl;
|
||||||
}
|
}
|
||||||
// cardsAtivos: filtra ids inválidos (catálogo pode ter mudado entre versões)
|
}
|
||||||
if (Array.isArray(prefs.cardsAtivos)) {
|
|
||||||
const valid = prefs.cardsAtivos.filter((id) => typeof id === 'string' && CARD_IDS.has(id));
|
// ── DB sync ────────────────────────────────────────────────────
|
||||||
if (valid.length > 0) cardsAtivos.value = valid;
|
let dbSaveTimer = null;
|
||||||
|
let dbReady = false; // só salva no DB depois do load inicial (evita sobrescrever c/ defaults)
|
||||||
|
|
||||||
|
async function loadDbPrefs() {
|
||||||
|
try {
|
||||||
|
const { data: u } = await supabase.auth.getUser();
|
||||||
|
const uid = u?.user?.id;
|
||||||
|
if (!uid) return;
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('user_settings')
|
||||||
|
.select('melissa_prefs')
|
||||||
|
.eq('user_id', uid)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) return;
|
||||||
|
if (data?.melissa_prefs && typeof data.melissa_prefs === 'object') {
|
||||||
|
applyPrefsPayload(data.melissa_prefs);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silencioso — falha de rede/auth não pode quebrar a UI
|
||||||
|
} finally {
|
||||||
|
dbReady = true;
|
||||||
}
|
}
|
||||||
if (prefs.cardsLayout === 'linha-unica' || prefs.cardsLayout === 'duas-linhas') {
|
}
|
||||||
cardsLayout.value = prefs.cardsLayout;
|
|
||||||
|
async function saveDbPrefs() {
|
||||||
|
if (!dbReady) return;
|
||||||
|
try {
|
||||||
|
const { data: u } = await supabase.auth.getUser();
|
||||||
|
const uid = u?.user?.id;
|
||||||
|
if (!uid) return;
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from('user_settings')
|
||||||
|
.upsert(
|
||||||
|
{ user_id: uid, melissa_prefs: currentPrefsSnapshot(), updated_at: new Date().toISOString() },
|
||||||
|
{ onConflict: 'user_id' }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// silencioso — pref save não-crítico; localStorage segura o estado até voltar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queueDbSave() {
|
||||||
|
if (dbSaveTimer) clearTimeout(dbSaveTimer);
|
||||||
|
dbSaveTimer = setTimeout(saveDbPrefs, 600);
|
||||||
|
}
|
||||||
|
|
||||||
// Salva em qualquer mudança das prefs (deep no array de cardsAtivos pra pegar splice/push)
|
// Salva em qualquer mudança das prefs (deep no array de cardsAtivos pra pegar splice/push)
|
||||||
watch(
|
watch(
|
||||||
[toqueTermino, overlayOpacity, use24h, bgUrl, cardsAtivos, cardsLayout],
|
[toqueTermino, overlayOpacity, bgImageOpacity, use24h, bgUrl, cardsAtivos, cardsLayout],
|
||||||
saveLayoutPrefs,
|
() => {
|
||||||
|
saveLayoutPrefs();
|
||||||
|
queueDbSave();
|
||||||
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Carrega antes do paint (use onMounted; o flash inicial é aceitável)
|
// Carrega antes do paint (use onMounted; o flash inicial é aceitável)
|
||||||
onMounted(loadLayoutPrefs);
|
onMounted(async () => {
|
||||||
|
loadLocalPrefs(); // sync: paint imediato com valores cached
|
||||||
|
await loadDbPrefs(); // async: sobrescreve com valores autoritativos do DB
|
||||||
|
});
|
||||||
|
|
||||||
// ───────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
// Workspace overlay
|
// Workspace overlay
|
||||||
@@ -463,7 +727,12 @@ function onKeydown(e) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="win11-root" :class="{ 'win11-has-photo': !!bgUrl }" :style="bgStyle">
|
<div class="win11-root" :class="{ 'win11-has-photo': !!bgUrl }" :style="defaultBgStyle">
|
||||||
|
<!-- Camada da foto custom (acima do gradiente, abaixo do dim).
|
||||||
|
Opacidade controlada pelo slider — permite blend com o gradiente
|
||||||
|
default que vive no .win11-root. -->
|
||||||
|
<div v-if="bgUrl" class="win11-photo" :style="photoStyle" />
|
||||||
|
|
||||||
<!-- Overlay escurecedor (controlado pelo slider) -->
|
<!-- Overlay escurecedor (controlado pelo slider) -->
|
||||||
<div class="win11-dim" :style="{ backgroundColor: `rgba(var(--m-dim-rgb), ${overlayOpacity})` }" />
|
<div class="win11-dim" :style="{ backgroundColor: `rgba(var(--m-dim-rgb), ${overlayOpacity})` }" />
|
||||||
|
|
||||||
@@ -489,12 +758,15 @@ function onKeydown(e) {
|
|||||||
<div class="text-xs uppercase tracking-widest text-white/60 mb-3">Personalização</div>
|
<div class="text-xs uppercase tracking-widest text-white/60 mb-3">Personalização</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="w-full text-left px-3 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 mb-2 flex items-center gap-2 text-sm"
|
class="w-full text-left px-3 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 mb-1.5 flex items-center gap-2 text-sm"
|
||||||
@click="pickFile"
|
@click="pickFile"
|
||||||
>
|
>
|
||||||
<i class="pi pi-image" />
|
<i class="pi pi-image" />
|
||||||
Trocar imagem de fundo
|
Trocar imagem de fundo
|
||||||
</button>
|
</button>
|
||||||
|
<p class="text-[0.68rem] leading-snug text-white/50 px-1 mb-2.5">
|
||||||
|
Recomendado: 1920×1080 (Full HD), JPG ou PNG. Tamanho máximo: 2 MB.
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
v-if="bgUrl"
|
v-if="bgUrl"
|
||||||
class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/10 mb-3 flex items-center gap-2 text-sm text-white/70"
|
class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/10 mb-3 flex items-center gap-2 text-sm text-white/70"
|
||||||
@@ -505,6 +777,20 @@ function onKeydown(e) {
|
|||||||
</button>
|
</button>
|
||||||
<input ref="fileInput" type="file" accept="image/*" hidden @change="onFileChange" />
|
<input ref="fileInput" type="file" accept="image/*" hidden @change="onFileChange" />
|
||||||
|
|
||||||
|
<div v-if="bgUrl" class="mb-3">
|
||||||
|
<label class="text-xs text-white/60 mb-1.5 block">
|
||||||
|
Transparência da imagem: {{ Math.round(bgImageOpacity * 100) }}%
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="bgImageOpacity"
|
||||||
|
type="range"
|
||||||
|
min="0.01"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
class="settings-range w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="text-xs text-white/60 mb-1.5 block">
|
<label class="text-xs text-white/60 mb-1.5 block">
|
||||||
Opacidade do fundo: {{ Math.round(overlayOpacity * 100) }}%
|
Opacidade do fundo: {{ Math.round(overlayOpacity * 100) }}%
|
||||||
@@ -515,7 +801,7 @@ function onKeydown(e) {
|
|||||||
min="0"
|
min="0"
|
||||||
max="0.8"
|
max="0.8"
|
||||||
step="0.05"
|
step="0.05"
|
||||||
class="w-full accent-white"
|
class="settings-range w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -538,11 +824,14 @@ function onKeydown(e) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm mb-3">
|
||||||
<span class="text-white/80">Formato 24h</span>
|
<span class="text-white/80">
|
||||||
|
Formato 24h
|
||||||
|
<span class="text-white/45 text-[0.7rem]">(relógio)</span>
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
class="w-10 h-6 rounded-full transition-colors relative"
|
class="w-10 h-6 rounded-full transition-colors relative settings-toggle"
|
||||||
:class="use24h ? 'bg-emerald-500' : 'bg-white/20'"
|
:class="use24h ? 'is-on' : 'bg-white/20'"
|
||||||
@click="use24h = !use24h"
|
@click="use24h = !use24h"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -551,6 +840,36 @@ function onKeydown(e) {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-sm mb-3">
|
||||||
|
<span class="text-white/80">Modo escuro</span>
|
||||||
|
<button
|
||||||
|
class="w-10 h-6 rounded-full transition-colors relative settings-toggle"
|
||||||
|
:class="isDarkTheme ? 'is-on' : 'bg-white/20'"
|
||||||
|
@click="setDark(!isDarkTheme)"
|
||||||
|
:title="isDarkTheme ? 'Mudar pra claro' : 'Mudar pra escuro'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
|
||||||
|
:style="{ left: isDarkTheme ? '1.125rem' : '0.125rem' }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-white/60 mb-1.5 block">Cor primária</label>
|
||||||
|
<div class="grid grid-cols-9 gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="pc in PRIMARY_COLORS"
|
||||||
|
:key="pc.name"
|
||||||
|
class="settings-swatch"
|
||||||
|
:class="{ 'is-active': layoutConfig.primary === pc.name }"
|
||||||
|
:style="{ backgroundColor: pc.swatch === 'currentColor' ? 'var(--m-text)' : pc.swatch }"
|
||||||
|
:title="pc.name"
|
||||||
|
@click="setPrimary(pc.name)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
@@ -847,96 +1166,20 @@ function onKeydown(e) {
|
|||||||
<!-- DIALOG — Evento da timeline (sessão / supervisão / reunião) -->
|
<!-- DIALOG — Evento da timeline (sessão / supervisão / reunião) -->
|
||||||
<!-- ═══════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
<Transition name="lift">
|
<Transition name="lift">
|
||||||
<div
|
<MelissaEventoPanel
|
||||||
v-if="eventoSelecionado"
|
v-if="eventoSelecionado"
|
||||||
class="evento-layer"
|
:evento="eventoSelecionado"
|
||||||
@click.self="fecharEvento"
|
:busy="eventoBusy"
|
||||||
>
|
@close="fecharEvento"
|
||||||
<div class="evento-panel">
|
@concluir="onConcluir"
|
||||||
<!-- Header -->
|
@faltou="onFaltou"
|
||||||
<div class="flex items-start justify-between mb-5">
|
@cancelar="onCancelar"
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
@remarcar="onRemarcar"
|
||||||
<div class="evento-pill" :style="{ backgroundColor: eventoSelecionado.color }" />
|
@edit="onEditEvento"
|
||||||
<div class="min-w-0">
|
@abrir-prontuario="onAbrirProntuario"
|
||||||
<div class="text-white/55 text-xs uppercase tracking-[0.2em]">
|
@whatsapp="onWhatsapp"
|
||||||
{{ tipoLabel(eventoSelecionado.tipo) }}
|
@historico="onHistoricoSessoes"
|
||||||
</div>
|
/>
|
||||||
<div class="text-white text-lg font-light mt-1 truncate">
|
|
||||||
{{ eventoSelecionado.tipo === 'sessao'
|
|
||||||
? eventoSelecionado.pacienteNome
|
|
||||||
: eventoSelecionado.label }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="glass-btn w-9 h-9 grid place-items-center shrink-0"
|
|
||||||
title="Fechar (Esc)"
|
|
||||||
@click="fecharEvento"
|
|
||||||
>
|
|
||||||
<i class="pi pi-times text-white/90 text-sm" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Conteúdo -->
|
|
||||||
<div class="flex flex-col gap-3 mb-6">
|
|
||||||
<div class="evento-row">
|
|
||||||
<i class="pi pi-clock" />
|
|
||||||
<span>{{ fmtHora(eventoSelecionado.startH) }} – {{ fmtHora(eventoSelecionado.endH) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="eventoSelecionado.tipo === 'sessao'">
|
|
||||||
<div class="evento-row">
|
|
||||||
<i class="pi pi-map-marker" />
|
|
||||||
<span>{{ eventoSelecionado.modalidade }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="evento-row">
|
|
||||||
<i class="pi pi-dollar" />
|
|
||||||
<span>R$ {{ eventoSelecionado.valor.toFixed(2).replace('.', ',') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="evento-row">
|
|
||||||
<i class="pi pi-info-circle" />
|
|
||||||
<span>{{ eventoSelecionado.status }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="eventoSelecionado.tipo === 'reuniao'">
|
|
||||||
<div class="evento-row">
|
|
||||||
<i class="pi pi-map-marker" />
|
|
||||||
<span>{{ eventoSelecionado.local }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="evento-row">
|
|
||||||
<i class="pi pi-users" />
|
|
||||||
<span>{{ eventoSelecionado.participantes }} participantes</span>
|
|
||||||
</div>
|
|
||||||
<div class="evento-desc">{{ eventoSelecionado.descricao }}</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="eventoSelecionado.tipo === 'supervisao'">
|
|
||||||
<div class="evento-row">
|
|
||||||
<i class="pi pi-user" />
|
|
||||||
<span>{{ eventoSelecionado.supervisorNome }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="evento-row">
|
|
||||||
<i class="pi pi-map-marker" />
|
|
||||||
<span>{{ eventoSelecionado.local }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="evento-desc">{{ eventoSelecionado.descricao }}</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ação principal -->
|
|
||||||
<button class="evento-action" @click="fecharEvento">
|
|
||||||
<template v-if="eventoSelecionado.tipo === 'sessao'">
|
|
||||||
<i class="pi pi-file-edit text-xs" />
|
|
||||||
Abrir prontuário
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<i class="pi pi-info-circle text-xs" />
|
|
||||||
Ver detalhes
|
|
||||||
</template>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
@@ -1012,6 +1255,7 @@ function onKeydown(e) {
|
|||||||
<Transition name="page-fade">
|
<Transition name="page-fade">
|
||||||
<MelissaAgenda
|
<MelissaAgenda
|
||||||
v-if="secaoAberta === 'agenda'"
|
v-if="secaoAberta === 'agenda'"
|
||||||
|
ref="melissaAgendaRef"
|
||||||
:pacientes="pacientesReais"
|
:pacientes="pacientesReais"
|
||||||
@select-evento="abrirEvento"
|
@select-evento="abrirEvento"
|
||||||
@close="fecharSecao"
|
@close="fecharSecao"
|
||||||
@@ -1019,13 +1263,22 @@ function onKeydown(e) {
|
|||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<Transition name="page-fade">
|
||||||
|
<MelissaPacientes
|
||||||
|
v-if="secaoAberta === 'pacientes'"
|
||||||
|
@close="fecharSecao"
|
||||||
|
@patient-created="refetchPacientes"
|
||||||
|
@goto-agenda="abrirSecao('agenda')"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
<!-- SEÇÃO — placeholder dialog pras sessões ainda não promovidas -->
|
<!-- SEÇÃO — placeholder dialog pras sessões ainda não promovidas -->
|
||||||
<!-- (Pacientes, WhatsApp, Financeiro, Copilot...) -->
|
<!-- (WhatsApp, Financeiro, Copilot...) -->
|
||||||
<!-- ═══════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
<Transition name="lift">
|
<Transition name="lift">
|
||||||
<div
|
<div
|
||||||
v-if="secaoAberta && secaoAberta !== 'agenda'"
|
v-if="secaoAberta && secaoAberta !== 'agenda' && secaoAberta !== 'pacientes'"
|
||||||
class="secao-layer"
|
class="secao-layer"
|
||||||
@click.self="fecharSecao"
|
@click.self="fecharSecao"
|
||||||
>
|
>
|
||||||
@@ -1078,6 +1331,65 @@ function onKeydown(e) {
|
|||||||
:toque-termino="toqueTermino"
|
:toque-termino="toqueTermino"
|
||||||
@visible-change="cronoVisible = $event"
|
@visible-change="cronoVisible = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Drawer de conversas (WhatsApp): mesmo padrão do AppLayout.
|
||||||
|
Sem ele montado, conversationDrawerStore.openForPatient() ativa o
|
||||||
|
estado mas não tem componente reativo pra abrir. -->
|
||||||
|
<ConversationDrawer />
|
||||||
|
|
||||||
|
<!-- ConfirmDialog: usado pelos handlers da agenda (drag/resize -->
|
||||||
|
<!-- pede confirmação). Auto-resolvido via PrimeVueResolver. -->
|
||||||
|
<!-- -->
|
||||||
|
<!-- Slot #message override: persistMoveOrResize gera mensagens -->
|
||||||
|
<!-- com <strong> ao redor de datas/horários. v-html renderiza -->
|
||||||
|
<!-- HTML; nome do paciente já é escapado em useMelissaAgenda -->
|
||||||
|
<!-- (_esc) pra evitar XSS. -->
|
||||||
|
<ConfirmDialog>
|
||||||
|
<template #message="slotProps">
|
||||||
|
<span class="p-confirm-dialog-message" v-html="slotProps.message.message" />
|
||||||
|
</template>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<!-- AgendaEventDialog — editor completo (CRUD + recorrência -->
|
||||||
|
<!-- + financeiro). Vive no nível do MelissaLayout pra cobrir -->
|
||||||
|
<!-- toda a tela e ser independente de qual seção está aberta. -->
|
||||||
|
<!-- Os handlers (M.onDialogSave/Delete/etc) e o estado vêm do -->
|
||||||
|
<!-- composable useMelissaAgenda. -->
|
||||||
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
|
<AgendaEventDialog
|
||||||
|
v-model="agendaDialogOpen"
|
||||||
|
:eventRow="agendaDialogEventRow"
|
||||||
|
:initialStartISO="agendaDialogStartISO"
|
||||||
|
:initialEndISO="agendaDialogEndISO"
|
||||||
|
:ownerId="agendaOwnerId"
|
||||||
|
:tenantId="agendaClinicTenantId"
|
||||||
|
:commitmentOptions="agendaCommitmentOptions"
|
||||||
|
:workRules="agendaWorkRules"
|
||||||
|
:blockedDates="[]"
|
||||||
|
:agendaSettings="agendaSettings"
|
||||||
|
:allEvents="agendaAllEvents"
|
||||||
|
:pausasSemanais="agendaSettings?.pausas_semanais || []"
|
||||||
|
:feriados="agendaFeriados"
|
||||||
|
newPatientRoute="/therapist/patients/cadastro"
|
||||||
|
@save="M.onDialogSave"
|
||||||
|
@delete="M.onDialogDelete"
|
||||||
|
@updateSeriesEvent="M.onUpdateSeriesEvent"
|
||||||
|
@editSeriesOccurrence="M.onEditSeriesOccurrence"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- BloqueioDialog — bloqueio de horário/período/dia/feriados.
|
||||||
|
Trigger é o menu na toolbar da MelissaAgenda. Após salvar,
|
||||||
|
refetcha pra refletir o bloqueio na agenda. -->
|
||||||
|
<BloqueioDialog
|
||||||
|
v-model="agendaBloqueioOpen"
|
||||||
|
:mode="agendaBloqueioMode"
|
||||||
|
:workRules="agendaWorkRules"
|
||||||
|
:settings="agendaSettings"
|
||||||
|
:ownerId="agendaOwnerId"
|
||||||
|
:tenantId="agendaClinicTenantId"
|
||||||
|
@saved="M.refetch"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1089,6 +1401,17 @@ function onKeydown(e) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
|
/* Camada da foto custom — fica entre o gradiente default (.win11-root)
|
||||||
|
e o dim. Opacidade vem do slider via inline style. */
|
||||||
|
.win11-photo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
.win11-dim {
|
.win11-dim {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1411,6 +1734,40 @@ function onKeydown(e) {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sliders do painel de personalização — usa accent-color (pinta thumb +
|
||||||
|
parte preenchida na primary). Não sobrescrevemos ::-webkit-slider-track
|
||||||
|
pra preservar o alinhamento vertical nativo da bolinha (Chrome entra
|
||||||
|
em modo "custom" se a track for estilizada e a thumb desce). */
|
||||||
|
.settings-range {
|
||||||
|
accent-color: var(--p-primary-color);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle "ligado" usa a primary do preset escolhido (não emerald hardcoded
|
||||||
|
como antes — agora todo o painel reflete a paleta selecionada). */
|
||||||
|
.settings-toggle.is-on {
|
||||||
|
background-color: var(--p-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Swatches de cor primária — círculos compactos com ring na ativa. */
|
||||||
|
.settings-swatch {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.settings-swatch:hover {
|
||||||
|
transform: scale(1.12);
|
||||||
|
}
|
||||||
|
.settings-swatch.is-active {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px var(--m-bg-medium),
|
||||||
|
0 0 0 4px var(--p-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Botão-trigger do cronômetro (irmão flex do relógio) ───── */
|
/* ─── Botão-trigger do cronômetro (irmão flex do relógio) ───── */
|
||||||
.crono-icon-btn {
|
.crono-icon-btn {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
@@ -1480,73 +1837,8 @@ function onKeydown(e) {
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Dialog de evento da timeline ─────────────────────────── */
|
/* (Antes vivia aqui o CSS do panel inline de evento — extraído pra
|
||||||
.evento-layer {
|
MelissaEventoPanel.vue em 2026-04-27 junto com o componente.) */
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 55;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
.evento-panel {
|
|
||||||
width: min(420px, 100%);
|
|
||||||
background: var(--m-bg-medium);
|
|
||||||
backdrop-filter: blur(32px) saturate(160%);
|
|
||||||
-webkit-backdrop-filter: blur(32px) saturate(160%);
|
|
||||||
border: 1px solid var(--m-border-strong);
|
|
||||||
border-radius: 22px;
|
|
||||||
padding: 1.75rem;
|
|
||||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
.evento-pill {
|
|
||||||
width: 4px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.evento-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--m-text);
|
|
||||||
}
|
|
||||||
.evento-row i {
|
|
||||||
width: 16px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--m-text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.evento-desc {
|
|
||||||
color: var(--m-text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding-top: 12px;
|
|
||||||
margin-top: 4px;
|
|
||||||
border-top: 1px solid var(--m-border);
|
|
||||||
}
|
|
||||||
.evento-action {
|
|
||||||
width: 100%;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
background: var(--m-bg-soft-hover);
|
|
||||||
border: 1px solid var(--m-border-strong);
|
|
||||||
color: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 140ms ease, border-color 140ms ease;
|
|
||||||
}
|
|
||||||
.evento-action:hover {
|
|
||||||
background: var(--m-border-strong);
|
|
||||||
border-color: var(--m-border-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── MelissaPage — transição "fade" pra páginas fullscreen ─── */
|
/* ─── MelissaPage — transição "fade" pra páginas fullscreen ─── */
|
||||||
.page-fade-enter-active,
|
.page-fade-enter-active,
|
||||||
@@ -1798,7 +2090,7 @@ function onKeydown(e) {
|
|||||||
|
|
||||||
/* ─── Gradiente "Bloom" (default = dark) ────────────────────────
|
/* ─── Gradiente "Bloom" (default = dark) ────────────────────────
|
||||||
Derivado da palette primária + surface do preset. Em light flipam
|
Derivado da palette primária + surface do preset. Em light flipam
|
||||||
pra tons claros (200/100 + surface-0/primary-50). O computed bgStyle
|
pra tons claros (200/100 + surface-0/primary-50). O defaultBgStyle
|
||||||
no <script> referencia essas vars; mudar aqui muda o bloom todo. */
|
no <script> referencia essas vars; mudar aqui muda o bloom todo. */
|
||||||
--bloom-c1: var(--p-primary-400);
|
--bloom-c1: var(--p-primary-400);
|
||||||
--bloom-c2: var(--p-primary-300);
|
--bloom-c2: var(--p-primary-300);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,7 @@ function normalizeEvent(r) {
|
|||||||
tipo: r.tipo || 'sessao',
|
tipo: r.tipo || 'sessao',
|
||||||
status: r.status || '',
|
status: r.status || '',
|
||||||
titulo: r.titulo || '',
|
titulo: r.titulo || '',
|
||||||
|
patient_id: r.patient_id || null,
|
||||||
pacienteNome: pacNome,
|
pacienteNome: pacNome,
|
||||||
modalidade: r.modalidade || '',
|
modalidade: r.modalidade || '',
|
||||||
descricao: r.observacoes || '',
|
descricao: r.observacoes || '',
|
||||||
@@ -80,7 +81,7 @@ async function _fetchRange(start, end) {
|
|||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||||
.eq('owner_id', userId)
|
.eq('owner_id', userId)
|
||||||
.is('mirror_of_event_id', null)
|
.is('mirror_of_event_id', null)
|
||||||
.gte('inicio_em', start.toISOString())
|
.gte('inicio_em', start.toISOString())
|
||||||
|
|||||||
Reference in New Issue
Block a user