Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda

This commit is contained in:
Leonardo
2026-02-22 17:56:01 -03:00
parent 6eff67bf22
commit 89b4ecaba1
77 changed files with 9433 additions and 1995 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
<script setup>
defineProps({})
defineEmits([])
</script>
<template>
<div class="p-4 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<b>AgendaCalendar (placeholder)</b>
</div>
</template>
@@ -0,0 +1,100 @@
<!-- src/features/agenda/components/AgendaClinicCalendar.vue -->
<script setup>
import { computed, ref } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import resourceTimeGridPlugin from '@fullcalendar/resource-timegrid'
import interactionPlugin from '@fullcalendar/interaction'
const props = defineProps({
view: { type: String, default: 'day' }, // 'day' | 'week'
timezone: { type: String, default: 'America/Sao_Paulo' },
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
slotDuration: { type: String, default: '00:30:00' },
slotMinTime: { type: String, default: '06:00:00' },
slotMaxTime: { type: String, default: '22:00:00' },
resources: { type: Array, default: () => [] }, // [{ id, title }]
events: { type: Array, default: () => [] }, // event.resourceId = resource.id
loading: { type: Boolean, default: false }
})
const emit = defineEmits([
'rangeChange',
'eventClick',
'eventDrop',
'eventResize'
])
const calendarRef = ref(null)
const initialView = computed(() => (props.view === 'week' ? 'resourceTimeGridWeek' : 'resourceTimeGridDay'))
const computedSlotMinTime = computed(() => (props.mode === 'full_24h' ? '00:00:00' : props.slotMinTime))
const computedSlotMaxTime = computed(() => (props.mode === 'full_24h' ? '24:00:00' : props.slotMaxTime))
const options = computed(() => ({
plugins: [resourceTimeGridPlugin, interactionPlugin],
initialView: initialView.value,
timeZone: props.timezone,
headerToolbar: false,
nowIndicator: true,
editable: true,
slotDuration: props.slotDuration,
slotMinTime: computedSlotMinTime.value,
slotMaxTime: computedSlotMaxTime.value,
resourceAreaWidth: '280px',
resourceAreaHeaderContent: 'Profissionais',
resources: props.resources,
events: props.events,
datesSet(arg) {
emit('rangeChange', {
start: arg.start,
end: arg.end,
startStr: arg.startStr,
endStr: arg.endStr,
viewType: arg.view.type
})
},
eventClick(info) { emit('eventClick', info) },
eventDrop(info) { emit('eventDrop', info) },
eventResize(info) { emit('eventResize', info) },
height: 'auto',
expandRows: true,
allDaySlot: false
}))
function api () {
const fc = calendarRef.value
return fc?.getApi?.()
}
function goToday () { api()?.today() }
function prev () { api()?.prev() }
function next () { api()?.next() }
function setView (v) { api()?.changeView(v === 'week' ? 'resourceTimeGridWeek' : 'resourceTimeGridDay') }
defineExpose({ goToday, prev, next, setView })
</script>
<template>
<div class="rounded-[1.5rem] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div v-if="loading" class="p-4 text-sm opacity-70 border-b border-[var(--surface-border)]">
Carregando agenda da clínica
</div>
<div class="p-2 md:p-3">
<FullCalendar ref="calendarRef" :options="options" />
</div>
</div>
</template>
@@ -0,0 +1,192 @@
<script setup>
import { computed, ref, watch, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
const props = defineProps({
view: { type: String, default: 'day' }, // 'day' | 'week'
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
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' },
// [{ id, title }]
staff: { type: Array, default: () => [] },
// todos os eventos (com extendedProps.owner_id)
events: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
// controla quantas colunas "visíveis" por vez (resto vai por scroll horizontal)
minColWidth: { type: Number, default: 360 }
})
// ✅ rangeChange = mudança de range (carregar eventos)
// ✅ slotSelect = seleção de intervalo em uma coluna específica (criar evento)
// ✅ eventClick/Drop/Resize = ações em evento
const emit = defineEmits(['rangeChange', 'slotSelect', 'eventClick', 'eventDrop', 'eventResize'])
const calendarRefs = ref([])
function setCalendarRef (el, idx) {
if (!el) return
calendarRefs.value[idx] = el
}
const initialView = computed(() => (props.view === 'week' ? 'timeGridWeek' : 'timeGridDay'))
const computedSlotMinTime = computed(() => (props.mode === 'full_24h' ? '00:00:00' : props.slotMinTime))
// ✅ 23:59:59 para evitar edge-case de 24:00:00
const computedSlotMaxTime = computed(() => (props.mode === 'full_24h' ? '23:59:59' : props.slotMaxTime))
function apiAt (idx) {
const fc = calendarRefs.value[idx]
return fc?.getApi?.()
}
function forEachApi (fn) {
for (let i = 0; i < calendarRefs.value.length; i++) {
const api = apiAt(i)
if (api) fn(api, i)
}
}
function goToday () { forEachApi(api => api.today()) }
function prev () { forEachApi(api => api.prev()) }
function next () { forEachApi(api => api.next()) }
function setView (v) {
const target = v === 'week' ? 'timeGridWeek' : 'timeGridDay'
forEachApi(api => api.changeView(target))
}
defineExpose({ goToday, prev, next, setView })
// Eventos por profissional (owner)
function eventsFor (ownerId) {
const list = props.events || []
return list.filter(e => e?.extendedProps?.owner_id === ownerId)
}
// ---- range sync ----
let lastRangeKey = ''
let suppressSync = false
function onDatesSet (arg) {
const key = `${arg.startStr}__${arg.endStr}__${arg.view?.type || ''}`
if (key === lastRangeKey) return
lastRangeKey = key
// dispara carregamento no pai
emit('rangeChange', {
start: arg.start,
end: arg.end,
startStr: arg.startStr,
endStr: arg.endStr,
viewType: arg.view.type
})
// mantém todos os calendários na mesma data
if (suppressSync) return
suppressSync = true
const masterDate = arg.start
forEachApi((api) => {
const cur = api.view?.currentStart
if (!cur) return
if (cur.getTime() !== masterDate.getTime()) api.gotoDate(masterDate)
})
// libera no próximo tick (evita loops)
Promise.resolve().then(() => { suppressSync = false })
}
// Se trocar view, garante que todos estão no mesmo
watch(() => props.view, async () => {
await nextTick()
setView(props.view)
})
</script>
<template>
<div class="rounded-[1.5rem] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div v-if="loading" class="p-4 text-sm opacity-70 border-b border-[var(--surface-border)]">
Carregando agenda da clínica
</div>
<!-- Mosaic -->
<div
class="p-2 md:p-3 overflow-x-auto"
:style="{ display: 'grid', gridAutoFlow: 'column', gridAutoColumns: `minmax(${minColWidth}px, 1fr)`, gap: '12px' }"
>
<div
v-for="(p, idx) in staff"
:key="p.id"
class="rounded-[1.25rem] border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] overflow-hidden"
>
<!-- Header da coluna -->
<div class="p-3 border-b border-[var(--surface-border)] flex items-center justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold truncate">{{ p.title }}</div>
<div class="text-xs opacity-70 truncate">Visão diária operacional</div>
</div>
<div class="text-xs opacity-70 whitespace-nowrap">
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
</div>
</div>
<div class="p-2">
<FullCalendar
:ref="(el) => setCalendarRef(el, idx)"
:options="{
plugins: [timeGridPlugin, interactionPlugin],
initialView: initialView,
timeZone: timezone,
headerToolbar: false,
nowIndicator: true,
editable: true,
// ✅ seleção para criar evento (por coluna)
selectable: true,
selectMirror: true,
select: (selection) => {
emit('slotSelect', {
ownerId: p.id,
start: selection.start,
end: selection.end,
startStr: selection.startStr,
endStr: selection.endStr,
jsEvent: selection.jsEvent || null,
viewType: selection.view?.type || initialView
})
},
slotDuration: slotDuration,
slotMinTime: computedSlotMinTime,
slotMaxTime: computedSlotMaxTime,
height: 'auto',
expandRows: true,
allDaySlot: false,
events: eventsFor(p.id),
datesSet: onDatesSet,
eventClick: (info) => emit('eventClick', info),
eventDrop: (info) => emit('eventDrop', info),
eventResize: (info) => emit('eventResize', info)
}"
/>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,243 @@
<script setup>
import { computed, ref, watch } from 'vue'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import Dropdown from 'primevue/dropdown'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import FloatLabel from 'primevue/floatlabel'
import InputNumber from 'primevue/inputnumber'
const props = defineProps({
modelValue: { type: Boolean, default: false },
// Para editar
eventRow: { type: Object, default: null },
// Para criar via seleção no calendário
initialStartISO: { type: String, default: '' },
initialEndISO: { type: String, default: '' },
// Quem é o dono da agenda (owner_id)
ownerId: { type: String, default: '' },
// Se estiver criando na visão clínica e quiser atribuir a um owner específico
allowOwnerEdit: { type: Boolean, default: false },
ownerOptions: { type: Array, default: () => [] } // [{ label, value }]
})
const emit = defineEmits(['update:modelValue', 'save', 'delete'])
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const tipoOptions = [
{ label: 'Sessão', value: 'sessao' },
{ label: 'Bloqueio', value: 'bloqueio' },
{ label: 'Pessoal', value: 'pessoal' },
{ label: 'Clínica', value: 'clinica' }
]
const statusOptions = [
{ label: 'Agendado', value: 'agendado' },
{ label: 'Realizado', value: 'realizado' },
{ label: 'Faltou', value: 'faltou' },
{ label: 'Cancelado', value: 'cancelado' }
]
const form = ref(resetForm())
watch(
() => [props.eventRow, props.initialStartISO, props.initialEndISO, props.ownerId],
() => {
form.value = resetForm()
},
{ immediate: true }
)
function resetForm () {
const r = props.eventRow
// ISO strings (timestamptz)
const startISO = r?.inicio_em || props.initialStartISO || ''
const endISO = r?.fim_em || props.initialEndISO || ''
return {
id: r?.id || null,
owner_id: r?.owner_id || props.ownerId || '',
terapeuta_id: r?.terapeuta_id ?? null,
paciente_id: r?.paciente_id ?? null,
tipo: r?.tipo || 'sessao',
status: r?.status || 'agendado',
titulo: r?.titulo || '',
observacoes: r?.observacoes || '',
inicio_em: startISO,
fim_em: endISO,
// ajuda de UX (minutos) caso você queira editar duração fácil
duracaoMin: calcMinutes(startISO, endISO) || 50
}
}
function calcMinutes (a, b) {
try {
if (!a || !b) return null
const ms = new Date(b).getTime() - new Date(a).getTime()
return Math.max(0, Math.round(ms / 60000))
} catch { return null }
}
function addMinutesISO (iso, min) {
const d = new Date(iso)
d.setMinutes(d.getMinutes() + Number(min || 0))
return d.toISOString()
}
const isEdit = computed(() => !!form.value.id)
const canSave = computed(() => {
if (!form.value.owner_id) return false
if (!form.value.inicio_em) return false
if (!form.value.fim_em) return false
const a = new Date(form.value.inicio_em).getTime()
const b = new Date(form.value.fim_em).getTime()
return b > a
})
function applyDuration () {
if (!form.value.inicio_em) return
form.value.fim_em = addMinutesISO(form.value.inicio_em, form.value.duracaoMin || 50)
}
function onSave () {
if (!canSave.value) return
const payload = {
owner_id: form.value.owner_id,
terapeuta_id: form.value.terapeuta_id,
paciente_id: form.value.paciente_id,
tipo: form.value.tipo,
status: form.value.status,
titulo: form.value.titulo || null,
observacoes: form.value.observacoes || null,
inicio_em: form.value.inicio_em,
fim_em: form.value.fim_em
}
emit('save', { id: form.value.id, payload })
}
function onDelete () {
if (!form.value.id) return
emit('delete', form.value.id)
}
</script>
<template>
<Dialog v-model:visible="visible" modal :style="{ width: '720px', maxWidth: '95vw' }" :header="isEdit ? 'Editar evento' : 'Novo evento'">
<div class="grid gap-4">
<div class="grid md:grid-cols-2 gap-3">
<div>
<FloatLabel>
<Dropdown
id="tipo"
class="w-full"
:options="tipoOptions"
optionLabel="label"
optionValue="value"
v-model="form.tipo"
/>
<label for="tipo">Tipo</label>
</FloatLabel>
</div>
<div>
<FloatLabel>
<Dropdown
id="status"
class="w-full"
:options="statusOptions"
optionLabel="label"
optionValue="value"
v-model="form.status"
/>
<label for="status">Status</label>
</FloatLabel>
</div>
<div v-if="allowOwnerEdit">
<FloatLabel>
<Dropdown
id="owner"
class="w-full"
:options="ownerOptions"
optionLabel="label"
optionValue="value"
v-model="form.owner_id"
/>
<label for="owner">Profissional</label>
</FloatLabel>
</div>
<div>
<FloatLabel>
<InputText id="titulo" class="w-full" v-model="form.titulo" placeholder=" " />
<label for="titulo">Título</label>
</FloatLabel>
</div>
</div>
<div class="grid md:grid-cols-2 gap-3">
<div>
<FloatLabel>
<InputText id="inicio" class="w-full" v-model="form.inicio_em" placeholder=" " />
<label for="inicio">Início (ISO)</label>
</FloatLabel>
<div class="text-xs opacity-70 mt-1">Por enquanto em ISO. Depois trocamos para DatePicker bonito.</div>
</div>
<div>
<FloatLabel>
<InputText id="fim" class="w-full" v-model="form.fim_em" placeholder=" " />
<label for="fim">Fim (ISO)</label>
</FloatLabel>
</div>
</div>
<div class="grid md:grid-cols-2 gap-3 items-end">
<div>
<FloatLabel>
<InputNumber id="dur" class="w-full" v-model="form.duracaoMin" :min="5" :max="480" />
<label for="dur">Duração (min)</label>
</FloatLabel>
</div>
<div class="flex gap-2">
<Button label="Aplicar duração" severity="secondary" outlined icon="pi pi-clock" @click="applyDuration" />
</div>
</div>
<div>
<FloatLabel>
<Textarea id="obs" class="w-full" autoResize rows="3" v-model="form.observacoes" placeholder=" " />
<label for="obs">Observações</label>
</FloatLabel>
</div>
<div class="flex justify-between items-center pt-2">
<Button v-if="isEdit" label="Excluir" icon="pi pi-trash" severity="danger" outlined @click="onDelete" />
<div class="flex gap-2 ml-auto">
<Button label="Cancelar" severity="secondary" outlined @click="visible = false" />
<Button label="Salvar" icon="pi pi-check" :disabled="!canSave" @click="onSave" />
</div>
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,13 @@
<script setup>
defineProps({})
</script>
<template>
<div class="p-4 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<b>AgendaRightPanel (placeholder)</b>
<div class="mt-3">
<slot name="top" />
<slot name="bottom" />
</div>
</div>
</template>
@@ -0,0 +1,115 @@
<script setup>
import { computed, ref, watch } from 'vue'
import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton'
import ToggleButton from 'primevue/togglebutton'
import FloatLabel from 'primevue/floatlabel'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
const props = defineProps({
title: { type: String, default: 'Agenda' },
// 'day' | 'week'
view: { type: String, default: 'day' },
// 'full_24h' | 'work_hours'
mode: { type: String, default: 'work_hours' },
showSearch: { type: Boolean, default: true },
searchPlaceholder: { type: String, default: ' ' },
// controla se exibe botões de ação
showActions: { type: Boolean, default: true }
})
const emit = defineEmits([
'today',
'prev',
'next',
'changeView',
'toggleMode',
'createSession',
'createBlock',
'search'
])
const viewOptions = [
{ label: 'Dia', value: 'day' },
{ label: 'Semana', value: 'week' }
]
const search = ref('')
watch(search, (v) => emit('search', v))
const modeLabel = computed(() => (props.mode === 'full_24h' ? '24h' : 'Horário'))
</script>
<template>
<div class="mb-4 overflow-hidden rounded-[1.5rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="p-4 md:p-5 flex flex-col gap-3">
<!-- topo -->
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="text-lg md:text-xl font-semibold truncate">{{ title }}</div>
<div class="text-sm opacity-70">Operação do dia com visão e ação.</div>
</div>
<div class="flex items-center gap-2">
<Button label="Hoje" icon="pi pi-calendar" severity="secondary" outlined @click="$emit('today')" />
<Button icon="pi pi-chevron-left" severity="secondary" outlined @click="$emit('prev')" />
<Button icon="pi pi-chevron-right" severity="secondary" outlined @click="$emit('next')" />
</div>
</div>
<!-- controles -->
<div class="flex flex-col md:flex-row md:items-center gap-3 md:justify-between">
<div class="flex flex-wrap items-center gap-2">
<SelectButton
:modelValue="view"
:options="viewOptions"
optionLabel="label"
optionValue="value"
@update:modelValue="$emit('changeView', $event)"
/>
<ToggleButton
:modelValue="mode === 'full_24h'"
onLabel="24h"
offLabel="Horário"
@update:modelValue="$emit('toggleMode', $event ? 'full_24h' : 'work_hours')"
/>
<div class="text-sm opacity-70">
Modo: <b>{{ modeLabel }}</b>
</div>
</div>
<div class="flex flex-col md:flex-row items-stretch md:items-center gap-2">
<template v-if="showActions">
<Button label="Nova sessão" icon="pi pi-plus" @click="$emit('createSession')" />
<Button label="Bloquear" icon="pi pi-lock" severity="secondary" outlined @click="$emit('createBlock')" />
</template>
<div v-if="showSearch" class="md:w-72 w-full">
<FloatLabel>
<IconField>
<InputIcon class="pi pi-search" />
<InputText
id="agendaSearch"
class="w-full"
v-model="search"
:placeholder="searchPlaceholder"
/>
</IconField>
<label for="agendaSearch">Buscar por paciente/título</label>
</FloatLabel>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,12 @@
<script setup>
defineProps({
items: { type: Array, default: () => [] }
})
defineEmits(['open', 'confirm', 'reschedule'])
</script>
<template>
<div class="p-3 border rounded-lg">
<b>AgendaNextSessionsCardList (placeholder)</b>
</div>
</template>
@@ -0,0 +1,12 @@
<script setup>
defineProps({
stats: { type: Object, default: () => ({}) }
})
defineEmits(['quickBlock', 'quickCreate'])
</script>
<template>
<div class="p-3 border rounded-lg">
<b>AgendaPulseCardGrid (placeholder)</b>
</div>
</template>
@@ -0,0 +1,24 @@
// src/features/agenda/composables/useAgendaClinicStaff.js
import { ref } from 'vue'
import { listTenantStaff } from '../services/agendaRepository'
export function useAgendaClinicStaff () {
const loading = ref(false)
const error = ref('')
const staff = ref([])
async function load (tenantId) {
loading.value = true
error.value = ''
try {
staff.value = await listTenantStaff(tenantId)
} catch (e) {
error.value = e?.message || 'Falha ao carregar profissionais.'
staff.value = []
} finally {
loading.value = false
}
}
return { loading, error, staff, load }
}
@@ -0,0 +1,106 @@
// src/features/agenda/composables/useAgendaEvents.js
import { ref } from 'vue'
import {
listMyAgendaEvents,
listClinicEvents,
createAgendaEvento,
updateAgendaEvento,
deleteAgendaEvento
} from '../services/agendaRepository.js'
export function useAgendaEvents () {
const loading = ref(false)
const error = ref('')
const rows = ref([])
async function loadMyRange (startISO, endISO) {
loading.value = true
error.value = ''
try {
rows.value = await listMyAgendaEvents({ startISO, endISO })
return rows.value
} catch (e) {
error.value = e?.message || 'Falha ao carregar eventos.'
rows.value = []
return []
} finally {
loading.value = false
}
}
async function loadClinicRange (ownerIds, startISO, endISO) {
loading.value = true
error.value = ''
try {
// ✅ evita erro "invalid input syntax for type uuid: null"
const safeIds = (ownerIds || []).filter(id => typeof id === 'string' && id && id !== 'null' && id !== 'undefined')
if (!safeIds.length) {
rows.value = []
return []
}
rows.value = await listClinicEvents({ ownerIds: safeIds, startISO, endISO })
return rows.value
} catch (e) {
error.value = e?.message || 'Falha ao carregar eventos da clínica.'
rows.value = []
return []
} finally {
loading.value = false
}
}
async function create (payload) {
loading.value = true
error.value = ''
try {
const created = await createAgendaEvento(payload)
return created
} catch (e) {
error.value = e?.message || 'Falha ao criar evento.'
throw e
} finally {
loading.value = false
}
}
async function update (id, patch) {
loading.value = true
error.value = ''
try {
const updated = await updateAgendaEvento(id, patch)
return updated
} catch (e) {
error.value = e?.message || 'Falha ao atualizar evento.'
throw e
} finally {
loading.value = false
}
}
async function remove (id) {
loading.value = true
error.value = ''
try {
await deleteAgendaEvento(id)
return true
} catch (e) {
error.value = e?.message || 'Falha ao excluir evento.'
throw e
} finally {
loading.value = false
}
}
return {
loading,
error,
rows,
loadMyRange,
loadClinicRange,
create,
update,
remove
}
}
@@ -0,0 +1,24 @@
// src/features/agenda/composables/useAgendaSettings.js
import { ref } from 'vue'
import { getMyAgendaSettings } from '../services/agendaRepository'
export function useAgendaSettings () {
const loading = ref(false)
const error = ref('')
const settings = ref(null)
async function load () {
loading.value = true
error.value = ''
try {
settings.value = await getMyAgendaSettings()
} catch (e) {
error.value = e?.message || 'Falha ao carregar configurações da agenda.'
settings.value = null
} finally {
loading.value = false
}
}
return { loading, error, settings, load }
}
@@ -0,0 +1,288 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import AgendaToolbar from '../components/AgendaToolbar.vue'
import AgendaClinicMosaic from '../components/AgendaClinicMosaic.vue'
import AgendaEventDialog from '../components/AgendaEventDialog.vue'
import { useAgendaEvents } from '../composables/useAgendaEvents.js'
import { useAgendaClinicStaff } from '../composables/useAgendaClinicStaff.js'
import { mapAgendaEventosToCalendarEvents } from '../services/agendaMappers.js'
import { useTenantStore } from '@/stores/tenantStore'
const toast = useToast()
const tenantStore = useTenantStore()
// -------------------- UI state --------------------
const view = ref('day')
const mode = ref('work_hours')
const calendarRef = ref(null)
const dialogOpen = ref(false)
const dialogEventRow = ref(null)
const dialogStartISO = ref('')
const dialogEndISO = ref('')
// guardamos o range atual (para recarregar depois)
const currentRange = ref({ start: null, end: null })
// -------------------- data --------------------
const { loading: loadingStaff, error: staffError, staff, load: loadStaff } = useAgendaClinicStaff()
// ✅ agora já pega também create/update/remove (se você já atualizou o composable)
const {
loading: loadingEvents,
error: eventsError,
rows,
loadClinicRange,
create,
update,
remove
} = useAgendaEvents()
const tenantId = computed(() => {
const t = tenantStore.activeTenantId
if (!t) return null
if (t === 'null' || t === 'undefined') return null
return t
})
function isUuid (v) {
return typeof v === 'string' &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)
}
const staffCols = computed(() => {
return (staff.value || [])
.filter(s => isUuid(s.user_id))
.map(s => ({
id: s.user_id,
title: s.full_name || s.nome || s.name || s.email || 'Profissional'
}))
})
// ✅ AQUI está o que faltava: ownerIds
const ownerIds = computed(() => staffCols.value.map(p => p.id))
const allEvents = computed(() => mapAgendaEventosToCalendarEvents(rows.value || []))
const ownerOptions = computed(() =>
staffCols.value.map(p => ({ label: p.title, value: p.id }))
)
// -------------------- lifecycle --------------------
onMounted(async () => {
if (!tenantId.value) {
toast.add({ severity: 'warn', summary: 'Clínica', detail: 'Nenhum tenant ativo.', life: 4500 })
return
}
await loadStaff(tenantId.value)
if (staffError.value) {
toast.add({ severity: 'warn', summary: 'Profissionais', detail: staffError.value, life: 4500 })
}
})
// -------------------- toolbar actions --------------------
function onToday () { calendarRef.value?.goToday?.() }
function onPrev () { calendarRef.value?.prev?.() }
function onNext () { calendarRef.value?.next?.() }
function onChangeView (v) {
view.value = v
calendarRef.value?.setView?.(v)
}
function onToggleMode (m) {
mode.value = m
}
// -------------------- calendar callbacks --------------------
async function onRangeChange ({ start, end }) {
currentRange.value = { start, end }
const ids = ownerIds.value
if (!ids.length) return
await loadClinicRange(ids, new Date(start).toISOString(), new Date(end).toISOString())
if (eventsError.value) {
toast.add({ severity: 'warn', summary: 'Eventos', detail: eventsError.value, life: 4500 })
}
}
function onEventClick (info) {
const ev = info?.event
if (!ev) return
dialogEventRow.value = {
id: ev.id,
owner_id: ev.extendedProps?.owner_id,
terapeuta_id: ev.extendedProps?.terapeuta_id ?? null,
paciente_id: ev.extendedProps?.paciente_id ?? null,
tipo: ev.extendedProps?.tipo,
status: ev.extendedProps?.status,
titulo: ev.title,
observacoes: ev.extendedProps?.observacoes ?? null,
inicio_em: ev.start?.toISOString?.() || ev.startStr,
fim_em: ev.end?.toISOString?.() || ev.endStr
}
dialogStartISO.value = ''
dialogEndISO.value = ''
dialogOpen.value = true
}
async function persistMoveOrResize (info, actionLabel) {
try {
const ev = info?.event
if (!ev) return
const id = ev.id
const startISO = ev.start ? ev.start.toISOString() : null
const endISO = ev.end ? ev.end.toISOString() : null
if (!startISO || !endISO) throw new Error('Evento sem start/end.')
await update(id, { inicio_em: startISO, fim_em: endISO })
toast.add({
severity: 'success',
summary: actionLabel,
detail: 'Alteração salva.',
life: 1800
})
} catch (e) {
// desfaz no calendário
info?.revert?.()
toast.add({
severity: 'warn',
summary: 'Erro',
detail: eventsError.value || e?.message || 'Falha ao salvar alteração.',
life: 4500
})
}
}
function onEventDrop (info) {
persistMoveOrResize(info, 'Movido')
}
function onEventResize (info) {
persistMoveOrResize(info, 'Redimensionado')
}
// -------------------- dialog actions (mínimo funcional) --------------------
function onCreateClinicEvent () {
// cria evento base (depois você troca para "selecionar no calendário", mas aqui é bom pra começar)
const start = new Date()
const end = new Date(Date.now() + 50 * 60000)
dialogEventRow.value = null
dialogStartISO.value = start.toISOString()
dialogEndISO.value = end.toISOString()
dialogOpen.value = true
}
async function onDialogSave ({ id, payload }) {
try {
if (id) await update(id, payload)
else await create(payload)
dialogOpen.value = false
// recarrega range atual se existir
if (currentRange.value.start && currentRange.value.end) {
await loadClinicRange(
ownerIds.value,
new Date(currentRange.value.start).toISOString(),
new Date(currentRange.value.end).toISOString()
)
}
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Evento salvo.', life: 2500 })
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao salvar.', life: 4500 })
}
}
async function onDialogDelete (id) {
try {
await remove(id)
dialogOpen.value = false
if (currentRange.value.start && currentRange.value.end) {
await loadClinicRange(
ownerIds.value,
new Date(currentRange.value.start).toISOString(),
new Date(currentRange.value.end).toISOString()
)
}
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Evento removido.', life: 2500 })
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao excluir.', life: 4500 })
}
}
function onSlotSelect ({ ownerId, start, end }) {
dialogEventRow.value = null
dialogStartISO.value = new Date(start).toISOString()
dialogEndISO.value = new Date(end).toISOString()
// aqui você pode setar o owner default do dialog via ownerId
// o Dialog já tem dropdown, mas você pode passar ownerId no payload quando salvar
dialogOpen.value = true
// opcional: guardar pra preselecionar no dialog (se você implementar isso)
// dialogOwnerId.value = ownerId
}
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<AgendaToolbar
title="Agenda da clínica"
:view="view"
:mode="mode"
@today="onToday"
@prev="onPrev"
@next="onNext"
@changeView="onChangeView"
@toggleMode="onToggleMode"
@createSession="onCreateClinicEvent"
@createBlock="() => toast.add({ severity: 'info', summary: 'Bloqueio', detail: 'Próximo passo: bloqueio da clínica.', life: 2500 })"
/>
<AgendaClinicMosaic
ref="calendarRef"
:view="view"
:mode="mode"
:staff="staffCols"
:events="allEvents"
:loading="loadingStaff || loadingEvents"
@rangeChange="onRangeChange"
@slotSelect="onSlotSelect"
@eventClick="onEventClick"
@eventDrop="onEventDrop"
@eventResize="onEventResize"
/>
<AgendaEventDialog
v-model="dialogOpen"
:eventRow="dialogEventRow"
:initialStartISO="dialogStartISO"
:initialEndISO="dialogEndISO"
:ownerId="staffCols?.[0]?.id || ''"
:allowOwnerEdit="true"
:ownerOptions="ownerOptions"
@save="onDialogSave"
@delete="onDialogDelete"
/>
</div>
</template>
@@ -0,0 +1,449 @@
<!-- src/features/agenda/pages/AgendaTerapeutaPage.vue -->
<script setup>
import { computed, onMounted, ref } from 'vue'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import AgendaEventDialog from '../components/AgendaEventDialog.vue'
import AgendaToolbar from '../components/AgendaToolbar.vue'
import AgendaCalendar from '../components/AgendaCalendar.vue'
import AgendaRightPanel from '../components/AgendaRightPanel.vue'
import AgendaNextSessionsCardList from '../components/cards/AgendaNextSessionsCardList.vue'
import AgendaPulseCardGrid from '../components/cards/AgendaPulseCardGrid.vue'
import { useAgendaSettings } from '../composables/useAgendaSettings'
import { useAgendaEvents } from '../composables/useAgendaEvents'
import {
mapAgendaEventosToCalendarEvents,
buildNextSessions,
buildWeeklyBreakBackgroundEvents,
calcDefaultSlotDuration,
minutesToDuration
} from '../services/agendaMappers'
const toast = useToast()
// -----------------------------
// State
// -----------------------------
const view = ref('day') // 'day' | 'week'
const mode = ref('work_hours') // 'full_24h' | 'work_hours'
const searchQuery = ref('')
const calendarRef = ref(null)
const { loading: loadingSettings, error: settingsError, settings, load: loadSettings } = useAgendaSettings()
const { loading: loadingEvents, error: eventsError, rows, loadMyRange, create, update, remove } = useAgendaEvents()
const dialogOpen = ref(false)
const dialogEventRow = ref(null)
const dialogStartISO = ref('')
const dialogEndISO = ref('')
const currentRange = ref({ start: null, end: null })
// Range atual (FullCalendar)
const currentRange = ref({ start: new Date(), end: new Date() })
// -----------------------------
// Derived: settings -> calendar behavior
// -----------------------------
const timezone = computed(() => settings.value?.timezone || 'America/Sao_Paulo')
const slotDuration = computed(() => {
if (!settings.value) return '00:30:00'
return calcDefaultSlotDuration(settings.value)
})
// work hours recorte (visual)
const slotMinTime = computed(() => {
if (!settings.value) return '06:00:00'
// Se estiver no modo "work_hours", você quer mostrar um pouco antes
// Aqui respeitamos admin_inicio_visualizacao se usar_horario_admin_custom estiver true,
// senão tentamos agenda_custom_start, senão default.
const s = settings.value
const base =
(s.usar_horario_admin_custom && s.admin_inicio_visualizacao) ||
s.agenda_custom_start ||
'06:00:00'
// padding -1h
return padTime(base, -60)
})
const slotMaxTime = computed(() => {
if (!settings.value) return '22:00:00'
const s = settings.value
const base =
(s.usar_horario_admin_custom && s.admin_fim_visualizacao) ||
s.agenda_custom_end ||
'22:00:00'
// padding +1h
return padTime(base, +60)
})
// business hours “verdadeiro” (sem padding)
const businessHours = computed(() => {
if (!settings.value) return []
const s = settings.value
const start =
(s.usar_horario_admin_custom && s.admin_inicio_visualizacao) ||
s.agenda_custom_start ||
'08:00:00'
const end =
(s.usar_horario_admin_custom && s.admin_fim_visualizacao) ||
s.agenda_custom_end ||
'18:00:00'
// Semana inteira (você pode trocar isso pra algo vindo de agenda_regras_semanais depois)
return [
{ daysOfWeek: [1,2,3,4,5], startTime: start, endTime: end }
]
})
// Eventos do banco -> FullCalendar
const calendarEvents = computed(() => {
const base = mapAgendaEventosToCalendarEvents(rows.value || [])
// Pausas semanais (jsonb) -> background events
const breaks = settings.value
? buildWeeklyBreakBackgroundEvents(
settings.value.pausas_semanais,
currentRange.value.start,
currentRange.value.end
)
: []
return [...base, ...breaks]
})
// Cards de próximas sessões
const nextSessions = computed(() => buildNextSessions(rows.value || []))
// Pulse stats (bem inicial, mas já útil)
const pulseStats = computed(() => {
const list = rows.value || []
const totalSessions = list.filter(r => (r.tipo || '').toLowerCase().includes('sess')).length
const totalMinutes = list.reduce((acc, r) => {
const ms = new Date(r.fim_em).getTime() - new Date(r.inicio_em).getTime()
return acc + Math.max(0, Math.round(ms / 60000))
}, 0)
const pending = list.filter(r => (r.status || '').toLowerCase().includes('pend')).length
const reschedules = list.filter(r => (r.status || '').toLowerCase().includes('remarc')).length
const attentions = pending + reschedules
// Sugerir encaixes (placeholder): depois vamos calcular via gaps no range.
const suggested1 = '—'
const suggested2 = '—'
const nextBreak = '—' // depois calculamos pela pausa semanal + "agora"
return {
totalSessions,
totalMinutes,
biggestFreeWindow: '—',
pending,
reschedules,
attentions,
suggested1,
suggested2,
nextBreak
}
})
// -----------------------------
// Lifecycle
// -----------------------------
onMounted(async () => {
await loadSettings()
if (settingsError.value) {
toast.add({ severity: 'warn', summary: 'Agenda', detail: settingsError.value, life: 4500 })
}
// aplica modo inicial vindo da config
if (settings.value?.agenda_view_mode) {
mode.value = settings.value.agenda_view_mode === 'full_24h' ? 'full_24h' : 'work_hours'
}
})
// -----------------------------
// Actions: toolbar
// -----------------------------
function onToday() { calendarRef.value?.goToday?.() }
function onPrev() { calendarRef.value?.prev?.() }
function onNext() { calendarRef.value?.next?.() }
function onChangeView(v) {
view.value = v
calendarRef.value?.setView?.(v)
}
function onToggleMode(m) {
mode.value = m
}
function onSearch(q) {
searchQuery.value = q || ''
// Por enquanto a busca não filtra o FullCalendar (isso exige requery ou filtro local).
// Vamos plugar isso quando tiver patient join e título mais rico.
}
function onCreateSession() {
toast.add({ severity: 'info', summary: 'Nova sessão', detail: 'Abrir modal de criação (próximo passo).', life: 2500 })
}
function onCreateBlock() {
toast.add({ severity: 'info', summary: 'Bloquear horário', detail: 'Abrir modal de bloqueio (próximo passo).', life: 2500 })
}
const staffCols = computed(() => (staff.value || [])
.filter(s => typeof s.user_id === 'string' && s.user_id && s.user_id !== 'null' && s.user_id !== 'undefined')
.map(s => ({
id: s.user_id,
title: s.full_name || s.nome || s.name || s.email || 'Profissional'
}))
)
const ownerIds = computed(() => staffCols.value.map(s => s.id))
const allEvents = computed(() => mapAgendaEventosToCalendarEvents(rows.value || []))
// -----------------------------
// FullCalendar callbacks
// -----------------------------
async function onRangeChange ({ start, end }) {
currentRange.value = { start, end }
const ids = ownerIds.value
if (!ids.length) return
await loadClinicRange(ids, new Date(start).toISOString(), new Date(end).toISOString())
if (eventsError.value) {
toast.add({ severity: 'warn', summary: 'Eventos', detail: eventsError.value, life: 4500 })
}
}
function onSelectTime (selection) {
const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50
const startISO = new Date(selection.start).toISOString()
const endISO = new Date(new Date(selection.start).getTime() + durMin * 60000).toISOString()
dialogEventRow.value = null
dialogStartISO.value = startISO
dialogEndISO.value = endISO
dialogOpen.value = true
}
function onEventClick (info) {
const ev = info?.event
if (!ev) return
dialogEventRow.value = {
id: ev.id,
owner_id: ev.extendedProps?.owner_id,
terapeuta_id: ev.extendedProps?.terapeuta_id ?? null,
paciente_id: ev.extendedProps?.paciente_id ?? null,
tipo: ev.extendedProps?.tipo,
status: ev.extendedProps?.status,
titulo: ev.title,
observacoes: ev.extendedProps?.observacoes ?? null,
inicio_em: ev.start?.toISOString?.() || ev.startStr,
fim_em: ev.end?.toISOString?.() || ev.endStr
}
dialogStartISO.value = ''
dialogEndISO.value = ''
dialogOpen.value = true
}
async function persistMoveOrResize (info, actionLabel) {
try {
const ev = info?.event
if (!ev) return
const id = ev.id
const startISO = ev.start ? ev.start.toISOString() : null
const endISO = ev.end ? ev.end.toISOString() : null
if (!startISO || !endISO) throw new Error('Evento sem start/end.')
await update(id, { inicio_em: startISO, fim_em: endISO })
toast.add({
severity: 'success',
summary: actionLabel,
detail: 'Alteração salva.',
life: 1800
})
} catch (e) {
// desfaz no calendário
info?.revert?.()
toast.add({
severity: 'warn',
summary: 'Erro',
detail: eventsError.value || e?.message || 'Falha ao salvar alteração.',
life: 4500
})
}
}
function onEventDrop (info) {
persistMoveOrResize(info, 'Movido')
}
function onEventResize (info) {
persistMoveOrResize(info, 'Redimensionado')
}
function onOpenFromCard (it) {
toast.add({
severity: 'info',
summary: 'Evento',
detail: it?.title || 'Evento',
life: 2500
})
}
function onConfirmFromCard () {
toast.add({
severity: 'success',
summary: 'Confirmar',
detail: 'Ação de confirmar (próximo passo: update no banco).',
life: 2500
})
}
function onRescheduleFromCard () {
toast.add({
severity: 'info',
summary: 'Remarcar',
detail: 'Ação de remarcar (próximo passo: fluxo de reagendamento).',
life: 2500
})
}
// -----------------------------
// Utils
// -----------------------------
function padTime(hhmmss, deltaMin) {
// hh:mm:ss
const [hh, mm, ss] = String(hhmmss || '00:00:00').split(':').map(Number)
let total = (hh * 60 + mm) + deltaMin
if (total < 0) total = 0
if (total > 24 * 60) total = 24 * 60
return minutesToDuration(total)
}
async function onDialogSave ({ id, payload }) {
try {
if (id) {
await update(id, payload)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Evento atualizado.', life: 2500 })
} else {
await create(payload)
toast.add({ severity: 'success', summary: 'Criado', detail: 'Evento criado.', life: 2500 })
}
dialogOpen.value = false
// recarrega o range atual
await loadMyRange(
new Date(currentRange.value.start).toISOString(),
new Date(currentRange.value.end).toISOString()
)
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao salvar.', life: 4500 })
}
}
async function onDialogDelete (id) {
try {
await remove(id)
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Evento removido.', life: 2500 })
dialogOpen.value = false
await loadMyRange(
new Date(currentRange.value.start).toISOString(),
new Date(currentRange.value.end).toISOString()
)
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao excluir.', life: 4500 })
}
}
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<AgendaToolbar
title="Minha agenda"
:view="view"
:mode="mode"
@today="onToday"
@prev="onPrev"
@next="onNext"
@changeView="onChangeView"
@toggleMode="onToggleMode"
@createSession="onCreateSession"
@createBlock="onCreateBlock"
@search="onSearch"
/>
<div class="grid gap-3 md:gap-4" style="grid-template-columns: 1fr; align-items: stretch;">
<div class="grid gap-3 md:gap-4 md:grid-cols-[1fr_380px]">
<!-- LEFT: Calendar -->
<AgendaCalendar
ref="calendarRef"
:view="view"
:mode="mode"
:timezone="timezone"
:slotDuration="slotDuration"
:slotMinTime="slotMinTime"
:slotMaxTime="slotMaxTime"
:businessHours="businessHours"
:events="calendarEvents"
:loading="loadingSettings || loadingEvents"
@rangeChange="onRangeChange"
@selectTime="onSelectTime"
@eventClick="onEventClick"
@eventDrop="onEventDrop"
@eventResize="onEventResize"
/>
<!-- RIGHT: Panel -->
<AgendaRightPanel>
<template #top>
<AgendaNextSessionsCardList
:items="nextSessions"
@open="onOpenFromCard"
@confirm="onConfirmFromCard"
@reschedule="onRescheduleFromCard"
/>
</template>
<template #bottom>
<AgendaPulseCardGrid
:stats="pulseStats"
@quickBlock="onCreateBlock"
@quickCreate="onCreateSession"
/>
</template>
</AgendaRightPanel>
</div>
</div>
<AgendaEventDialog
v-model="dialogOpen"
:eventRow="dialogEventRow"
:initialStartISO="dialogStartISO"
:initialEndISO="dialogEndISO"
:ownerId="(settings?.owner_id || '')"
@save="onDialogSave"
@delete="onDialogDelete"
/>
</div>
</template>
@@ -0,0 +1,146 @@
// src/features/agenda/services/agendaMappers.js
export function mapAgendaEventosToCalendarEvents (rows) {
return (rows || []).map((r) => ({
id: r.id,
title: r.titulo || tituloFallback(r.tipo),
start: r.inicio_em,
end: r.fim_em,
extendedProps: {
tipo: r.tipo,
status: r.status,
paciente_id: r.paciente_id,
terapeuta_id: r.terapeuta_id,
observacoes: r.observacoes,
owner_id: r.owner_id
}
}))
}
export function buildNextSessions (rows, now = new Date()) {
const nowMs = now.getTime()
return (rows || [])
.filter((r) => new Date(r.fim_em).getTime() >= nowMs)
.slice(0, 6)
.map((r) => ({
id: r.id,
title: r.titulo || tituloFallback(r.tipo),
startISO: r.inicio_em,
endISO: r.fim_em,
tipo: r.tipo,
status: r.status,
pacienteId: r.paciente_id || null
}))
}
export function calcDefaultSlotDuration (settings) {
const min =
((settings?.usar_granularidade_custom && settings?.granularidade_min) || 0) ||
settings?.admin_slot_visual_minutos ||
30
return minutesToDuration(min)
}
export function minutesToDuration (min) {
const h = Math.floor(min / 60)
const m = min % 60
const hh = String(h).padStart(2, '0')
const mm = String(m).padStart(2, '0')
return `${hh}:${mm}:00`
}
export function tituloFallback (tipo) {
const t = String(tipo || '').toLowerCase()
if (t.includes('sess')) return 'Sessão'
if (t.includes('block') || t.includes('bloq')) return 'Bloqueio'
if (t.includes('pessoal')) return 'Pessoal'
if (t.includes('clin')) return 'Clínica'
return 'Compromisso'
}
/**
* Pausas semanais (jsonb) -> background events do FullCalendar.
* Leitura flexível:
* - esperado: [{ weekday: 1..7 ou 0..6, start:"HH:MM", end:"HH:MM", label }]
*/
export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd) {
if (!Array.isArray(pausas) || pausas.length === 0) return []
const out = []
const dayMs = 24 * 60 * 60 * 1000
for (let ts = startOfDay(rangeStart).getTime(); ts < rangeEnd.getTime(); ts += dayMs) {
const d = new Date(ts)
const dow = d.getDay() // 0..6
for (const p of pausas) {
const wd = normalizeWeekday(p?.weekday)
if (wd === null) continue
if (wd !== dow) continue
const start = asTime(p?.start ?? p?.inicio ?? p?.from)
const end = asTime(p?.end ?? p?.fim ?? p?.to)
if (!start || !end) continue
out.push({
id: `break-${ts}-${start}-${end}`,
start: combineDateTimeISO(d, start),
end: combineDateTimeISO(d, end),
display: 'background',
overlap: false,
extendedProps: { kind: 'break', label: p?.label ?? 'Pausa' }
})
}
}
return out
}
export function mapAgendaEventosToClinicResourceEvents (rows) {
return (rows || []).map((r) => ({
id: r.id,
title: r.titulo || tituloFallback(r.tipo),
start: r.inicio_em,
end: r.fim_em,
resourceId: r.owner_id, // 🔥 coluna = dono da agenda (profissional)
extendedProps: {
tipo: r.tipo,
status: r.status,
paciente_id: r.paciente_id,
terapeuta_id: r.terapeuta_id,
observacoes: r.observacoes,
owner_id: r.owner_id
}
}))
}
function normalizeWeekday (value) {
if (value === null || value === undefined) return null
const n = Number(value)
if (Number.isNaN(n)) return null
if (n >= 0 && n <= 6) return n
if (n >= 1 && n <= 7) return n === 7 ? 0 : n
return null
}
function asTime (v) {
if (!v || typeof v !== 'string') return null
const s = v.trim()
if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00`
if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s
return null
}
function startOfDay (d) {
const x = new Date(d)
x.setHours(0, 0, 0, 0)
return x
}
function combineDateTimeISO (date, timeHHMMSS) {
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}T${timeHHMMSS}`
}
@@ -0,0 +1,98 @@
// src/features/agenda/services/agendaRepository.js
import { supabase } from '@/lib/supabase/client'
export async function getMyAgendaSettings () {
const { data: userRes, error: userErr } = await supabase.auth.getUser()
if (userErr) throw userErr
const uid = userRes?.user?.id
if (!uid) throw new Error('Usuário não autenticado.')
const { data, error } = await supabase
.from('agenda_configuracoes')
.select('*')
.eq('owner_id', uid)
.single()
if (error) throw error
return data
}
export async function listMyAgendaEvents ({ startISO, endISO }) {
const { data: userRes, error: userErr } = await supabase.auth.getUser()
if (userErr) throw userErr
const uid = userRes?.user?.id
if (!uid) throw new Error('Usuário não autenticado.')
const { data, error } = await supabase
.from('agenda_eventos')
.select('*')
.eq('owner_id', uid)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
.order('inicio_em', { ascending: true })
if (error) throw error
return data || []
}
export async function listClinicEvents ({ ownerIds, startISO, endISO }) {
if (!ownerIds?.length) return []
const { data, error } = await supabase
.from('agenda_eventos')
.select('*')
.in('owner_id', ownerIds)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
.order('inicio_em', { ascending: true })
if (error) throw error
return data || []
}
export async function listTenantStaff (tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') return []
const { data, error } = await supabase
.from('v_tenant_staff')
.select('*')
.eq('tenant_id', tenantId)
if (error) throw error
return data || []
}
export async function createAgendaEvento (payload) {
const { data, error } = await supabase
.from('agenda_eventos')
.insert(payload)
.select('*')
.single()
if (error) throw error
return data
}
export async function updateAgendaEvento (id, patch) {
const { data, error } = await supabase
.from('agenda_eventos')
.update(patch)
.eq('id', id)
.select('*')
.single()
if (error) throw error
return data
}
export async function deleteAgendaEvento (id) {
const { error } = await supabase
.from('agenda_eventos')
.delete()
.eq('id', id)
if (error) throw error
return true
}