Ajuste usuarios - Inicio agenda
This commit is contained in:
@@ -1,10 +1,209 @@
|
|||||||
|
<!-- src/features/agenda/components/AgendaCalendar.vue -->
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({})
|
import { computed, ref, watch, onMounted } from 'vue'
|
||||||
defineEmits([])
|
|
||||||
|
import FullCalendar from '@fullcalendar/vue3'
|
||||||
|
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||||
|
import interactionPlugin from '@fullcalendar/interaction'
|
||||||
|
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||||
|
|
||||||
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// UI
|
||||||
|
view: { type: String, default: 'day' }, // 'day' | 'week'
|
||||||
|
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
|
||||||
|
|
||||||
|
// calendar behavior
|
||||||
|
timezone: { type: String, default: 'America/Sao_Paulo' },
|
||||||
|
slotDuration: { type: String, default: '00:30:00' },
|
||||||
|
slotMinTime: { type: String, default: '06:00:00' },
|
||||||
|
slotMaxTime: { type: String, default: '22:00:00' },
|
||||||
|
businessHours: { type: [Array, Object], default: () => [] },
|
||||||
|
|
||||||
|
// data
|
||||||
|
events: { type: Array, default: () => [] },
|
||||||
|
loading: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'rangeChange',
|
||||||
|
'selectTime',
|
||||||
|
'eventClick',
|
||||||
|
'eventDrop',
|
||||||
|
'eventResize'
|
||||||
|
])
|
||||||
|
|
||||||
|
const fcRef = ref(null)
|
||||||
|
|
||||||
|
const initialView = computed(() => (props.view === 'week' ? 'timeGridWeek' : 'timeGridDay'))
|
||||||
|
|
||||||
|
function getApi () {
|
||||||
|
const inst = fcRef.value
|
||||||
|
return inst?.getApi?.() || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitRange () {
|
||||||
|
const api = getApi()
|
||||||
|
if (!api) return
|
||||||
|
const v = api.view
|
||||||
|
emit('rangeChange', { start: v.activeStart, end: v.activeEnd })
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Calendar options
|
||||||
|
// -----------------------------
|
||||||
|
const calendarOptions = computed(() => {
|
||||||
|
const isWorkHours = props.mode !== 'full_24h'
|
||||||
|
|
||||||
|
// No modo 24h, não recorta — mas mantemos min/max caso você queira ainda controlar
|
||||||
|
const minTime = isWorkHours ? props.slotMinTime : '00:00:00'
|
||||||
|
// FullCalendar timeGrid costuma aceitar 24:00:00, mas 23:59:59 evita edge-case
|
||||||
|
const maxTime = isWorkHours ? props.slotMaxTime : '23:59:59'
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [timeGridPlugin, interactionPlugin, dayGridPlugin],
|
||||||
|
initialView: initialView.value,
|
||||||
|
|
||||||
|
timeZone: props.timezone,
|
||||||
|
|
||||||
|
// Header desativado (você controla no Toolbar)
|
||||||
|
headerToolbar: false,
|
||||||
|
|
||||||
|
// Visão “produto”: blocos com linhas suaves
|
||||||
|
nowIndicator: true,
|
||||||
|
allDaySlot: false,
|
||||||
|
expandRows: true,
|
||||||
|
height: 'auto',
|
||||||
|
|
||||||
|
// Seleção / DnD / Resize
|
||||||
|
selectable: true,
|
||||||
|
selectMirror: true,
|
||||||
|
editable: true,
|
||||||
|
eventStartEditable: true,
|
||||||
|
eventDurationEditable: true,
|
||||||
|
|
||||||
|
// Intervalos visuais
|
||||||
|
slotDuration: props.slotDuration,
|
||||||
|
slotMinTime: minTime,
|
||||||
|
slotMaxTime: maxTime,
|
||||||
|
slotLabelFormat: {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
},
|
||||||
|
|
||||||
|
// Horário “verdadeiro” de funcionamento (se você usar)
|
||||||
|
businessHours: props.businessHours,
|
||||||
|
|
||||||
|
// Dados
|
||||||
|
events: props.events,
|
||||||
|
|
||||||
|
// Melhor UX
|
||||||
|
weekends: true,
|
||||||
|
firstDay: 1, // segunda
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
datesSet: () => {
|
||||||
|
// dispara quando muda o intervalo exibido (prev/next/today/view)
|
||||||
|
emitRange()
|
||||||
|
},
|
||||||
|
|
||||||
|
select: (selection) => {
|
||||||
|
// selection: { start, end, allDay, ... }
|
||||||
|
emit('selectTime', selection)
|
||||||
|
},
|
||||||
|
|
||||||
|
eventClick: (info) => emit('eventClick', info),
|
||||||
|
eventDrop: (info) => emit('eventDrop', info),
|
||||||
|
eventResize: (info) => emit('eventResize', info)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Exposed methods (para Toolbar/Page)
|
||||||
|
// -----------------------------
|
||||||
|
function goToday () { getApi()?.today?.() }
|
||||||
|
function prev () { getApi()?.prev?.() }
|
||||||
|
function next () { getApi()?.next?.() }
|
||||||
|
|
||||||
|
function setView (v) {
|
||||||
|
const api = getApi()
|
||||||
|
if (!api) return
|
||||||
|
api.changeView(v === 'week' ? 'timeGridWeek' : 'timeGridDay')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ goToday, prev, next, setView })
|
||||||
|
|
||||||
|
// Se a prop view mudar, sincroniza
|
||||||
|
watch(
|
||||||
|
() => props.view,
|
||||||
|
(v) => setView(v)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Emite o range inicial assim que montar
|
||||||
|
onMounted(() => {
|
||||||
|
// garante que o FullCalendar já criou a view
|
||||||
|
setTimeout(() => emitRange(), 0)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-4 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
<div class="agenda-calendar-wrap">
|
||||||
<b>AgendaCalendar (placeholder)</b>
|
<div v-if="loading" class="agenda-calendar-loading">
|
||||||
|
<ProgressSpinner strokeWidth="3" />
|
||||||
|
<div class="text-sm mt-2" style="color: var(--text-color-secondary);">
|
||||||
|
Carregando agenda…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FullCalendar
|
||||||
|
ref="fcRef"
|
||||||
|
:options="calendarOptions"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.agenda-calendar-wrap{
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-calendar-loading{
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
background: color-mix(in srgb, var(--surface-card) 85%, transparent);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deixa o calendário “respirar” dentro de cards/layouts */
|
||||||
|
:deep(.fc){
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.fc .fc-timegrid-slot){
|
||||||
|
height: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.fc .fc-timegrid-now-indicator-line){
|
||||||
|
opacity: .75;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.fc .fc-scrollgrid){
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.fc .fc-timegrid-axis-cushion),
|
||||||
|
:deep(.fc .fc-timegrid-slot-label-cushion){
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,105 @@
|
|||||||
|
<!-- src/features/agenda/components/AgendaRightPanel.vue -->
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({})
|
import Card from 'primevue/card'
|
||||||
|
import Divider from 'primevue/divider'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: { type: String, default: 'Painel' },
|
||||||
|
subtitle: { type: String, default: 'Visão rápida do dia e ações de triagem.' },
|
||||||
|
sticky: { type: Boolean, default: true },
|
||||||
|
showHeaderActions: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['refresh', 'collapse'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Este componente é propositalmente "burro".
|
||||||
|
* Ele só organiza layout e slots para o painel lateral.
|
||||||
|
* Regras/ações reais ficam nas páginas ou nos cards filhos.
|
||||||
|
*/
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-4 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
<div
|
||||||
<b>AgendaRightPanel (placeholder)</b>
|
class="agenda-right-panel"
|
||||||
<div class="mt-3">
|
:class="{ 'is-sticky': sticky }"
|
||||||
|
>
|
||||||
|
<Card class="h-full">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex align-items-start justify-content-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-base md:text-lg font-semibold truncate">
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs mt-1" style="color: var(--text-color-secondary);">
|
||||||
|
{{ subtitle }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showHeaderActions" class="flex align-items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
v-tooltip.top="'Atualizar painel'"
|
||||||
|
@click="emit('refresh')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
icon="pi pi-window-minimize"
|
||||||
|
v-tooltip.top="'Recolher (UI)'"
|
||||||
|
@click="emit('collapse')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="content-wrap">
|
||||||
|
<!-- TOP slot (ex.: próximas sessões) -->
|
||||||
|
<div class="slot-top">
|
||||||
<slot name="top" />
|
<slot name="top" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider class="my-3" />
|
||||||
|
|
||||||
|
<!-- BOTTOM slot (ex.: pulse / atalhos) -->
|
||||||
|
<div class="slot-bottom">
|
||||||
<slot name="bottom" />
|
<slot name="bottom" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.agenda-right-panel{
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-right-panel.is-sticky{
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deixa o painel com scroll interno sem estourar a página */
|
||||||
|
.content-wrap{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .25rem;
|
||||||
|
max-height: calc(100vh - 220px);
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Melhor sensação de “sessões” */
|
||||||
|
.slot-top, .slot-bottom{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,157 @@
|
|||||||
|
<!-- src/features/agenda/components/AgendaToolbar.vue -->
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import { computed, ref, watch } from 'vue'
|
||||||
items: { type: Array, default: () => [] }
|
|
||||||
|
import Card from 'primevue/card'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Divider from 'primevue/divider'
|
||||||
|
import SelectButton from 'primevue/selectbutton'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
import FloatLabel from 'primevue/floatlabel'
|
||||||
|
import Tag from 'primevue/tag'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: { type: String, default: 'Agenda' },
|
||||||
|
view: { type: String, default: 'day' }, // 'day' | 'week'
|
||||||
|
mode: { type: String, default: 'work_hours' } // 'full_24h' | 'work_hours'
|
||||||
})
|
})
|
||||||
defineEmits(['open', 'confirm', 'reschedule'])
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'today',
|
||||||
|
'prev',
|
||||||
|
'next',
|
||||||
|
'changeView',
|
||||||
|
'toggleMode',
|
||||||
|
'createSession',
|
||||||
|
'createBlock',
|
||||||
|
'search'
|
||||||
|
])
|
||||||
|
|
||||||
|
// UX: busca com debounce simples
|
||||||
|
const searchLocal = ref('')
|
||||||
|
let t = null
|
||||||
|
watch(searchLocal, (v) => {
|
||||||
|
clearTimeout(t)
|
||||||
|
t = setTimeout(() => emit('search', v), 220)
|
||||||
|
})
|
||||||
|
|
||||||
|
// SelectButtons (executivo, direto)
|
||||||
|
const viewOptions = [
|
||||||
|
{ label: 'Dia', value: 'day' },
|
||||||
|
{ label: 'Semana', value: 'week' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const modeOptions = [
|
||||||
|
{ label: 'Horário de funcionamento', value: 'work_hours' },
|
||||||
|
{ label: 'Dia completo (24h)', value: 'full_24h' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const modeTag = computed(() => {
|
||||||
|
return props.mode === 'full_24h'
|
||||||
|
? { severity: 'info', text: '24h' }
|
||||||
|
: { severity: 'success', text: 'Funcionamento' }
|
||||||
|
})
|
||||||
|
|
||||||
|
function onChangeView(val) {
|
||||||
|
emit('changeView', val)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggleMode(val) {
|
||||||
|
emit('toggleMode', val)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-3 border rounded-lg">
|
<Card class="mb-3 md:mb-4">
|
||||||
<b>AgendaNextSessionsCardList (placeholder)</b>
|
<template #content>
|
||||||
|
<div class="flex flex-column gap-3">
|
||||||
|
|
||||||
|
<!-- Top row -->
|
||||||
|
<div class="flex align-items-start justify-content-between gap-3 flex-wrap">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<div class="text-xl md:text-2xl font-semibold leading-tight">
|
||||||
|
{{ title }}
|
||||||
</div>
|
</div>
|
||||||
|
<Tag :severity="modeTag.severity" :value="modeTag.text" />
|
||||||
|
</div>
|
||||||
|
<div class="text-sm mt-1" style="color: var(--text-color-secondary);">
|
||||||
|
Navegue, filtre e crie compromissos com velocidade — sem perder controle clínico.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
label="Sessão"
|
||||||
|
@click="emit('createSession')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon="pi pi-lock"
|
||||||
|
severity="secondary"
|
||||||
|
label="Bloqueio"
|
||||||
|
@click="emit('createBlock')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider class="my-0" />
|
||||||
|
|
||||||
|
<!-- Controls row -->
|
||||||
|
<div class="grid gap-3 md:gap-4" style="grid-template-columns: 1fr;">
|
||||||
|
<div class="grid gap-3 md:gap-4 items-center" style="grid-template-columns: 1fr;">
|
||||||
|
|
||||||
|
<div class="flex align-items-center justify-content-between gap-3 flex-wrap">
|
||||||
|
<!-- Nav -->
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<Button size="small" text icon="pi pi-angle-left" @click="emit('prev')" />
|
||||||
|
<Button size="small" text icon="pi pi-angle-right" @click="emit('next')" />
|
||||||
|
<Button size="small" icon="pi pi-calendar" label="Hoje" @click="emit('today')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View + Mode -->
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<SelectButton
|
||||||
|
:modelValue="view"
|
||||||
|
:options="viewOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
@update:modelValue="onChangeView"
|
||||||
|
/>
|
||||||
|
<SelectButton
|
||||||
|
:modelValue="mode"
|
||||||
|
:options="modeOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
@update:modelValue="onToggleMode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<div class="flex-1 min-w-[260px]">
|
||||||
|
<FloatLabel>
|
||||||
|
<InputText id="agenda-search" v-model="searchLocal" class="w-full" />
|
||||||
|
<label for="agenda-search">Buscar por título / observação</label>
|
||||||
|
</FloatLabel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
icon="pi pi-times"
|
||||||
|
:disabled="!searchLocal"
|
||||||
|
@click="searchLocal = ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,12 +1,142 @@
|
|||||||
|
<!-- src/features/agenda/components/cards/AgendaPulseCardGrid.vue -->
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import Card from 'primevue/card'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Divider from 'primevue/divider'
|
||||||
|
import Tag from 'primevue/tag'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
stats: { type: Object, default: () => ({}) }
|
stats: { type: Object, default: () => ({}) }
|
||||||
})
|
})
|
||||||
defineEmits(['quickBlock', 'quickCreate'])
|
|
||||||
|
const emit = defineEmits(['quickBlock', 'quickCreate'])
|
||||||
|
|
||||||
|
const dados_de_exemplo = {
|
||||||
|
totalSessions: 6,
|
||||||
|
totalMinutes: 300,
|
||||||
|
biggestFreeWindow: '2h 10m',
|
||||||
|
pending: 0,
|
||||||
|
reschedules: 1,
|
||||||
|
attentions: 1,
|
||||||
|
suggested1: '14:00',
|
||||||
|
suggested2: '16:30',
|
||||||
|
nextBreak: '12:00'
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = computed(() => {
|
||||||
|
const base = props.stats && Object.keys(props.stats).length ? props.stats : dados_de_exemplo
|
||||||
|
return {
|
||||||
|
totalSessions: base.totalSessions ?? 0,
|
||||||
|
totalMinutes: base.totalMinutes ?? 0,
|
||||||
|
biggestFreeWindow: base.biggestFreeWindow ?? '—',
|
||||||
|
pending: base.pending ?? 0,
|
||||||
|
reschedules: base.reschedules ?? 0,
|
||||||
|
attentions: base.attentions ?? 0,
|
||||||
|
suggested1: base.suggested1 ?? '—',
|
||||||
|
suggested2: base.suggested2 ?? '—',
|
||||||
|
nextBreak: base.nextBreak ?? '—'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function minutesToHuman(min) {
|
||||||
|
const n = Number(min || 0)
|
||||||
|
const h = Math.floor(n / 60)
|
||||||
|
const m = n % 60
|
||||||
|
if (h <= 0) return `${m}m`
|
||||||
|
if (m <= 0) return `${h}h`
|
||||||
|
return `${h}h ${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTimeHuman = computed(() => minutesToHuman(s.value.totalMinutes))
|
||||||
|
|
||||||
|
const attentionSeverity = computed(() => {
|
||||||
|
if (s.value.attentions >= 3) return 'danger'
|
||||||
|
if (s.value.attentions >= 1) return 'warning'
|
||||||
|
return 'success'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-3 border rounded-lg">
|
<Card>
|
||||||
<b>AgendaPulseCardGrid (placeholder)</b>
|
<template #title>
|
||||||
|
<div class="flex align-items-center justify-content-between gap-2 flex-wrap">
|
||||||
|
<div class="flex flex-column">
|
||||||
|
<span>Pulso da agenda</span>
|
||||||
|
<span class="text-xs" style="color: var(--text-color-secondary);">
|
||||||
|
Indicadores rápidos para decisão imediata.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Tag :severity="attentionSeverity" :value="`Atenções: ${s.attentions}`" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<!-- Linha 1: métricas -->
|
||||||
|
<div class="grid gap-2" style="grid-template-columns: 1fr 1fr;">
|
||||||
|
<div class="p-3 border-round-xl" style="border: 1px solid var(--surface-border); background: var(--surface-card);">
|
||||||
|
<div class="text-xs" style="color: var(--text-color-secondary);">Sessões (no filtro)</div>
|
||||||
|
<div class="text-xl font-semibold mt-1">{{ s.totalSessions }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 border-round-xl" style="border: 1px solid var(--surface-border); background: var(--surface-card);">
|
||||||
|
<div class="text-xs" style="color: var(--text-color-secondary);">Tempo total</div>
|
||||||
|
<div class="text-xl font-semibold mt-1">{{ totalTimeHuman }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Linha 2: janelas e atenção -->
|
||||||
|
<div class="grid gap-2" style="grid-template-columns: 1fr 1fr;">
|
||||||
|
<div class="p-3 border-round-xl" style="border: 1px solid var(--surface-border); background: var(--surface-card);">
|
||||||
|
<div class="text-xs" style="color: var(--text-color-secondary);">Maior janela livre</div>
|
||||||
|
<div class="text-base font-semibold mt-1">{{ s.biggestFreeWindow }}</div>
|
||||||
|
<div class="text-xs mt-2" style="color: var(--text-color-secondary);">
|
||||||
|
Sugestões: <b>{{ s.suggested1 }}</b> e <b>{{ s.suggested2 }}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 border-round-xl" style="border: 1px solid var(--surface-border); background: var(--surface-card);">
|
||||||
|
<div class="text-xs" style="color: var(--text-color-secondary);">Pontos de atenção</div>
|
||||||
|
<div class="flex align-items-center gap-2 mt-2 flex-wrap">
|
||||||
|
<Tag severity="warning" :value="`Pendências: ${s.pending}`" />
|
||||||
|
<Tag severity="info" :value="`Remarcar: ${s.reschedules}`" />
|
||||||
|
</div>
|
||||||
|
<div class="text-xs mt-2" style="color: var(--text-color-secondary);">
|
||||||
|
Próxima pausa: <b>{{ s.nextBreak }}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider class="my-2" />
|
||||||
|
|
||||||
|
<!-- Ações rápidas -->
|
||||||
|
<div class="flex align-items-center justify-content-between gap-2 flex-wrap">
|
||||||
|
<div class="text-xs" style="color: var(--text-color-secondary);">
|
||||||
|
Ações rápidas (criação sempre abre modal — nada nasce “direto”).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
label="Nova sessão"
|
||||||
|
@click="emit('quickCreate')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon="pi pi-lock"
|
||||||
|
severity="secondary"
|
||||||
|
label="Bloquear horário"
|
||||||
|
@click="emit('quickBlock')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,28 @@
|
|||||||
// src/features/agenda/services/agendaRepository.js
|
// src/features/agenda/services/agendaRepository.js
|
||||||
import { supabase } from '@/lib/supabase/client'
|
import { supabase } from '@/lib/supabase/client'
|
||||||
|
import { useTenantStore } from '@/stores/tenantStore'
|
||||||
|
|
||||||
export async function getMyAgendaSettings () {
|
function assertValidTenantId (tenantId) {
|
||||||
|
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||||
|
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de carregar a agenda.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUid () {
|
||||||
const { data: userRes, error: userErr } = await supabase.auth.getUser()
|
const { data: userRes, error: userErr } = await supabase.auth.getUser()
|
||||||
if (userErr) throw userErr
|
if (userErr) throw userErr
|
||||||
|
|
||||||
const uid = userRes?.user?.id
|
const uid = userRes?.user?.id
|
||||||
if (!uid) throw new Error('Usuário não autenticado.')
|
if (!uid) throw new Error('Usuário não autenticado.')
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configurações da agenda (por owner)
|
||||||
|
* Se você decidir que configurações são por tenant também, adicionamos tenant_id aqui.
|
||||||
|
*/
|
||||||
|
export async function getMyAgendaSettings () {
|
||||||
|
const uid = await getUid()
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('agenda_configuracoes')
|
.from('agenda_configuracoes')
|
||||||
@@ -18,16 +34,23 @@ export async function getMyAgendaSettings () {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMyAgendaEvents ({ startISO, endISO }) {
|
/**
|
||||||
const { data: userRes, error: userErr } = await supabase.auth.getUser()
|
* Lista agenda do terapeuta (somente do owner logado) dentro do tenant ativo.
|
||||||
if (userErr) throw userErr
|
* Isso impede misturar eventos caso o terapeuta atue em múltiplas clínicas.
|
||||||
|
*/
|
||||||
|
export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantIdArg } = {}) {
|
||||||
|
const uid = await getUid()
|
||||||
|
|
||||||
const uid = userRes?.user?.id
|
const tenantStore = useTenantStore()
|
||||||
if (!uid) throw new Error('Usuário não autenticado.')
|
const tenantId = tenantIdArg || tenantStore.activeTenantId
|
||||||
|
assertValidTenantId(tenantId)
|
||||||
|
|
||||||
|
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).')
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.select('*')
|
.select('*')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
.eq('owner_id', uid)
|
.eq('owner_id', uid)
|
||||||
.gte('inicio_em', startISO)
|
.gte('inicio_em', startISO)
|
||||||
.lt('inicio_em', endISO)
|
.lt('inicio_em', endISO)
|
||||||
@@ -37,13 +60,26 @@ export async function listMyAgendaEvents ({ startISO, endISO }) {
|
|||||||
return data || []
|
return data || []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listClinicEvents ({ ownerIds, startISO, endISO }) {
|
/**
|
||||||
|
* Lista eventos para mosaico da clínica (admin/secretaria) dentro de um tenant específico.
|
||||||
|
* IMPORTANTE: SEM tenant_id aqui vira vazamento multi-tenant.
|
||||||
|
*/
|
||||||
|
export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO }) {
|
||||||
|
assertValidTenantId(tenantId)
|
||||||
if (!ownerIds?.length) return []
|
if (!ownerIds?.length) return []
|
||||||
|
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).')
|
||||||
|
|
||||||
|
// Sanitiza ownerIds
|
||||||
|
const safeOwnerIds = ownerIds
|
||||||
|
.filter(id => typeof id === 'string' && id && id !== 'null' && id !== 'undefined')
|
||||||
|
|
||||||
|
if (!safeOwnerIds.length) return []
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.select('*')
|
.select('*')
|
||||||
.in('owner_id', ownerIds)
|
.eq('tenant_id', tenantId)
|
||||||
|
.in('owner_id', safeOwnerIds)
|
||||||
.gte('inicio_em', startISO)
|
.gte('inicio_em', startISO)
|
||||||
.lt('inicio_em', endISO)
|
.lt('inicio_em', endISO)
|
||||||
.order('inicio_em', { ascending: true })
|
.order('inicio_em', { ascending: true })
|
||||||
@@ -53,7 +89,7 @@ export async function listClinicEvents ({ ownerIds, startISO, endISO }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listTenantStaff (tenantId) {
|
export async function listTenantStaff (tenantId) {
|
||||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') return []
|
assertValidTenantId(tenantId)
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('v_tenant_staff')
|
.from('v_tenant_staff')
|
||||||
@@ -64,10 +100,33 @@ export async function listTenantStaff (tenantId) {
|
|||||||
return data || []
|
return data || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Criação segura:
|
||||||
|
* - injeta tenant_id do tenantStore
|
||||||
|
* - injeta owner_id do usuário logado (ignora owner_id vindo de fora)
|
||||||
|
*
|
||||||
|
* Observação:
|
||||||
|
* - Para admin/secretária criar para outros owners, o ideal é ter uma função separada
|
||||||
|
* (ex.: createClinicAgendaEvento) que permita owner_id explicitamente.
|
||||||
|
* Por enquanto, deixo esta função como "safe default" para terapeuta.
|
||||||
|
*/
|
||||||
export async function createAgendaEvento (payload) {
|
export async function createAgendaEvento (payload) {
|
||||||
|
const uid = await getUid()
|
||||||
|
const tenantStore = useTenantStore()
|
||||||
|
const tenantId = tenantStore.activeTenantId
|
||||||
|
assertValidTenantId(tenantId)
|
||||||
|
|
||||||
|
if (!payload) throw new Error('Payload vazio.')
|
||||||
|
|
||||||
|
const insertPayload = {
|
||||||
|
...payload,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
owner_id: uid
|
||||||
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.insert(payload)
|
.insert(insertPayload)
|
||||||
.select('*')
|
.select('*')
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
@@ -75,11 +134,24 @@ export async function createAgendaEvento (payload) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAgendaEvento (id, patch) {
|
/**
|
||||||
|
* Atualização segura:
|
||||||
|
* - filtra por id + tenant_id (evita update cruzado por acidente)
|
||||||
|
* RLS deve reforçar isso no banco.
|
||||||
|
*/
|
||||||
|
export async function updateAgendaEvento (id, patch, { tenantId: tenantIdArg } = {}) {
|
||||||
|
if (!id) throw new Error('ID inválido.')
|
||||||
|
if (!patch) throw new Error('Patch vazio.')
|
||||||
|
|
||||||
|
const tenantStore = useTenantStore()
|
||||||
|
const tenantId = tenantIdArg || tenantStore.activeTenantId
|
||||||
|
assertValidTenantId(tenantId)
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.update(patch)
|
.update(patch)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
.select('*')
|
.select('*')
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
@@ -87,12 +159,61 @@ export async function updateAgendaEvento (id, patch) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAgendaEvento (id) {
|
/**
|
||||||
|
* Delete seguro:
|
||||||
|
* - filtra por id + tenant_id
|
||||||
|
*/
|
||||||
|
export async function deleteAgendaEvento (id, { tenantId: tenantIdArg } = {}) {
|
||||||
|
if (!id) throw new Error('ID inválido.')
|
||||||
|
|
||||||
|
const tenantStore = useTenantStore()
|
||||||
|
const tenantId = tenantIdArg || tenantStore.activeTenantId
|
||||||
|
assertValidTenantId(tenantId)
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adicione no mesmo arquivo: src/features/agenda/services/agendaRepository.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Criação para a área da clínica (admin/secretária):
|
||||||
|
* - exige tenantId explícito (ou cai no tenantStore)
|
||||||
|
* - permite definir owner_id (terapeuta dono do compromisso)
|
||||||
|
*
|
||||||
|
* Segurança real deve ser garantida por RLS:
|
||||||
|
* - clinic_admin/tenant_admin pode criar para qualquer owner dentro do tenant
|
||||||
|
* - therapist não deve conseguir passar daqui (guard + RLS)
|
||||||
|
*/
|
||||||
|
export async function createClinicAgendaEvento (payload, { tenantId: tenantIdArg } = {}) {
|
||||||
|
const tenantStore = useTenantStore()
|
||||||
|
const tenantId = tenantIdArg || tenantStore.activeTenantId
|
||||||
|
assertValidTenantId(tenantId)
|
||||||
|
|
||||||
|
if (!payload) throw new Error('Payload vazio.')
|
||||||
|
|
||||||
|
const ownerId = payload.owner_id
|
||||||
|
if (!ownerId || ownerId === 'null' || ownerId === 'undefined') {
|
||||||
|
throw new Error('owner_id é obrigatório para criação pela clínica.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertPayload = {
|
||||||
|
...payload,
|
||||||
|
tenant_id: tenantId
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('agenda_eventos')
|
||||||
|
.insert(insertPayload)
|
||||||
|
.select('*')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -25,19 +25,38 @@ const { layoutState } = useLayout()
|
|||||||
const tenantStore = useTenantStore()
|
const tenantStore = useTenantStore()
|
||||||
const entitlementsStore = useEntitlementsStore()
|
const entitlementsStore = useEntitlementsStore()
|
||||||
|
|
||||||
|
const tenantId = computed(() => tenantStore.activeTenantId || null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ✅ Role canônico pro MENU:
|
* ✅ Role canônico pro MENU:
|
||||||
* - Prioriza o role do tenant (mesma fonte usada pelo router guard)
|
* - PRIORIDADE 1: contexto de rota (evita menu errado quando role do tenant atrasa/falha)
|
||||||
* - Faz fallback pro sessionRole (ex.: telas fora de tenant)
|
* Ex.: /therapist/* => força menu therapist; /admin* => força menu admin
|
||||||
|
* - PRIORIDADE 2: se há tenant ativo: usa role do tenant
|
||||||
|
* - PRIORIDADE 3: fallback pro sessionRole (ex.: telas fora de tenant)
|
||||||
|
*
|
||||||
|
* Motivo: o bug que você descreveu (terapeuta vendo admin.menu) geralmente é:
|
||||||
|
* - tenant role ainda não carregou OU tenantId está null
|
||||||
|
* - sessionRole vem como 'admin'
|
||||||
|
* Então, rota > tenant > session elimina o menu “trocar sozinho”.
|
||||||
*/
|
*/
|
||||||
const navRole = computed(() => {
|
const navRole = computed(() => {
|
||||||
return tenantStore.activeRole || sessionRole.value || null
|
const p = String(route.path || '')
|
||||||
|
|
||||||
|
// ✅ blindagem por contexto
|
||||||
|
if (p.startsWith('/therapist')) return 'therapist'
|
||||||
|
if (p.startsWith('/admin') || p.startsWith('/clinic')) return 'clinic_admin'
|
||||||
|
if (p.startsWith('/patient')) return 'patient'
|
||||||
|
|
||||||
|
// ✅ dentro de tenant: confia no role do tenant
|
||||||
|
if (tenantId.value) return tenantStore.activeRole || null
|
||||||
|
|
||||||
|
// ✅ fora de tenant: fallback pro sessionRole
|
||||||
|
return sessionRole.value || null
|
||||||
})
|
})
|
||||||
|
|
||||||
const model = computed(() => {
|
const model = computed(() => {
|
||||||
// ✅ fonte correta: tenant role (clinic_admin/therapist/patient)
|
// ✅ role efetivo do menu já vem “canônico” do navRole
|
||||||
// fallback: profiles.role (admin/therapist/patient)
|
const effectiveRole = navRole.value
|
||||||
const effectiveRole = tenantStore.activeRole || sessionRole.value
|
|
||||||
|
|
||||||
const base = getMenuByRole(effectiveRole, { isSaasAdmin: sessionIsSaasAdmin.value }) || []
|
const base = getMenuByRole(effectiveRole, { isSaasAdmin: sessionIsSaasAdmin.value }) || []
|
||||||
|
|
||||||
@@ -52,8 +71,6 @@ const model = computed(() => {
|
|||||||
return [...base].sort((a, b) => priorityOrder(a) - priorityOrder(b))
|
return [...base].sort((a, b) => priorityOrder(a) - priorityOrder(b))
|
||||||
})
|
})
|
||||||
|
|
||||||
const tenantId = computed(() => tenantStore.activeTenantId || null)
|
|
||||||
|
|
||||||
// quando troca tenant -> recarrega entitlements
|
// quando troca tenant -> recarrega entitlements
|
||||||
watch(
|
watch(
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -64,7 +81,7 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
// ✅ quando troca role efetivo do menu (tenant role / session role) -> recarrega entitlements do tenant atual
|
// ✅ quando troca role efetivo do menu (via rota/tenant/session) -> recarrega entitlements do tenant atual
|
||||||
watch(
|
watch(
|
||||||
() => navRole.value,
|
() => navRole.value,
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -16,15 +16,9 @@ export default function adminMenu (ctx = {}) {
|
|||||||
to: '/admin'
|
to: '/admin'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Agenda',
|
label: 'Agenda do Terapeuta',
|
||||||
icon: 'pi pi-fw pi-calendar',
|
|
||||||
to: '/admin/agenda',
|
|
||||||
feature: 'agenda.view'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Agenda da Clínica',
|
|
||||||
icon: 'pi pi-fw pi-sitemap',
|
icon: 'pi pi-fw pi-sitemap',
|
||||||
to: '/admin/agenda/clinica',
|
to: '/therapist/agenda',
|
||||||
feature: 'agenda.view'
|
feature: 'agenda.view'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -233,7 +233,15 @@ export function applyGuards (router) {
|
|||||||
// - se tem memberships active mas activeTenantId está null -> seta e segue
|
// - se tem memberships active mas activeTenantId está null -> seta e segue
|
||||||
if (!tenant.activeTenantId) {
|
if (!tenant.activeTenantId) {
|
||||||
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
|
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
|
||||||
const firstActive = mem.find(m => m && m.status === 'active' && m.tenant_id)
|
|
||||||
|
// 1) tenta casar role da rota (ex.: therapist) com membership
|
||||||
|
const wantedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : []
|
||||||
|
const preferred = wantedRoles.length
|
||||||
|
? mem.find(m => m && m.status === 'active' && m.tenant_id && wantedRoles.includes(m.role))
|
||||||
|
: null
|
||||||
|
|
||||||
|
// 2) fallback: primeiro active
|
||||||
|
const firstActive = preferred || mem.find(m => m && m.status === 'active' && m.tenant_id)
|
||||||
|
|
||||||
if (!firstActive) {
|
if (!firstActive) {
|
||||||
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
|
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
|
||||||
@@ -292,13 +300,17 @@ export function applyGuards (router) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// roles guard (plural)
|
// roles guard (plural)
|
||||||
const allowedRoles = to.meta?.roles
|
// Se a rota pede roles específicas e o role ativo não bate,
|
||||||
if (Array.isArray(allowedRoles) && allowedRoles.length) {
|
// tenta ajustar o activeRole dentro do mesmo tenant (se houver membership compatível).
|
||||||
if (!matchesRoles(allowedRoles, tenant.activeRole)) {
|
const allowedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : null
|
||||||
const fallback = roleToPath(tenant.activeRole)
|
if (allowedRoles && allowedRoles.length && !allowedRoles.includes(tenant.activeRole)) {
|
||||||
if (to.path === fallback) { console.timeEnd(tlabel); return { path: '/pages/access' } }
|
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
|
||||||
console.timeEnd(tlabel)
|
const compatible = mem.find(m =>
|
||||||
return { path: fallback }
|
m && m.status === 'active' && m.tenant_id === tenantId && allowedRoles.includes(m.role)
|
||||||
|
)
|
||||||
|
if (compatible) {
|
||||||
|
// muda role ativo para o compatível
|
||||||
|
tenant.activeRole = compatible.role
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ async function onSubmit () {
|
|||||||
// ✅ fonte de verdade: tenant_members (rpc my_tenants)
|
// ✅ fonte de verdade: tenant_members (rpc my_tenants)
|
||||||
await tenant.loadSessionAndTenant()
|
await tenant.loadSessionAndTenant()
|
||||||
|
|
||||||
|
console.log('[LOGIN] tenant.user', tenant.user)
|
||||||
|
console.log('[LOGIN] memberships', tenant.memberships)
|
||||||
|
console.log('[LOGIN] activeTenantId', tenant.activeTenantId)
|
||||||
|
console.log('[LOGIN] activeRole', tenant.activeRole)
|
||||||
|
|
||||||
if (!tenant.user) {
|
if (!tenant.user) {
|
||||||
authError.value = 'Não foi possível obter a sessão após login.'
|
authError.value = 'Não foi possível obter a sessão após login.'
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user