Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras

This commit is contained in:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions
+141 -144
View File
@@ -15,213 +15,210 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch, onMounted } from 'vue'
import { computed, ref, watch, onMounted } from 'vue';
import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
import dayGridPlugin from '@fullcalendar/daygrid'
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'
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'
// 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: () => [] },
// 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 }
})
// data
events: { type: Array, default: () => [] },
loading: { type: Boolean, default: false }
});
const emit = defineEmits([
'rangeChange',
'selectTime',
'eventClick',
'eventDrop',
'eventResize'
])
const emit = defineEmits(['rangeChange', 'selectTime', 'eventClick', 'eventDrop', 'eventResize']);
const fcRef = ref(null)
const fcRef = ref(null);
const initialView = computed(() => (props.view === 'week' ? 'timeGridWeek' : 'timeGridDay'))
const initialView = computed(() => (props.view === 'week' ? 'timeGridWeek' : 'timeGridDay'));
function getApi () {
const inst = fcRef.value
return inst?.getApi?.() || null
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 })
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'
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'
// 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,
return {
plugins: [timeGridPlugin, interactionPlugin, dayGridPlugin],
initialView: initialView.value,
timeZone: props.timezone,
timeZone: props.timezone,
// Header desativado (você controla no Toolbar)
headerToolbar: false,
// Header desativado (você controla no Toolbar)
headerToolbar: false,
// Visão "produto": blocos com linhas suaves
nowIndicator: true,
allDaySlot: false,
expandRows: true,
height: 'auto',
// 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,
// 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
},
// 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,
// Horário "verdadeiro" de funcionamento (se você usar)
businessHours: props.businessHours,
// Dados
events: props.events,
// Dados
events: props.events,
// Melhor UX
weekends: true,
firstDay: 1, // segunda
// Melhor UX
weekends: true,
firstDay: 1, // segunda
// Callbacks
datesSet: () => {
// dispara quando muda o intervalo exibido (prev/next/today/view)
emitRange()
},
// Callbacks
datesSet: () => {
// dispara quando muda o intervalo exibido (prev/next/today/view)
emitRange();
},
select: (selection) => {
// selection: { start, end, allDay, ... }
emit('selectTime', selection)
},
select: (selection) => {
// selection: { start, end, allDay, ... }
emit('selectTime', selection);
},
eventClick: (info) => emit('eventClick', info),
eventDrop: (info) => emit('eventDrop', info),
eventResize: (info) => emit('eventResize', info)
}
})
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')
function goToday() {
getApi()?.today?.();
}
function prev() {
getApi()?.prev?.();
}
function next() {
getApi()?.next?.();
}
defineExpose({ goToday, prev, next, setView })
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)
)
() => props.view,
(v) => setView(v)
);
// Emite o range inicial assim que montar
onMounted(() => {
// garante que o FullCalendar já criou a view
setTimeout(() => emitRange(), 0)
})
// garante que o FullCalendar já criou a view
setTimeout(() => emitRange(), 0);
});
</script>
<template>
<div class="agenda-calendar-wrap">
<div v-if="loading" class="agenda-calendar-loading">
<div class="flex flex-col gap-2 w-full px-2 py-2">
<Skeleton height="2rem" class="mb-1" />
<div class="grid grid-cols-7 gap-1">
<Skeleton v-for="n in 7" :key="n" height="1.25rem" />
<div class="agenda-calendar-wrap">
<div v-if="loading" class="agenda-calendar-loading">
<div class="flex flex-col gap-2 w-full px-2 py-2">
<Skeleton height="2rem" class="mb-1" />
<div class="grid grid-cols-7 gap-1">
<Skeleton v-for="n in 7" :key="n" height="1.25rem" />
</div>
<Skeleton v-for="n in 8" :key="'row' + n" height="3rem" />
</div>
</div>
<Skeleton v-for="n in 8" :key="'row' + n" height="3rem" />
</div>
</div>
<FullCalendar
ref="fcRef"
:options="calendarOptions"
/>
</div>
<FullCalendar ref="fcRef" :options="calendarOptions" />
</div>
</template>
<style scoped>
.agenda-calendar-wrap{
position: relative;
width: 100%;
.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;
.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) {
font-size: 0.95rem;
}
:deep(.fc .fc-timegrid-slot){
height: 2.2rem;
:deep(.fc .fc-timegrid-slot) {
height: 2.2rem;
}
:deep(.fc .fc-timegrid-now-indicator-line){
opacity: .75;
:deep(.fc .fc-timegrid-now-indicator-line) {
opacity: 0.75;
}
:deep(.fc .fc-scrollgrid){
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--surface-border);
: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);
:deep(.fc .fc-timegrid-slot-label-cushion) {
color: var(--text-color-secondary);
}
</style>
</style>
@@ -15,101 +15,108 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref } from 'vue'
import { computed, ref } from 'vue';
import FullCalendar from '@fullcalendar/vue3'
import resourceTimeGridPlugin from '@fullcalendar/resource-timegrid'
import interactionPlugin from '@fullcalendar/interaction'
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' },
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' },
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
resources: { type: Array, default: () => [] }, // [{ id, title }]
events: { type: Array, default: () => [] }, // event.resourceId = resource.id
loading: { type: Boolean, default: false }
})
loading: { type: Boolean, default: false }
});
const emit = defineEmits([
'rangeChange',
'eventClick',
'eventDrop',
'eventResize'
])
const emit = defineEmits(['rangeChange', 'eventClick', 'eventDrop', 'eventResize']);
const calendarRef = ref(null)
const calendarRef = ref(null);
const initialView = computed(() => (props.view === 'week' ? 'resourceTimeGridWeek' : 'resourceTimeGridDay'))
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 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,
plugins: [resourceTimeGridPlugin, interactionPlugin],
initialView: initialView.value,
timeZone: props.timezone,
headerToolbar: false,
headerToolbar: false,
nowIndicator: true,
editable: true,
nowIndicator: true,
editable: true,
slotDuration: props.slotDuration,
slotMinTime: computedSlotMinTime.value,
slotMaxTime: computedSlotMaxTime.value,
slotDuration: props.slotDuration,
slotMinTime: computedSlotMinTime.value,
slotMaxTime: computedSlotMaxTime.value,
resourceAreaWidth: '280px',
resourceAreaHeaderContent: 'Profissionais',
resources: props.resources,
resourceAreaWidth: '280px',
resourceAreaHeaderContent: 'Profissionais',
resources: props.resources,
events: props.events,
events: props.events,
datesSet(arg) {
emit('rangeChange', {
start: arg.start,
end: arg.end,
startStr: arg.startStr,
endStr: arg.endStr,
viewType: arg.view.type
})
},
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) },
eventClick(info) {
emit('eventClick', info);
},
eventDrop(info) {
emit('eventDrop', info);
},
eventResize(info) {
emit('eventResize', info);
},
height: 'auto',
expandRows: true,
allDaySlot: false
}))
height: 'auto',
expandRows: true,
allDaySlot: false
}));
function api () {
const fc = calendarRef.value
return fc?.getApi?.()
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') }
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 })
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="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 class="p-2 md:p-3">
<FullCalendar ref="calendarRef" :options="options" />
</div>
</div>
</div>
</template>
</template>
@@ -15,327 +15,328 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch, nextTick } from 'vue'
import { computed, ref, watch, nextTick } from 'vue';
import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid'
import dayGridPlugin from '@fullcalendar/daygrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction'
import ptBrLocale from '@fullcalendar/core/locales/pt-br'
import FullCalendar from '@fullcalendar/vue3';
import timeGridPlugin from '@fullcalendar/timegrid';
import dayGridPlugin from '@fullcalendar/daygrid';
import listPlugin from '@fullcalendar/list';
import interactionPlugin from '@fullcalendar/interaction';
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
const props = defineProps({
view: { type: String, default: 'day' }, // 'day' | 'week' | 'month'
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
timezone: { type: String, default: 'local' },
view: { type: String, default: 'day' }, // 'day' | 'week' | 'month'
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
timezone: { type: String, default: 'local' },
slotDuration: { type: String, default: '00:15:00' },
slotMinTime: { type: String, default: '06:00:00' },
slotMaxTime: { type: String, default: '22:00:00' },
slotDuration: { type: String, default: '00:15:00' },
slotMinTime: { type: String, default: '06:00:00' },
slotMaxTime: { type: String, default: '22:00:00' },
// [{ id, title }]
staff: { type: Array, default: () => [] },
// [{ id, title }]
staff: { type: Array, default: () => [] },
// todos os eventos (com extendedProps.owner_id)
events: { type: Array, default: () => [] },
// todos os eventos (com extendedProps.owner_id)
events: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
// largura mínima de cada coluna (terapeutas)
minColWidth: { type: Number, default: 360 },
// largura mínima de cada coluna (terapeutas)
minColWidth: { type: Number, default: 360 },
// ✅ coluna da clínica
showClinicColumn: { type: Boolean, default: true },
clinicId: { type: String, default: '' },
clinicTitle: { type: String, default: 'Clínica' },
clinicSubtitle: { type: String, default: 'Agenda da clínica' },
// ✅ coluna da clínica
showClinicColumn: { type: Boolean, default: true },
clinicId: { type: String, default: '' },
clinicTitle: { type: String, default: 'Clínica' },
clinicSubtitle: { type: String, default: 'Agenda da clínica' },
// subtitle terapeutas
staffSubtitle: { type: String, default: 'Visão diária operacional' },
// subtitle terapeutas
staffSubtitle: { type: String, default: 'Visão diária operacional' },
// jornada por dia: [{ daysOfWeek:[n], startTime:'HH:MM', endTime:'HH:MM' }]
businessHours: { type: Array, default: () => [] },
// jornada por dia: [{ daysOfWeek:[n], startTime:'HH:MM', endTime:'HH:MM' }]
businessHours: { type: Array, default: () => [] },
// Array de ISO strings (yyyy-mm-dd) de dias totalmente bloqueados.
// Exibe banner vermelho no topo de cada coluna (view "day") e fundo no calendário.
blockedDates: { type: Array, default: () => [] }
})
// Array de ISO strings (yyyy-mm-dd) de dias totalmente bloqueados.
// Exibe banner vermelho no topo de cada coluna (view "day") e fundo no calendário.
blockedDates: { type: Array, default: () => [] }
});
const emit = defineEmits([
'rangeChange',
'slotSelect',
'eventClick',
'eventDrop',
'eventResize',
'debugColumn'
])
const emit = defineEmits(['rangeChange', 'slotSelect', 'eventClick', 'eventDrop', 'eventResize', 'debugColumn']);
const calendarRefs = ref([])
function setCalendarRef (el, idx) {
if (!el) return
calendarRefs.value[idx] = el
const calendarRefs = ref([]);
function setCalendarRef(el, idx) {
if (!el) return;
calendarRefs.value[idx] = el;
}
const initialView = computed(() => {
if (props.view === 'week') return 'timeGridWeek'
if (props.view === 'month') return 'dayGridMonth'
return 'timeGridDay'
})
if (props.view === 'week') return 'timeGridWeek';
if (props.view === 'month') return 'dayGridMonth';
return 'timeGridDay';
});
const computedSlotMinTime = computed(() => (props.mode === 'full_24h' ? '00:00:00' : props.slotMinTime))
const computedSlotMaxTime = computed(() => (props.mode === 'full_24h' ? '23:59:59' : props.slotMaxTime))
const computedSlotMinTime = computed(() => (props.mode === 'full_24h' ? '00:00:00' : props.slotMinTime));
const computedSlotMaxTime = computed(() => (props.mode === 'full_24h' ? '23:59:59' : props.slotMaxTime));
// ✅ coluna fixa (clínica)
const clinicColumn = computed(() => {
if (!props.showClinicColumn) return null
const id = String(props.clinicId || '').trim()
if (!id) return null
return { id, title: props.clinicTitle || 'Clínica', __kind: 'clinic' }
})
if (!props.showClinicColumn) return null;
const id = String(props.clinicId || '').trim();
if (!id) return null;
return { id, title: props.clinicTitle || 'Clínica', __kind: 'clinic' };
});
const staffColumns = computed(() => {
const base = Array.isArray(props.staff) ? props.staff : []
return base
.filter(s => s?.id)
.map(s => ({ ...s, __kind: 'staff' }))
})
const base = Array.isArray(props.staff) ? props.staff : [];
return base.filter((s) => s?.id).map((s) => ({ ...s, __kind: 'staff' }));
});
function apiAt (idx) {
const fc = calendarRefs.value[idx]
return fc?.getApi?.()
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 forEachApi(fn) {
for (let i = 0; i < calendarRefs.value.length; i++) {
const api = apiAt(i);
if (api) fn(api, i);
}
}
function goToday () {
const d = new Date(); d.setHours(12, 0, 0, 0)
trackedDate = d
forEachApi(api => api.today())
function goToday() {
const d = new Date();
d.setHours(12, 0, 0, 0);
trackedDate = d;
forEachApi((api) => api.today());
}
function prev () { trackedDate = null; forEachApi(api => api.prev()) }
function next () { trackedDate = null; forEachApi(api => api.next()) }
function gotoDate (date) {
if (!date) return
const dt = (date instanceof Date) ? new Date(date) : new Date(date)
dt.setHours(12, 0, 0, 0)
trackedDate = new Date(dt)
forEachApi(api => api.gotoDate(dt))
function prev() {
trackedDate = null;
forEachApi((api) => api.prev());
}
function next() {
trackedDate = null;
forEachApi((api) => api.next());
}
function gotoDate(date) {
if (!date) return;
const dt = date instanceof Date ? new Date(date) : new Date(date);
dt.setHours(12, 0, 0, 0);
trackedDate = new Date(dt);
forEachApi((api) => api.gotoDate(dt));
}
function setView (v) {
const target = v === 'week' ? 'timeGridWeek' : v === 'month' ? 'dayGridMonth' : v === 'list' ? 'listWeek' : 'timeGridDay'
forEachApi(api => api.changeView(target))
function setView(v) {
const target = v === 'week' ? 'timeGridWeek' : v === 'month' ? 'dayGridMonth' : v === 'list' ? 'listWeek' : 'timeGridDay';
forEachApi((api) => api.changeView(target));
}
function setMode () {}
function setMode() {}
defineExpose({ goToday, prev, next, gotoDate, setView, setMode })
defineExpose({ goToday, prev, next, gotoDate, setView, setMode });
// ── Dias bloqueados ────────────────────────────────────────────
const blockedSet = computed(() => new Set(props.blockedDates || []))
const blockedSet = computed(() => new Set(props.blockedDates || []));
// Eventos de background que colorem o dia inteiro de vermelho suave no FullCalendar
const blockedBgEvents = computed(() =>
(props.blockedDates || []).map(iso => ({
id: `_blocked_bg_${iso}`,
start: iso,
allDay: true,
display: 'background',
color: 'rgba(239,68,68,0.13)',
classNames: ['fc-blocked-day']
}))
)
(props.blockedDates || []).map((iso) => ({
id: `_blocked_bg_${iso}`,
start: iso,
allDay: true,
display: 'background',
color: 'rgba(239,68,68,0.13)',
classNames: ['fc-blocked-day']
}))
);
// ISO do dia sendo exibido atualmente — atualizado pelo datesSet
const currentViewISO = ref('')
const currentViewISO = ref('');
// Retorna true se o dia exibido está bloqueado (banner só aparece na view "day")
function isCurrentDayBlocked () {
return props.view === 'day' && !!currentViewISO.value && blockedSet.value.has(currentViewISO.value)
function isCurrentDayBlocked() {
return props.view === 'day' && !!currentViewISO.value && blockedSet.value.has(currentViewISO.value);
}
function eventsFor (ownerId) {
const list = (props.events || []).filter(
e => String(e?.extendedProps?.owner_id || '') === String(ownerId || '')
)
return [...list, ...blockedBgEvents.value]
function eventsFor(ownerId) {
const list = (props.events || []).filter((e) => String(e?.extendedProps?.owner_id || '') === String(ownerId || ''));
return [...list, ...blockedBgEvents.value];
}
// ---- range sync ----
let lastRangeKey = ''
let suppressSync = false
let trackedDate = null // data-alvo atual (definida via gotoDate/goToday; limpa em prev/next)
let lastRangeKey = '';
let suppressSync = false;
let trackedDate = null; // data-alvo atual (definida via gotoDate/goToday; limpa em prev/next)
function sameDay (a, b) {
if (!a || !b) return false
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
function sameDay(a, b) {
if (!a || !b) return false;
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function onDatesSet (arg) {
const cd = arg.view?.currentStart || arg.start
if (cd) {
currentViewISO.value = `${cd.getFullYear()}-${String(cd.getMonth()+1).padStart(2,'0')}-${String(cd.getDate()).padStart(2,'0')}`
}
// Calendário recém-montado disparou datesSet com data antiga (ex: hoje) enquanto
// a agenda já estava em outra data (trackedDate). Navega silenciosamente para a
// data correta sem emitir rangeChange nem atualizar lastRangeKey.
if (trackedDate && cd && !sameDay(cd, trackedDate)) {
if (!suppressSync) {
suppressSync = true
forEachApi((api) => {
const cur = api.view?.currentStart
if (!cur || sameDay(cur, trackedDate)) return
api.gotoDate(trackedDate)
})
Promise.resolve().then(() => { suppressSync = false })
function onDatesSet(arg) {
const cd = arg.view?.currentStart || arg.start;
if (cd) {
currentViewISO.value = `${cd.getFullYear()}-${String(cd.getMonth() + 1).padStart(2, '0')}-${String(cd.getDate()).padStart(2, '0')}`;
}
return
}
const key = `${arg.startStr}__${arg.endStr}__${arg.view?.type || ''}`
if (key === lastRangeKey) return
lastRangeKey = key
// Calendário recém-montado disparou datesSet com data antiga (ex: hoje) enquanto
// a agenda já estava em outra data (trackedDate). Navega silenciosamente para a
// data correta sem emitir rangeChange nem atualizar lastRangeKey.
if (trackedDate && cd && !sameDay(cd, trackedDate)) {
if (!suppressSync) {
suppressSync = true;
forEachApi((api) => {
const cur = api.view?.currentStart;
if (!cur || sameDay(cur, trackedDate)) return;
api.gotoDate(trackedDate);
});
Promise.resolve().then(() => {
suppressSync = false;
});
}
return;
}
emit('rangeChange', {
start: arg.start,
end: arg.end,
startStr: arg.startStr,
endStr: arg.endStr,
viewType: arg.view.type,
currentDate: cd
})
const key = `${arg.startStr}__${arg.endStr}__${arg.view?.type || ''}`;
if (key === lastRangeKey) return;
lastRangeKey = key;
if (suppressSync) return
suppressSync = true
emit('rangeChange', {
start: arg.start,
end: arg.end,
startStr: arg.startStr,
endStr: arg.endStr,
viewType: arg.view.type,
currentDate: cd
});
const masterDate = cd
forEachApi((api) => {
const cur = api.view?.currentStart
if (!cur || !masterDate) return
if (cur.getTime() !== masterDate.getTime()) api.gotoDate(masterDate)
})
if (suppressSync) return;
suppressSync = true;
Promise.resolve().then(() => { suppressSync = false })
const masterDate = cd;
forEachApi((api) => {
const cur = api.view?.currentStart;
if (!cur || !masterDate) return;
if (cur.getTime() !== masterDate.getTime()) api.gotoDate(masterDate);
});
Promise.resolve().then(() => {
suppressSync = false;
});
}
watch(() => props.view, async () => {
await nextTick()
setView(props.view)
})
watch(
() => props.view,
async () => {
await nextTick();
setView(props.view);
}
);
// ✅ Fix: watch combinado — evita render intermediário que colapsava labels de hora cheia
watch([computedSlotMinTime, computedSlotMaxTime], async ([minT, maxT]) => {
await nextTick()
forEachApi(api => {
api.setOption?.('slotMinTime', minT)
api.setOption?.('slotMaxTime', maxT)
api.updateSize?.()
})
})
await nextTick();
forEachApi((api) => {
api.setOption?.('slotMinTime', minT);
api.setOption?.('slotMaxTime', maxT);
api.updateSize?.();
});
});
// ---------- helpers UI ----------
function colSubtitle (p) {
return p?.__kind === 'clinic' ? props.clinicSubtitle : props.staffSubtitle
function colSubtitle(p) {
return p?.__kind === 'clinic' ? props.clinicSubtitle : props.staffSubtitle;
}
// ✅ debug emitter (cabeçalho clicável)
function emitDebug (col) {
emit('debugColumn', {
staffCol: col,
staffUserId: col?.id || null,
staffTitle: col?.title || null,
kind: col?.__kind || null,
at: new Date().toISOString()
})
function emitDebug(col) {
emit('debugColumn', {
staffCol: col,
staffUserId: col?.id || null,
staffTitle: col?.title || null,
kind: col?.__kind || null,
at: new Date().toISOString()
});
}
function buildFcOptions (ownerId) {
const base = {
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
locale: ptBrLocale,
timeZone: props.timezone,
function buildFcOptions(ownerId) {
const base = {
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
locale: ptBrLocale,
timeZone: props.timezone,
headerToolbar: false,
initialView: initialView.value,
headerToolbar: false,
initialView: initialView.value,
nowIndicator: true,
editable: true,
selectable: true,
selectMirror: true,
nowIndicator: true,
editable: true,
selectable: true,
selectMirror: true,
slotDuration: props.slotDuration,
snapDuration: '00:15:00',
slotMinTime: computedSlotMinTime.value,
slotMaxTime: computedSlotMaxTime.value,
slotLabelInterval: '00:30',
slotLabelContent: (arg) => {
const min = arg.date.getMinutes()
if (min === 0) {
const h = String(arg.date.getHours()).padStart(2, '0')
return { html: `<span class="fc-slot-label-hour">${h}:00</span>` }
}
return { html: `<span class="fc-slot-label-half">:${String(min).padStart(2, '0')}</span>` }
},
slotDuration: props.slotDuration,
snapDuration: '00:15:00',
slotMinTime: computedSlotMinTime.value,
slotMaxTime: computedSlotMaxTime.value,
slotLabelInterval: '00:30',
slotLabelContent: (arg) => {
const min = arg.date.getMinutes();
if (min === 0) {
const h = String(arg.date.getHours()).padStart(2, '0');
return { html: `<span class="fc-slot-label-hour">${h}:00</span>` };
}
return { html: `<span class="fc-slot-label-half">:${String(min).padStart(2, '0')}</span>` };
},
businessHours: props.businessHours,
businessHours: props.businessHours,
views: {
timeGridDay: {
dayHeaderFormat: { day: 'numeric', month: 'long', year: 'numeric' },
},
},
views: {
timeGridDay: {
dayHeaderFormat: { day: 'numeric', month: 'long', year: 'numeric' }
}
},
height: 'auto',
expandRows: true,
allDaySlot: false,
height: 'auto',
expandRows: true,
allDaySlot: false,
events: eventsFor(ownerId),
datesSet: onDatesSet,
events: eventsFor(ownerId),
datesSet: onDatesSet,
eventClick: (info) => emit('eventClick', info),
eventDrop: (info) => emit('eventDrop', info),
eventResize: (info) => emit('eventResize', info),
eventClick: (info) => emit('eventClick', info),
eventDrop: (info) => emit('eventDrop', info),
eventResize: (info) => emit('eventResize', info),
eventContent: (arg) => {
const ext = arg.event.extendedProps || {}
const avatarUrl = ext.paciente_avatar || ''
const nome = ext.paciente_nome || ''
const obs = ext.observacoes || ''
const title = arg.event.title || ''
const timeText = arg.timeText || ''
const pacienteStatus = ext.paciente_status || ''
eventContent: (arg) => {
const ext = arg.event.extendedProps || {};
const avatarUrl = ext.paciente_avatar || '';
const nome = ext.paciente_nome || '';
const obs = ext.observacoes || '';
const title = arg.event.title || '';
const timeText = arg.timeText || '';
const pacienteStatus = ext.paciente_status || '';
const esc = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const esc = (s) =>
String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const initials = (n) => {
const p = String(n).trim().split(/\s+/).filter(Boolean)
if (!p.length) return '?'
if (p.length === 1) return p[0].slice(0, 2).toUpperCase()
return (p[0][0] + p[p.length - 1][0]).toUpperCase()
}
const initials = (n) => {
const p = String(n).trim().split(/\s+/).filter(Boolean);
if (!p.length) return '?';
if (p.length === 1) return p[0].slice(0, 2).toUpperCase();
return (p[0][0] + p[p.length - 1][0]).toUpperCase();
};
const avatarHtml = avatarUrl
? `<img src="${esc(avatarUrl)}" class="ev-avatar ev-avatar-img" />`
: nome
? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>`
: ''
const avatarHtml = avatarUrl ? `<img src="${esc(avatarUrl)}" class="ev-avatar ev-avatar-img" />` : nome ? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>` : '';
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
const statusBadge = (pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado')
? `<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.4;margin-top:2px;">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
: ''
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : '';
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : '';
const statusBadge =
pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado'
? `<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.4;margin-top:2px;">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
: '';
return {
html: `<div class="ev-custom">
return {
html: `<div class="ev-custom">
${avatarHtml}
<div class="ev-body">
${timeHtml}
@@ -344,229 +345,242 @@ function buildFcOptions (ownerId) {
${obsHtml}
</div>
</div>`
}
},
};
},
eventClassNames: (arg) => {
const classes = []
if (arg?.event?.backgroundColor) classes.push('evt-has-color')
return classes
},
eventClassNames: (arg) => {
const classes = [];
if (arg?.event?.backgroundColor) classes.push('evt-has-color');
return classes;
},
eventDidMount: (info) => {
const bgColor = info.event.extendedProps?.commitment_bg_color
if (bgColor) {
info.el.style.setProperty('background-color', bgColor, 'important')
info.el.style.setProperty('border-color', bgColor, 'important')
}
}
}
eventDidMount: (info) => {
const bgColor = info.event.extendedProps?.commitment_bg_color;
if (bgColor) {
info.el.style.setProperty('background-color', bgColor, 'important');
info.el.style.setProperty('border-color', bgColor, 'important');
}
}
};
base.select = (selection) => {
emit('slotSelect', {
ownerId,
start: selection.start,
end: selection.end,
startStr: selection.startStr,
endStr: selection.endStr,
jsEvent: selection.jsEvent || null,
viewType: selection.view?.type || initialView.value
})
}
base.select = (selection) => {
emit('slotSelect', {
ownerId,
start: selection.start,
end: selection.end,
startStr: selection.startStr,
endStr: selection.endStr,
jsEvent: selection.jsEvent || null,
viewType: selection.view?.type || initialView.value
});
};
return base
return base;
}
</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="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="mosaic-shell">
<!-- Coluna fixa: Clínica -->
<div v-if="clinicColumn" class="mosaic-fixed">
<div class="mosaic-col">
<div class="mosaic-col-head cursor-pointer" @click="emitDebug(clinicColumn)" title="Debug desta coluna">
<div class="min-w-0">
<div class="font-semibold truncate">{{ clinicColumn.title }}</div>
<div class="text-xs opacity-70 truncate">{{ colSubtitle(clinicColumn) }}</div>
<div class="mosaic-shell">
<!-- Coluna fixa: Clínica -->
<div v-if="clinicColumn" class="mosaic-fixed">
<div class="mosaic-col">
<div class="mosaic-col-head cursor-pointer" @click="emitDebug(clinicColumn)" title="Debug desta coluna">
<div class="min-w-0">
<div class="font-semibold truncate">{{ clinicColumn.title }}</div>
<div class="text-xs opacity-70 truncate">{{ colSubtitle(clinicColumn) }}</div>
</div>
<div class="text-xs opacity-70 whitespace-nowrap">
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
</div>
</div>
<!-- Banner: dia bloqueado aparece apenas na view "day" -->
<div v-if="isCurrentDayBlocked()" class="mosaic-blocked-banner">
<i class="pi pi-lock text-xs" />
<span>Dia bloqueado apenas compromissos de sessão não são permitidos</span>
</div>
<div class="p-2">
<FullCalendar :ref="(el) => setCalendarRef(el, 0)" :options="buildFcOptions(clinicColumn.id)" />
</div>
</div>
</div>
<div class="text-xs opacity-70 whitespace-nowrap">
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
<!-- Área rolável: Terapeutas -->
<div class="mosaic-scroll">
<div class="mosaic-grid" :style="{ gridAutoColumns: `minmax(${minColWidth}px, 1fr)` }">
<div v-for="(p, sIdx) in staffColumns" :key="p.id" class="mosaic-col">
<div class="mosaic-col-head cursor-pointer" @click="emitDebug(p)" title="Debug desta coluna">
<div class="min-w-0">
<div class="font-semibold truncate">{{ p.title }}</div>
<div class="text-xs opacity-70 truncate">{{ colSubtitle(p) }}</div>
</div>
<div class="text-xs opacity-70 whitespace-nowrap">
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
</div>
</div>
<!-- Banner: dia bloqueado aparece apenas na view "day" -->
<div v-if="isCurrentDayBlocked()" class="mosaic-blocked-banner">
<i class="pi pi-lock text-xs" />
<span>Dia bloqueado apenas compromissos de sessão não são permitidos</span>
</div>
<div class="p-2">
<FullCalendar :ref="(el) => setCalendarRef(el, clinicColumn ? sIdx + 1 : sIdx)" :options="buildFcOptions(p.id)" />
</div>
</div>
</div>
</div>
</div>
<!-- Banner: dia bloqueado aparece apenas na view "day" -->
<div v-if="isCurrentDayBlocked()" class="mosaic-blocked-banner">
<i class="pi pi-lock text-xs" />
<span>Dia bloqueado apenas compromissos de sessão não são permitidos</span>
</div>
<div class="p-2">
<FullCalendar
:ref="(el) => setCalendarRef(el, 0)"
:options="buildFcOptions(clinicColumn.id)"
/>
</div>
</div>
</div>
<!-- Área rolável: Terapeutas -->
<div class="mosaic-scroll">
<div
class="mosaic-grid"
:style="{ gridAutoColumns: `minmax(${minColWidth}px, 1fr)` }"
>
<div
v-for="(p, sIdx) in staffColumns"
:key="p.id"
class="mosaic-col"
>
<div class="mosaic-col-head cursor-pointer" @click="emitDebug(p)" title="Debug desta coluna">
<div class="min-w-0">
<div class="font-semibold truncate">{{ p.title }}</div>
<div class="text-xs opacity-70 truncate">{{ colSubtitle(p) }}</div>
</div>
<div class="text-xs opacity-70 whitespace-nowrap">
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
</div>
</div>
<!-- Banner: dia bloqueado aparece apenas na view "day" -->
<div v-if="isCurrentDayBlocked()" class="mosaic-blocked-banner">
<i class="pi pi-lock text-xs" />
<span>Dia bloqueado apenas compromissos de sessão não são permitidos</span>
</div>
<div class="p-2">
<FullCalendar
:ref="(el) => setCalendarRef(el, (clinicColumn ? (sIdx + 1) : sIdx))"
:options="buildFcOptions(p.id)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
/* Evento customizado — unscoped pois é HTML injetado pelo FullCalendar */
.ev-custom {
display: flex;
align-items: flex-start;
gap: 5px;
overflow: hidden;
padding: 1px 2px;
height: 100%;
width: 100%;
display: flex;
align-items: flex-start;
gap: 5px;
overflow: hidden;
padding: 1px 2px;
height: 100%;
width: 100%;
}
.ev-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 1px;
width: 22px;
height: 22px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 1px;
}
.ev-avatar-img {
object-fit: cover;
}
.ev-avatar-img { object-fit: cover; }
.ev-avatar-initials {
background: rgba(255,255,255,0.25);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
letter-spacing: .5px;
background: rgba(255, 255, 255, 0.25);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.5px;
}
.ev-body {
min-width: 0;
flex: 1;
overflow: hidden;
}
.ev-time {
font-size: 10px;
opacity: 0.8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ev-title {
font-size: 11px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.ev-obs {
font-size: 10px;
opacity: 0.75;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
.ev-body { min-width: 0; flex: 1; overflow: hidden; }
.ev-time { font-size: 10px; opacity: 0.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ev-title { font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.3; }
.ev-obs { font-size: 10px; opacity: 0.75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
</style>
<style scoped>
.mosaic-shell {
display: flex;
gap: 12px;
padding: 8px;
display: flex;
gap: 12px;
padding: 8px;
}
@media (min-width: 768px) {
.mosaic-shell { padding: 12px; }
.mosaic-shell {
padding: 12px;
}
}
.mosaic-fixed {
flex: 0 0 auto;
width: 420px;
min-width: 320px;
max-width: 460px;
flex: 0 0 auto;
width: 420px;
min-width: 320px;
max-width: 460px;
}
.mosaic-scroll {
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
}
.mosaic-grid {
display: grid;
grid-auto-flow: column;
gap: 12px;
display: grid;
grid-auto-flow: column;
gap: 12px;
}
.mosaic-col {
border-radius: 1.25rem;
border: 1px solid var(--surface-border);
background: color-mix(in srgb, var(--surface-card), transparent 12%);
overflow: hidden;
border-radius: 1.25rem;
border: 1px solid var(--surface-border);
background: color-mix(in srgb, var(--surface-card), transparent 12%);
overflow: hidden;
}
.mosaic-col-head {
padding: 12px;
border-bottom: 1px solid var(--surface-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--surface-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* Banner vermelho de dia bloqueado no topo de cada coluna */
.mosaic-blocked-banner {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 0.75rem;
font-weight: 600;
color: var(--red-700, #b91c1c);
background: color-mix(in srgb, var(--red-400, #f87171) 15%, var(--surface-card));
border-bottom: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 0.75rem;
font-weight: 600;
color: var(--red-700, #b91c1c);
background: color-mix(in srgb, var(--red-400, #f87171) 15%, var(--surface-card));
border-bottom: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
}
</style>
<style>
.fc-slot-label-hour {
display: inline-block;
font-size: 0.8rem;
font-weight: 700;
color: var(--text-color);
letter-spacing: -0.01em;
line-height: 1;
display: inline-block;
font-size: 0.8rem;
font-weight: 700;
color: var(--text-color);
letter-spacing: -0.01em;
line-height: 1;
}
.fc-slot-label-half {
display: inline-block;
font-size: 0.68rem;
font-weight: 400;
color: var(--text-color-secondary);
opacity: 0.5;
line-height: 1;
padding-left: 2px;
display: inline-block;
font-size: 0.68rem;
font-weight: 400;
color: var(--text-color-secondary);
opacity: 0.5;
line-height: 1;
padding-left: 2px;
}
/* Garante opacidade total nos dias bloqueados (background event do FullCalendar) */
.fc-blocked-day {
opacity: 1 !important;
opacity: 1 !important;
}
</style>
</style>
File diff suppressed because it is too large Load Diff
@@ -15,15 +15,14 @@
|--------------------------------------------------------------------------
-->
<script setup>
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 }
})
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'])
const emit = defineEmits(['refresh', 'collapse']);
/**
* Este componente é propositalmente "burro".
@@ -33,85 +32,71 @@ const emit = defineEmits(['refresh', 'collapse'])
</script>
<template>
<div
class="agenda-right-panel"
: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 class="agenda-right-panel" :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>
<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" />
</div>
<template #content>
<div class="content-wrap">
<!-- TOP slot (ex.: próximas sessões) -->
<div class="slot-top">
<slot name="top" />
</div>
<Divider class="my-3" />
<Divider class="my-3" />
<!-- BOTTOM slot (ex.: pulse / atalhos) -->
<div class="slot-bottom">
<slot name="bottom" />
</div>
</div>
</template>
</Card>
</div>
<!-- BOTTOM slot (ex.: pulse / atalhos) -->
<div class="slot-bottom">
<slot name="bottom" />
</div>
</div>
</template>
</Card>
</div>
</template>
<style scoped>
.agenda-right-panel{
width: 100%;
.agenda-right-panel {
width: 100%;
}
.agenda-right-panel.is-sticky{
position: sticky;
top: 1rem;
align-self: start;
.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;
.content-wrap {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: calc(100vh - 220px);
overflow: auto;
padding-right: 0.25rem;
}
/* Melhor sensação de "sessões" */
.slot-top, .slot-bottom{
display: flex;
flex-direction: column;
gap: .75rem;
.slot-top,
.slot-bottom {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>
</style>
@@ -15,111 +15,86 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue';
import ToggleButton from 'primevue/togglebutton'
import ToggleButton from 'primevue/togglebutton';
const props = defineProps({
title: { type: String, default: 'Agenda' },
title: { type: String, default: 'Agenda' },
// 'day' | 'week'
view: { type: String, default: 'day' },
// 'day' | 'week'
view: { type: String, default: 'day' },
// 'full_24h' | 'work_hours'
mode: { type: String, default: 'work_hours' },
// 'full_24h' | 'work_hours'
mode: { type: String, default: 'work_hours' },
showSearch: { type: Boolean, default: true },
searchPlaceholder: { type: String, default: ' ' },
showSearch: { type: Boolean, default: true },
searchPlaceholder: { type: String, default: ' ' },
// controla se exibe botões de ação
showActions: { type: Boolean, default: true }
})
// 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 emit = defineEmits(['today', 'prev', 'next', 'changeView', 'toggleMode', 'createSession', 'createBlock', 'search']);
const viewOptions = [
{ label: 'Dia', value: 'day' },
{ label: 'Semana', value: 'week' }
]
{ label: 'Dia', value: 'day' },
{ label: 'Semana', value: 'week' }
];
const search = ref('')
const search = ref('');
watch(search, (v) => emit('search', v))
watch(search, (v) => emit('search', v));
const modeLabel = computed(() => (props.mode === 'full_24h' ? '24h' : 'Horário'))
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 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 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>
</template>
File diff suppressed because it is too large Load Diff
@@ -14,462 +14,372 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
class="dc-dialog w-[96vw] max-w-2xl"
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' }, footer: { class: 'pt-0' } }"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<!-- Dot de cor -->
<span
class="shrink-0 w-3.5 h-3.5 rounded-full border-2 border-white/30 shadow-[0_0_0_3px_rgba(0,0,0,0.08)] transition-colors duration-200"
:style="{ backgroundColor: previewBgColor }"
/>
<div class="min-w-0">
<div class="text-base font-semibold truncate">
{{ form.name || (mode === 'create' ? 'Novo compromisso' : 'Editar compromisso') }}
</div>
<div class="text-xs opacity-50">
{{ mode === 'create' ? 'Novo tipo de compromisso' : 'Editando tipo de compromisso' }}
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
v-if="mode === 'edit' && canDelete"
icon="pi pi-trash"
severity="danger"
text
rounded
:disabled="saving"
v-tooltip.top="'Excluir'"
@click="emitDelete"
/>
</div>
</div>
</template>
<!-- Banner de preview -->
<div
class="h-[72px] flex items-center justify-center transition-colors duration-[250ms] rounded-[6px]"
:style="{ backgroundColor: previewBgColor }"
>
<span
class="text-base font-bold tracking-[-0.02em] px-[1.1rem] py-[0.35rem] bg-black/15 rounded-full backdrop-blur-sm transition-colors duration-200"
:style="{ color: form.text_color || '#ffffff' }"
>
{{ form.name || 'Nome do compromisso' }}
</span>
</div>
<!-- Corpo -->
<div class="flex flex-col gap-4 mt-4">
<!-- Nome + Ativo -->
<div class="flex items-center gap-3">
<div class="flex-1">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText
id="cr-nome"
v-model="form.name"
class="w-full"
variant="filled"
:disabled="saving || isEditLocked"
@keydown.enter.prevent="submit"
/>
</IconField>
<label for="cr-nome">Nome *</label>
</FloatLabel>
</div>
<!-- Toggle Ativo -->
<div class="shrink-0 flex items-center gap-2">
<span class="text-sm font-medium">Ativo</span>
<ToggleSwitch v-model="form.active" :disabled="saving || isActiveLocked" />
</div>
</div>
<!-- Descrição -->
<FloatLabel variant="on">
<Textarea
id="cr-descricao"
v-model="form.description"
autoResize
rows="2"
class="w-full"
variant="filled"
:disabled="saving || isEditLocked"
/>
<label for="cr-descricao">Descrição</label>
</FloatLabel>
<!-- Seção Cor -->
<div class="border border-[var(--surface-border)] rounded-[6px] bg-[var(--surface-card)] p-4">
<div class="text-[1rem] font-bold uppercase tracking-[0.06em] opacity-45 mb-3">Cor</div>
<!-- Paleta predefinida -->
<div class="flex flex-wrap gap-[0.45rem]">
<button
v-for="p in presetColors"
:key="p.bg"
class="w-7 h-7 rounded-full grid place-items-center cursor-pointer relative transition-transform duration-[120ms] ease-in-out hover:scale-[1.18] hover:shadow-[0_3px_10px_rgba(0,0,0,0.2)] disabled:cursor-not-allowed"
:class="form.bg_color === p.bg ? 'shadow-[0_0_0_2px_var(--text-color)] border-2 border-[var(--surface-0,#fff)]' : 'border-0'"
:style="{ backgroundColor: `#${p.bg}` }"
:title="p.name"
:disabled="saving || isEditLocked"
@click="applyPreset(p)"
>
<i v-if="form.bg_color === p.bg" class="pi pi-check !text-[13px] text-white font-black p-1" />
</button>
<!-- Custom ColorPicker -->
<div
class="w-7 h-7 rounded-full grid place-items-center cursor-pointer overflow-hidden relative transition-transform duration-[120ms] ease-in-out hover:scale-[1.18] hover:shadow-[0_3px_10px_rgba(0,0,0,0.2)]"
:class="isCustomColor ? 'shadow-[0_0_0_2px_var(--text-color)]' : ''"
style="background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);"
title="Cor personalizada"
>
<i
v-if="isCustomColor"
class="pi pi-check !text-[13px] text-white font-black absolute z-10 pointer-events-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]"
/>
<ColorPicker
v-model="form.bg_color"
format="hex"
:disabled="saving || isEditLocked"
class="absolute inset-0 [&_.p-colorpicker-preview]:w-full [&_.p-colorpicker-preview]:h-full [&_.p-colorpicker-preview]:border-0 [&_.p-colorpicker-preview]:rounded-full [&_.p-colorpicker-preview]:opacity-0"
/>
</div>
</div>
<!-- Texto -->
<div class="flex items-center gap-3 mt-2">
<span class="text-xs font-medium opacity-60 uppercase tracking-wide">Texto</span>
<div class="flex gap-1">
<button
class="inline-flex items-center gap-[0.4rem] px-3 py-1 rounded-full border text-sm font-medium cursor-pointer transition-colors duration-[120ms] disabled:cursor-not-allowed"
:class="
form.text_color === '#ffffff'
? 'bg-[var(--surface-section,var(--surface-100))] border-[var(--primary-color)] text-[var(--primary-color)] font-bold'
: 'bg-transparent border-[var(--surface-border)] text-[var(--text-color)] hover:bg-[var(--surface-hover)]'
"
:disabled="saving || isEditLocked"
@click="form.text_color = '#ffffff'"
>
<span class="w-2.5 h-2.5 rounded-full inline-block border border-[#ccc]" style="background:#ffffff;" />
Branco
</button>
<button
class="inline-flex items-center gap-[0.4rem] px-3 py-1 rounded-full border text-sm font-medium cursor-pointer transition-colors duration-[120ms] disabled:cursor-not-allowed"
:class="
form.text_color === '#000000'
? 'bg-[var(--surface-section,var(--surface-100))] border-[var(--primary-color)] text-[var(--primary-color)] font-bold'
: 'bg-transparent border-[var(--surface-border)] text-[var(--text-color)] hover:bg-[var(--surface-hover)]'
"
:disabled="saving || isEditLocked"
@click="form.text_color = '#000000'"
>
<span class="w-2.5 h-2.5 rounded-full inline-block" style="background:#000000;" />
Preto
</button>
</div>
</div>
</div>
<!-- Campos adicionais -->
<div class="border border-[var(--surface-border)] rounded-[6px] bg-[var(--surface-card)] p-4">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="text-[1rem] font-bold uppercase tracking-[0.06em] opacity-45">Campos adicionais</div>
<Button
label="Adicionar campo"
icon="pi pi-plus"
severity="secondary"
outlined
size="small"
class="rounded-full"
:disabled="saving || isFieldsLocked"
@click="addField"
/>
</div>
<div v-if="!form.fields.length" class="py-3 text-center text-sm opacity-50">
Nenhum campo adicional configurado.
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="(f, idx) in form.fields"
:key="f.key"
class="grid grid-cols-1 gap-2 rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-0)] p-3 md:grid-cols-12"
>
<div class="md:col-span-6">
<FloatLabel variant="on">
<InputText
:id="`cr-field-label-${idx}`"
v-model="f.label"
class="w-full"
variant="filled"
:disabled="saving || isFieldsLocked"
@keydown.enter.prevent="submit"
@blur="syncKey(f)"
/>
<label :for="`cr-field-label-${idx}`">Nome do campo</label>
</FloatLabel>
</div>
<div class="md:col-span-4">
<FloatLabel variant="on">
<Dropdown
:id="`cr-field-type-${idx}`"
v-model="f.type"
:options="fieldTypeOptions"
optionLabel="label"
optionValue="value"
class="w-full"
variant="filled"
:disabled="saving || isFieldsLocked"
/>
<label :for="`cr-field-type-${idx}`">Tipo</label>
</FloatLabel>
</div>
<div class="md:col-span-2 flex items-center justify-end">
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
:disabled="saving || isFieldsLocked"
@click="removeField(idx)"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Footer com botões Cancelar / Salvar -->
<template #footer>
<div class="flex items-center justify-end gap-2 pt-2">
<Button
label="Cancelar"
severity="secondary"
outlined
class="rounded-full"
:disabled="saving"
@click="close"
/>
<Button
label="Salvar"
icon="pi pi-check"
class="rounded-full"
:loading="saving"
:disabled="!canSubmit"
@click="submit"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { computed, reactive, watch } from 'vue'
import Textarea from 'primevue/textarea'
import Dropdown from 'primevue/dropdown'
import ColorPicker from 'primevue/colorpicker'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, reactive, watch } from 'vue';
import Textarea from 'primevue/textarea';
import Dropdown from 'primevue/dropdown';
import ColorPicker from 'primevue/colorpicker';
import ToggleSwitch from 'primevue/toggleswitch';
const props = defineProps({
modelValue: { type: Boolean, default: false },
mode: { type: String, default: 'create' }, // 'create' | 'edit'
saving: { type: Boolean, default: false },
commitment: { type: Object, default: null } // quando edit
})
modelValue: { type: Boolean, default: false },
mode: { type: String, default: 'create' }, // 'create' | 'edit'
saving: { type: Boolean, default: false },
commitment: { type: Object, default: null } // quando edit
});
const emit = defineEmits(['update:modelValue', 'save', 'delete'])
const emit = defineEmits(['update:modelValue', 'save', 'delete']);
const fieldTypeOptions = [
{ label: 'Texto', value: 'text' },
{ label: 'Texto longo', value: 'textarea' }
]
{ label: 'Texto', value: 'text' },
{ label: 'Texto longo', value: 'textarea' }
];
const presetColors = [
{ bg: '6366f1', text: '#ffffff', name: 'Índigo' },
{ bg: '8b5cf6', text: '#ffffff', name: 'Violeta' },
{ bg: 'ec4899', text: '#ffffff', name: 'Rosa' },
{ bg: 'ef4444', text: '#ffffff', name: 'Vermelho' },
{ bg: 'f97316', text: '#ffffff', name: 'Laranja' },
{ bg: 'eab308', text: '#000000', name: 'Amarelo' },
{ bg: '22c55e', text: '#ffffff', name: 'Verde' },
{ bg: '14b8a6', text: '#ffffff', name: 'Teal' },
{ bg: '3b82f6', text: '#ffffff', name: 'Azul' },
{ bg: '06b6d4', text: '#ffffff', name: 'Ciano' },
{ bg: '64748b', text: '#ffffff', name: 'Ardósia' },
{ bg: '292524', text: '#ffffff', name: 'Escuro' },
]
{ bg: '6366f1', text: '#ffffff', name: 'Índigo' },
{ bg: '8b5cf6', text: '#ffffff', name: 'Violeta' },
{ bg: 'ec4899', text: '#ffffff', name: 'Rosa' },
{ bg: 'ef4444', text: '#ffffff', name: 'Vermelho' },
{ bg: 'f97316', text: '#ffffff', name: 'Laranja' },
{ bg: 'eab308', text: '#000000', name: 'Amarelo' },
{ bg: '22c55e', text: '#ffffff', name: 'Verde' },
{ bg: '14b8a6', text: '#ffffff', name: 'Teal' },
{ bg: '3b82f6', text: '#ffffff', name: 'Azul' },
{ bg: '06b6d4', text: '#ffffff', name: 'Ciano' },
{ bg: '64748b', text: '#ffffff', name: 'Ardósia' },
{ bg: '292524', text: '#ffffff', name: 'Escuro' }
];
// bg_colors dos presets (sem #) para comparação
const presetBgValues = presetColors.map(p => p.bg)
const presetBgValues = presetColors.map((p) => p.bg);
// Verdadeiro quando a cor atual não bate com nenhum preset
const isCustomColor = computed(() => {
if (!form.bg_color) return false
const clean = String(form.bg_color).replace('#', '').toLowerCase()
return !presetBgValues.includes(clean)
})
if (!form.bg_color) return false;
const clean = String(form.bg_color).replace('#', '').toLowerCase();
return !presetBgValues.includes(clean);
});
function applyPreset (p) {
if (props.saving) return
form.bg_color = p.bg
form.text_color = p.text
function applyPreset(p) {
if (props.saving) return;
form.bg_color = p.bg;
form.text_color = p.text;
}
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
});
const form = reactive({
id: null,
name: '',
description: '',
native: false,
locked: false,
active: true,
bg_color: '6366f1',
text_color: '#ffffff',
fields: []
})
id: null,
name: '',
description: '',
native: false,
locked: false,
active: true,
bg_color: '6366f1',
text_color: '#ffffff',
fields: []
});
const previewBgColor = computed(() => {
if (!form.bg_color) return '#6366f1'
return form.bg_color.startsWith('#') ? form.bg_color : `#${form.bg_color}`
})
if (!form.bg_color) return '#6366f1';
return form.bg_color.startsWith('#') ? form.bg_color : `#${form.bg_color}`;
});
watch(
() => props.modelValue,
(open) => {
if (!open) return
hydrate()
}
)
() => props.modelValue,
(open) => {
if (!open) return;
hydrate();
}
);
watch(
() => props.commitment,
() => {
if (!props.modelValue) return
hydrate()
}
)
() => props.commitment,
() => {
if (!props.modelValue) return;
hydrate();
}
);
function hydrate () {
const c = props.commitment
if (props.mode === 'edit' && c) {
form.id = c.id
form.name = c.name || ''
form.description = c.description || ''
form.native = !!c.native
form.locked = !!c.locked
form.active = !!c.active
form.bg_color = c.bg_color || '6366f1'
form.text_color = c.text_color || '#ffffff'
form.fields = Array.isArray(c.fields) ? JSON.parse(JSON.stringify(c.fields)) : []
} else {
form.id = null
form.name = ''
form.description = ''
form.native = false
form.locked = false
form.active = true
form.bg_color = '6366f1'
form.text_color = '#ffffff'
form.fields = []
}
function hydrate() {
const c = props.commitment;
if (props.mode === 'edit' && c) {
form.id = c.id;
form.name = c.name || '';
form.description = c.description || '';
form.native = !!c.native;
form.locked = !!c.locked;
form.active = !!c.active;
form.bg_color = c.bg_color || '6366f1';
form.text_color = c.text_color || '#ffffff';
form.fields = Array.isArray(c.fields) ? JSON.parse(JSON.stringify(c.fields)) : [];
} else {
form.id = null;
form.name = '';
form.description = '';
form.native = false;
form.locked = false;
form.active = true;
form.bg_color = '6366f1';
form.text_color = '#ffffff';
form.fields = [];
}
}
const isActiveLocked = computed(() => !!form.locked)
const isEditLocked = computed(() => false)
const isFieldsLocked = computed(() => false)
const canDelete = computed(() => !form.native)
const isActiveLocked = computed(() => !!form.locked);
const isEditLocked = computed(() => false);
const isFieldsLocked = computed(() => false);
const canDelete = computed(() => !form.native);
const canSubmit = computed(() => {
if (props.saving) return false
if (!String(form.name || '').trim()) return false
return true
})
if (props.saving) return false;
if (!String(form.name || '').trim()) return false;
return true;
});
function close () {
if (props.saving) return
visible.value = false
function close() {
if (props.saving) return;
visible.value = false;
}
function submit () {
if (!canSubmit.value) return
function submit() {
if (!canSubmit.value) return;
const payload = {
id: form.id,
name: String(form.name || '').trim(),
description: String(form.description || '').trim(),
active: form.locked ? true : !!form.active,
bg_color: form.bg_color || null,
text_color: form.text_color || null,
fields: (form.fields || []).map(f => ({
key: f.key,
label: String(f.label || '').trim() || 'Campo',
type: f.type === 'textarea' ? 'textarea' : 'text',
required: !!f.required
}))
}
const payload = {
id: form.id,
name: String(form.name || '').trim(),
description: String(form.description || '').trim(),
active: form.locked ? true : !!form.active,
bg_color: form.bg_color || null,
text_color: form.text_color || null,
fields: (form.fields || []).map((f) => ({
key: f.key,
label: String(f.label || '').trim() || 'Campo',
type: f.type === 'textarea' ? 'textarea' : 'text',
required: !!f.required
}))
};
emit('save', payload)
emit('save', payload);
}
function emitDelete () {
if (props.saving) return
emit('delete', { id: form.id })
visible.value = false
function emitDelete() {
if (props.saving) return;
emit('delete', { id: form.id });
visible.value = false;
}
function addField () {
const base = `campo-${form.fields.length + 1}`
form.fields.push({
key: makeKey(base),
label: 'Observação',
type: 'textarea',
required: false
})
function addField() {
const base = `campo-${form.fields.length + 1}`;
form.fields.push({
key: makeKey(base),
label: 'Observação',
type: 'textarea',
required: false
});
}
function removeField (idx) {
form.fields.splice(idx, 1)
function removeField(idx) {
form.fields.splice(idx, 1);
}
function syncKey (field) {
const next = makeKey(field.label)
field.key = next
function syncKey(field) {
const next = makeKey(field.label);
field.key = next;
}
function makeKey (label) {
const k = String(label || '')
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/(^_|_$)/g, '') || `field_${Math.random().toString(16).slice(2, 8)}`
return k
function makeKey(label) {
const k =
String(label || '')
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/(^_|_$)/g, '') || `field_${Math.random().toString(16).slice(2, 8)}`;
return k;
}
</script>
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
class="dc-dialog w-[96vw] max-w-2xl"
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' }, footer: { class: 'pt-0' } }"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<!-- Dot de cor -->
<span class="shrink-0 w-3.5 h-3.5 rounded-full border-2 border-white/30 shadow-[0_0_0_3px_rgba(0,0,0,0.08)] transition-colors duration-200" :style="{ backgroundColor: previewBgColor }" />
<div class="min-w-0">
<div class="text-base font-semibold truncate">
{{ form.name || (mode === 'create' ? 'Novo compromisso' : 'Editar compromisso') }}
</div>
<div class="text-xs opacity-50">
{{ mode === 'create' ? 'Novo tipo de compromisso' : 'Editando tipo de compromisso' }}
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button v-if="mode === 'edit' && canDelete" icon="pi pi-trash" severity="danger" text rounded :disabled="saving" v-tooltip.top="'Excluir'" @click="emitDelete" />
</div>
</div>
</template>
<!-- Banner de preview -->
<div class="h-[72px] flex items-center justify-center transition-colors duration-[250ms] rounded-[6px]" :style="{ backgroundColor: previewBgColor }">
<span class="text-base font-bold tracking-[-0.02em] px-[1.1rem] py-[0.35rem] bg-black/15 rounded-full backdrop-blur-sm transition-colors duration-200" :style="{ color: form.text_color || '#ffffff' }">
{{ form.name || 'Nome do compromisso' }}
</span>
</div>
<!-- Corpo -->
<div class="flex flex-col gap-4 mt-4">
<!-- Nome + Ativo -->
<div class="flex items-center gap-3">
<div class="flex-1">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText id="cr-nome" v-model="form.name" class="w-full" variant="filled" :disabled="saving || isEditLocked" @keydown.enter.prevent="submit" />
</IconField>
<label for="cr-nome">Nome *</label>
</FloatLabel>
</div>
<!-- Toggle Ativo -->
<div class="shrink-0 flex items-center gap-2">
<span class="text-sm font-medium">Ativo</span>
<ToggleSwitch v-model="form.active" :disabled="saving || isActiveLocked" />
</div>
</div>
<!-- Descrição -->
<FloatLabel variant="on">
<Textarea id="cr-descricao" v-model="form.description" autoResize rows="2" class="w-full" variant="filled" :disabled="saving || isEditLocked" />
<label for="cr-descricao">Descrição</label>
</FloatLabel>
<!-- Seção Cor -->
<div class="border border-[var(--surface-border)] rounded-[6px] bg-[var(--surface-card)] p-4">
<div class="text-[1rem] font-bold uppercase tracking-[0.06em] opacity-45 mb-3">Cor</div>
<!-- Paleta predefinida -->
<div class="flex flex-wrap gap-[0.45rem]">
<button
v-for="p in presetColors"
:key="p.bg"
class="w-7 h-7 rounded-full grid place-items-center cursor-pointer relative transition-transform duration-[120ms] ease-in-out hover:scale-[1.18] hover:shadow-[0_3px_10px_rgba(0,0,0,0.2)] disabled:cursor-not-allowed"
:class="form.bg_color === p.bg ? 'shadow-[0_0_0_2px_var(--text-color)] border-2 border-[var(--surface-0,#fff)]' : 'border-0'"
:style="{ backgroundColor: `#${p.bg}` }"
:title="p.name"
:disabled="saving || isEditLocked"
@click="applyPreset(p)"
>
<i v-if="form.bg_color === p.bg" class="pi pi-check !text-[13px] text-white font-black p-1" />
</button>
<!-- Custom ColorPicker -->
<div
class="w-7 h-7 rounded-full grid place-items-center cursor-pointer overflow-hidden relative transition-transform duration-[120ms] ease-in-out hover:scale-[1.18] hover:shadow-[0_3px_10px_rgba(0,0,0,0.2)]"
:class="isCustomColor ? 'shadow-[0_0_0_2px_var(--text-color)]' : ''"
style="background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red)"
title="Cor personalizada"
>
<i v-if="isCustomColor" class="pi pi-check !text-[13px] text-white font-black absolute z-10 pointer-events-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]" />
<ColorPicker
v-model="form.bg_color"
format="hex"
:disabled="saving || isEditLocked"
class="absolute inset-0 [&_.p-colorpicker-preview]:w-full [&_.p-colorpicker-preview]:h-full [&_.p-colorpicker-preview]:border-0 [&_.p-colorpicker-preview]:rounded-full [&_.p-colorpicker-preview]:opacity-0"
/>
</div>
</div>
<!-- Texto -->
<div class="flex items-center gap-3 mt-2">
<span class="text-xs font-medium opacity-60 uppercase tracking-wide">Texto</span>
<div class="flex gap-1">
<button
class="inline-flex items-center gap-[0.4rem] px-3 py-1 rounded-full border text-sm font-medium cursor-pointer transition-colors duration-[120ms] disabled:cursor-not-allowed"
:class="
form.text_color === '#ffffff'
? 'bg-[var(--surface-section,var(--surface-100))] border-[var(--primary-color)] text-[var(--primary-color)] font-bold'
: 'bg-transparent border-[var(--surface-border)] text-[var(--text-color)] hover:bg-[var(--surface-hover)]'
"
:disabled="saving || isEditLocked"
@click="form.text_color = '#ffffff'"
>
<span class="w-2.5 h-2.5 rounded-full inline-block border border-[#ccc]" style="background: #ffffff" />
Branco
</button>
<button
class="inline-flex items-center gap-[0.4rem] px-3 py-1 rounded-full border text-sm font-medium cursor-pointer transition-colors duration-[120ms] disabled:cursor-not-allowed"
:class="
form.text_color === '#000000'
? 'bg-[var(--surface-section,var(--surface-100))] border-[var(--primary-color)] text-[var(--primary-color)] font-bold'
: 'bg-transparent border-[var(--surface-border)] text-[var(--text-color)] hover:bg-[var(--surface-hover)]'
"
:disabled="saving || isEditLocked"
@click="form.text_color = '#000000'"
>
<span class="w-2.5 h-2.5 rounded-full inline-block" style="background: #000000" />
Preto
</button>
</div>
</div>
</div>
<!-- Campos adicionais -->
<div class="border border-[var(--surface-border)] rounded-[6px] bg-[var(--surface-card)] p-4">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="text-[1rem] font-bold uppercase tracking-[0.06em] opacity-45">Campos adicionais</div>
<Button label="Adicionar campo" icon="pi pi-plus" severity="secondary" outlined size="small" class="rounded-full" :disabled="saving || isFieldsLocked" @click="addField" />
</div>
<div v-if="!form.fields.length" class="py-3 text-center text-sm opacity-50">Nenhum campo adicional configurado.</div>
<div v-else class="flex flex-col gap-2">
<div v-for="(f, idx) in form.fields" :key="f.key" class="grid grid-cols-1 gap-2 rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-0)] p-3 md:grid-cols-12">
<div class="md:col-span-6">
<FloatLabel variant="on">
<InputText :id="`cr-field-label-${idx}`" v-model="f.label" class="w-full" variant="filled" :disabled="saving || isFieldsLocked" @keydown.enter.prevent="submit" @blur="syncKey(f)" />
<label :for="`cr-field-label-${idx}`">Nome do campo</label>
</FloatLabel>
</div>
<div class="md:col-span-4">
<FloatLabel variant="on">
<Dropdown :id="`cr-field-type-${idx}`" v-model="f.type" :options="fieldTypeOptions" optionLabel="label" optionValue="value" class="w-full" variant="filled" :disabled="saving || isFieldsLocked" />
<label :for="`cr-field-type-${idx}`">Tipo</label>
</FloatLabel>
</div>
<div class="md:col-span-2 flex items-center justify-end">
<Button icon="pi pi-trash" severity="danger" text rounded :disabled="saving || isFieldsLocked" @click="removeField(idx)" />
</div>
</div>
</div>
</div>
</div>
<!-- Footer com botões Cancelar / Salvar -->
<template #footer>
<div class="flex items-center justify-end gap-2 pt-2">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="close" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="saving" :disabled="!canSubmit" @click="submit" />
</div>
</template>
</Dialog>
</template>
@@ -15,443 +15,386 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useToast } from 'primevue/usetoast'
import { useFeriados } from '@/composables/useFeriados'
import DatePicker from 'primevue/datepicker'
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useToast } from 'primevue/usetoast';
import { useFeriados } from '@/composables/useFeriados';
import DatePicker from 'primevue/datepicker';
defineOptions({ inheritAttrs: false })
defineOptions({ inheritAttrs: false });
const props = defineProps({
// Quando passados pelas páginas de agenda, dispensam o boot() interno
ownerId: { type: String, default: null },
tenantId: { type: String, default: null },
workRules: { type: Array, default: () => [] }
})
// Quando passados pelas páginas de agenda, dispensam o boot() interno
ownerId: { type: String, default: null },
tenantId: { type: String, default: null },
workRules: { type: Array, default: () => [] }
});
const emit = defineEmits(['bloqueado'])
const emit = defineEmits(['bloqueado']);
const router = useRouter()
const tenantStore = useTenantStore()
const toast = useToast()
const router = useRouter();
const tenantStore = useTenantStore();
const toast = useToast();
const { nacionais, municipais, todos, loading, load, criar, remover, isDuplicata, doMes } = useFeriados()
const { nacionais, municipais, todos, loading, load, criar, remover, isDuplicata, doMes } = useFeriados();
// ── Auth — só faz boot interno se as props não vieram ────────
const _ownerId = ref(props.ownerId)
const _tenantId = ref(props.tenantId)
const _ownerId = ref(props.ownerId);
const _tenantId = ref(props.tenantId);
watch(() => props.ownerId, v => { if (v) _ownerId.value = v })
watch(() => props.tenantId, v => { if (v) _tenantId.value = v })
watch(
() => props.ownerId,
(v) => {
if (v) _ownerId.value = v;
}
);
watch(
() => props.tenantId,
(v) => {
if (v) _tenantId.value = v;
}
);
async function boot () {
if (!_ownerId.value) {
const { data } = await supabase.auth.getUser()
_ownerId.value = data?.user?.id || null
}
if (!_tenantId.value) {
_tenantId.value = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
}
if (_tenantId.value) await load(_tenantId.value)
async function boot() {
if (!_ownerId.value) {
const { data } = await supabase.auth.getUser();
_ownerId.value = data?.user?.id || null;
}
if (!_tenantId.value) {
_tenantId.value = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
}
if (_tenantId.value) await load(_tenantId.value);
}
onMounted(boot)
onMounted(boot);
// ── Feriados do mês atual ────────────────────────────────────
const mesAtual = new Date().getMonth() + 1
const feriadosMes = computed(() => doMes(mesAtual))
const mesAtual = new Date().getMonth() + 1;
const feriadosMes = computed(() => doMes(mesAtual));
const MESES = ['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro']
const nomeMes = MESES[mesAtual - 1]
const MESES = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'];
const nomeMes = MESES[mesAtual - 1];
// ── Dias de trabalho (dow) ────────────────────────────────────
const workDowSet = computed(() =>
new Set((props.workRules || []).filter(r => r.ativo).map(r => Number(r.dia_semana)))
)
const workDowSet = computed(() => new Set((props.workRules || []).filter((r) => r.ativo).map((r) => Number(r.dia_semana))));
function isDiaUtil (iso) {
if (!iso) return false
const [y, m, d] = iso.split('-').map(Number)
const dow = new Date(y, m - 1, d).getDay()
// Se não tem workRules, assume que todo dia pode ser relevante
if (!props.workRules?.length) return true
return workDowSet.value.has(dow)
function isDiaUtil(iso) {
if (!iso) return false;
const [y, m, d] = iso.split('-').map(Number);
const dow = new Date(y, m - 1, d).getDay();
// Se não tem workRules, assume que todo dia pode ser relevante
if (!props.workRules?.length) return true;
return workDowSet.value.has(dow);
}
// ── Bloqueios já existentes para o mês ───────────────────────
const bloqueiosDatas = ref(new Set()) // Set de ISO strings já bloqueadas (feriado)
const loadingBloqueios = ref(false)
const bloqueiosDatas = ref(new Set()); // Set de ISO strings já bloqueadas (feriado)
const loadingBloqueios = ref(false);
async function loadBloqueiosMes () {
if (!_ownerId.value) return
const ano = new Date().getFullYear()
const start = `${ano}-${String(mesAtual).padStart(2,'0')}-01`
const end = `${ano}-${String(mesAtual).padStart(2,'0')}-31`
loadingBloqueios.value = true
try {
const { data } = await supabase
.from('agenda_bloqueios')
.select('data_inicio')
.eq('owner_id', _ownerId.value)
.in('origem', ['agenda_feriado', 'agenda_dia'])
.gte('data_inicio', start)
.lte('data_inicio', end)
bloqueiosDatas.value = new Set((data || []).map(r => r.data_inicio))
} catch { /* silencioso */ }
finally { loadingBloqueios.value = false }
async function loadBloqueiosMes() {
if (!_ownerId.value) return;
const ano = new Date().getFullYear();
const start = `${ano}-${String(mesAtual).padStart(2, '0')}-01`;
const end = `${ano}-${String(mesAtual).padStart(2, '0')}-31`;
loadingBloqueios.value = true;
try {
const { data } = await supabase.from('agenda_bloqueios').select('data_inicio').eq('owner_id', _ownerId.value).in('origem', ['agenda_feriado', 'agenda_dia']).gte('data_inicio', start).lte('data_inicio', end);
bloqueiosDatas.value = new Set((data || []).map((r) => r.data_inicio));
} catch {
/* silencioso */
} finally {
loadingBloqueios.value = false;
}
}
watch(_ownerId, v => { if (v) loadBloqueiosMes() })
onMounted(() => { if (_ownerId.value) loadBloqueiosMes() })
watch(_ownerId, (v) => {
if (v) loadBloqueiosMes();
});
onMounted(() => {
if (_ownerId.value) loadBloqueiosMes();
});
function jaFoiBloqueado (iso) {
return bloqueiosDatas.value.has(iso)
function jaFoiBloqueado(iso) {
return bloqueiosDatas.value.has(iso);
}
// ── Dupla confirmação inline ──────────────────────────────────
const confirmandoIso = ref(null) // ISO do feriado aguardando confirmação
const salvandoIso = ref(null) // ISO sendo gravado
const confirmandoIso = ref(null); // ISO do feriado aguardando confirmação
const salvandoIso = ref(null); // ISO sendo gravado
function pedirConfirmacao (iso) {
// Se já está confirmando outro, cancela e abre o novo
confirmandoIso.value = confirmandoIso.value === iso ? null : iso
function pedirConfirmacao(iso) {
// Se já está confirmando outro, cancela e abre o novo
confirmandoIso.value = confirmandoIso.value === iso ? null : iso;
}
function cancelarConfirmacao () {
confirmandoIso.value = null
function cancelarConfirmacao() {
confirmandoIso.value = null;
}
async function confirmarBloqueio (feriado) {
if (!_ownerId.value || !_tenantId.value) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Configurações da agenda não carregadas.', life: 3000 })
return
}
salvandoIso.value = feriado.data
confirmandoIso.value = null
try {
const row = {
owner_id: _ownerId.value,
tenant_id: _tenantId.value,
tipo: 'bloqueio',
recorrente: false,
titulo: `Feriado: ${feriado.nome}`,
data_inicio: feriado.data,
data_fim: feriado.data,
hora_inicio: null,
hora_fim: null,
origem: 'agenda_feriado'
async function confirmarBloqueio(feriado) {
if (!_ownerId.value || !_tenantId.value) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Configurações da agenda não carregadas.', life: 3000 });
return;
}
salvandoIso.value = feriado.data;
confirmandoIso.value = null;
try {
const row = {
owner_id: _ownerId.value,
tenant_id: _tenantId.value,
tipo: 'bloqueio',
recorrente: false,
titulo: `Feriado: ${feriado.nome}`,
data_inicio: feriado.data,
data_fim: feriado.data,
hora_inicio: null,
hora_fim: null,
origem: 'agenda_feriado'
};
const { error } = await supabase.from('agenda_bloqueios').insert([row])
if (error) throw error
const { error } = await supabase.from('agenda_bloqueios').insert([row]);
if (error) throw error;
// Marcar sessões existentes no dia como 'remarcar'
await supabase
.from('agenda_eventos')
.update({ status: 'remarcar' })
.eq('owner_id', _ownerId.value)
.eq('tipo', 'sessao')
.gte('inicio_em', `${feriado.data}T00:00:00`)
.lte('inicio_em', `${feriado.data}T23:59:59`)
// Marcar sessões existentes no dia como 'remarcar'
await supabase.from('agenda_eventos').update({ status: 'remarcar' }).eq('owner_id', _ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
bloqueiosDatas.value = new Set([...bloqueiosDatas.value, feriado.data])
toast.add({
severity: 'success',
summary: 'Dia bloqueado',
detail: `${feriado.nome} bloqueado. Sessões existentes marcadas para reagendamento.`,
life: 4000
})
emit('bloqueado', feriado)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao bloquear.', life: 4000 })
} finally {
salvandoIso.value = null
}
bloqueiosDatas.value = new Set([...bloqueiosDatas.value, feriado.data]);
toast.add({
severity: 'success',
summary: 'Dia bloqueado',
detail: `${feriado.nome} bloqueado. Sessões existentes marcadas para reagendamento.`,
life: 4000
});
emit('bloqueado', feriado);
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao bloquear.', life: 4000 });
} finally {
salvandoIso.value = null;
}
}
// ── Dialog cadastro municipal ─────────────────────────────────
const dlgOpen = ref(false)
const saving = ref(false)
const form = ref({ nome: '', data: null, observacao: '', bloqueia_sessoes: false })
const dlgOpen = ref(false);
const saving = ref(false);
const form = ref({ nome: '', data: null, observacao: '', bloqueia_sessoes: false });
const formValid = computed(() => !!form.value.nome.trim() && !!form.value.data)
const formValid = computed(() => !!form.value.nome.trim() && !!form.value.data);
function abrirDialog () {
form.value = { nome: '', data: null, observacao: '', bloqueia_sessoes: false }
dlgOpen.value = true
function abrirDialog() {
form.value = { nome: '', data: null, observacao: '', bloqueia_sessoes: false };
dlgOpen.value = true;
}
function dateToISO (d) {
if (!d) return null
const dt = d instanceof Date ? d : new Date(d)
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`
function dateToISO(d) {
if (!d) return null;
const dt = d instanceof Date ? d : new Date(d);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
}
async function salvar () {
if (!formValid.value) return
const iso = dateToISO(form.value.data)
if (isDuplicata(iso, form.value.nome)) {
toast.add({ severity: 'warn', summary: 'Duplicado', detail: 'Já existe um feriado com esse nome nessa data.', life: 3000 })
return
}
saving.value = true
try {
await criar({
tenant_id: _tenantId.value,
owner_id: _ownerId.value,
tipo: 'municipal',
nome: form.value.nome.trim(),
data: iso,
observacao: form.value.observacao || null,
bloqueia_sessoes: form.value.bloqueia_sessoes
})
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
dlgOpen.value = false
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
} finally {
saving.value = false
}
async function salvar() {
if (!formValid.value) return;
const iso = dateToISO(form.value.data);
if (isDuplicata(iso, form.value.nome)) {
toast.add({ severity: 'warn', summary: 'Duplicado', detail: 'Já existe um feriado com esse nome nessa data.', life: 3000 });
return;
}
saving.value = true;
try {
await criar({
tenant_id: _tenantId.value,
owner_id: _ownerId.value,
tipo: 'municipal',
nome: form.value.nome.trim(),
data: iso,
observacao: form.value.observacao || null,
bloqueia_sessoes: form.value.bloqueia_sessoes
});
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 });
dlgOpen.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 });
} finally {
saving.value = false;
}
}
// ── Helpers ───────────────────────────────────────────────────
function fmtDate (iso) {
if (!iso) return ''
const [, m, d] = String(iso).split('-')
return `${d}/${m}`
function fmtDate(iso) {
if (!iso) return '';
const [, m, d] = String(iso).split('-');
return `${d}/${m}`;
}
</script>
<template>
<div v-bind="$attrs" class="rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
<!-- Cabeçalho -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
<div class="flex items-center gap-2">
<i class="pi pi-star text-amber-500 text-sm" />
<span class="font-semibold text-sm">Próximos feriados</span>
</div>
<span class="text-xs text-[var(--text-color-secondary)]">{{ nomeMes }}</span>
</div>
<!-- Lista -->
<div class="px-4 py-3">
<div v-if="loading" class="flex flex-col gap-2 py-1">
<Skeleton v-for="n in 3" :key="n" height="2rem" class="rounded" />
</div>
<div v-else-if="!feriadosMes.length" class="text-sm text-[var(--text-color-secondary)] py-1">
Nenhum feriado este mês.
</div>
<ul v-else class="flex flex-col gap-2">
<li
v-for="f in feriadosMes"
:key="f.data + f.nome"
class="flex flex-col gap-1"
>
<!-- Linha principal do feriado -->
<div class="flex items-center gap-2 text-sm">
<span class="text-[var(--text-color-secondary)] font-mono text-xs w-10 shrink-0">{{ fmtDate(f.data) }}</span>
<span class="flex-1 truncate" :class="{ 'line-through opacity-50': jaFoiBloqueado(f.data) }">{{ f.nome }}</span>
<Tag
:value="f.tipo === 'nacional' ? 'Nacional' : 'Municipal'"
:severity="f.tipo === 'nacional' ? 'info' : 'warn'"
class="text-xs shrink-0"
/>
<!-- Botão bloquear / bloqueado -->
<template v-if="isDiaUtil(f.data)">
<!-- bloqueado -->
<span
v-if="jaFoiBloqueado(f.data)"
v-tooltip.top="'Dia bloqueado'"
class="pfc-lock pfc-lock--done"
>
<i class="pi pi-lock text-xs" />
</span>
<!-- Salvando -->
<span v-else-if="salvandoIso === f.data" class="pfc-lock">
<i class="pi pi-spinner pi-spin text-xs" />
</span>
<!-- Aguardando confirmação ícone ativo -->
<button
v-else-if="confirmandoIso === f.data"
v-tooltip.top="'Cancelar'"
class="pfc-lock pfc-lock--active"
@click="cancelarConfirmacao"
>
<i class="pi pi-times text-xs" />
</button>
<!-- Estado normal abre confirmação -->
<button
v-else
v-tooltip.top="'Bloquear este dia'"
class="pfc-lock pfc-lock--idle"
@click="pedirConfirmacao(f.data)"
>
<i class="pi pi-lock-open text-xs" />
</button>
</template>
</div>
<!-- Confirmação inline (expande abaixo do item) -->
<Transition name="pfc-expand">
<div
v-if="confirmandoIso === f.data"
class="pfc-confirm"
>
<i class="pi pi-exclamation-triangle pfc-confirm__icon" />
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold mb-0.5">Bloquear {{ f.nome }}?</p>
<p class="text-xs opacity-70 leading-snug">O dia inteiro ficará indisponível. Sessões existentes serão marcadas para reagendamento.</p>
</div>
<div class="flex gap-1.5 shrink-0">
<Button label="Não" size="small" severity="secondary" outlined class="rounded-full h-7 text-xs px-3" @click="cancelarConfirmacao" />
<Button label="Bloquear" size="small" severity="danger" icon="pi pi-lock" class="rounded-full h-7 text-xs px-3" @click="confirmarBloqueio(f)" />
</div>
<div v-bind="$attrs" class="rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
<!-- Cabeçalho -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
<div class="flex items-center gap-2">
<i class="pi pi-star text-amber-500 text-sm" />
<span class="font-semibold text-sm">Próximos feriados</span>
</div>
</Transition>
</li>
</ul>
<span class="text-xs text-[var(--text-color-secondary)]">{{ nomeMes }}</span>
</div>
<!-- Lista -->
<div class="px-4 py-3">
<div v-if="loading" class="flex flex-col gap-2 py-1">
<Skeleton v-for="n in 3" :key="n" height="2rem" class="rounded" />
</div>
<div v-else-if="!feriadosMes.length" class="text-sm text-[var(--text-color-secondary)] py-1">Nenhum feriado este mês.</div>
<ul v-else class="flex flex-col gap-2">
<li v-for="f in feriadosMes" :key="f.data + f.nome" class="flex flex-col gap-1">
<!-- Linha principal do feriado -->
<div class="flex items-center gap-2 text-sm">
<span class="text-[var(--text-color-secondary)] font-mono text-xs w-10 shrink-0">{{ fmtDate(f.data) }}</span>
<span class="flex-1 truncate" :class="{ 'line-through opacity-50': jaFoiBloqueado(f.data) }">{{ f.nome }}</span>
<Tag :value="f.tipo === 'nacional' ? 'Nacional' : 'Municipal'" :severity="f.tipo === 'nacional' ? 'info' : 'warn'" class="text-xs shrink-0" />
<!-- Botão bloquear / bloqueado -->
<template v-if="isDiaUtil(f.data)">
<!-- bloqueado -->
<span v-if="jaFoiBloqueado(f.data)" v-tooltip.top="'Dia já bloqueado'" class="pfc-lock pfc-lock--done">
<i class="pi pi-lock text-xs" />
</span>
<!-- Salvando -->
<span v-else-if="salvandoIso === f.data" class="pfc-lock">
<i class="pi pi-spinner pi-spin text-xs" />
</span>
<!-- Aguardando confirmação ícone ativo -->
<button v-else-if="confirmandoIso === f.data" v-tooltip.top="'Cancelar'" class="pfc-lock pfc-lock--active" @click="cancelarConfirmacao">
<i class="pi pi-times text-xs" />
</button>
<!-- Estado normal abre confirmação -->
<button v-else v-tooltip.top="'Bloquear este dia'" class="pfc-lock pfc-lock--idle" @click="pedirConfirmacao(f.data)">
<i class="pi pi-lock-open text-xs" />
</button>
</template>
</div>
<!-- Confirmação inline (expande abaixo do item) -->
<Transition name="pfc-expand">
<div v-if="confirmandoIso === f.data" class="pfc-confirm">
<i class="pi pi-exclamation-triangle pfc-confirm__icon" />
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold mb-0.5">Bloquear {{ f.nome }}?</p>
<p class="text-xs opacity-70 leading-snug">O dia inteiro ficará indisponível. Sessões existentes serão marcadas para reagendamento.</p>
</div>
<div class="flex gap-1.5 shrink-0">
<Button label="Não" size="small" severity="secondary" outlined class="rounded-full h-7 text-xs px-3" @click="cancelarConfirmacao" />
<Button label="Bloquear" size="small" severity="danger" icon="pi pi-lock" class="rounded-full h-7 text-xs px-3" @click="confirmarBloqueio(f)" />
</div>
</div>
</Transition>
</li>
</ul>
</div>
<!-- Ações -->
<div class="flex flex-col gap-1.5 px-4 pb-4">
<Button icon="pi pi-plus" label="Cadastrar feriado municipal" severity="secondary" outlined size="small" class="w-full rounded-full" @click="abrirDialog" />
<Button icon="pi pi-list" label="Ver todos os feriados" text size="small" class="w-full rounded-full" @click="router.push('/configuracoes/bloqueios')" />
</div>
</div>
<!-- Ações -->
<div class="flex flex-col gap-1.5 px-4 pb-4">
<Button
icon="pi pi-plus"
label="Cadastrar feriado municipal"
severity="secondary"
outlined
size="small"
class="w-full rounded-full"
@click="abrirDialog"
/>
<Button
icon="pi pi-list"
label="Ver todos os feriados"
text
size="small"
class="w-full rounded-full"
@click="router.push('/configuracoes/bloqueios')"
/>
</div>
</div>
<!-- Dialog cadastro -->
<Dialog
v-model:visible="dlgOpen"
modal
:draggable="false"
header="Cadastrar feriado municipal"
:style="{ width: '420px' }"
>
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Nome do feriado *</label>
<InputText v-model="form.nome" class="w-full mt-1" placeholder="Ex.: Aniversário da cidade, Padroeiro…" />
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Data *</label>
<DatePicker
v-model="form.data"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
class="mt-1"
>
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
</div>
<div v-if="form.data && form.nome && isDuplicata(dateToISO(form.data), form.nome)"
class="text-sm text-red-500 flex items-center gap-2">
<i class="pi pi-exclamation-triangle" />
existe um feriado com esse nome nessa data.
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="dlgOpen = false" />
<Button
label="Cadastrar"
icon="pi pi-check"
:disabled="!formValid || (form.data && form.nome && isDuplicata(dateToISO(form.data), form.nome))"
:loading="saving"
@click="salvar"
/>
</template>
</Dialog>
<!-- Dialog cadastro -->
<Dialog v-model:visible="dlgOpen" modal :draggable="false" header="Cadastrar feriado municipal" :style="{ width: '420px' }">
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Nome do feriado *</label>
<InputText v-model="form.nome" class="w-full mt-1" placeholder="Ex.: Aniversário da cidade, Padroeiro…" />
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Data *</label>
<DatePicker v-model="form.data" showIcon fluid iconDisplay="input" dateFormat="dd/mm/yy" :manualInput="false" class="mt-1">
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
</div>
<div v-if="form.data && form.nome && isDuplicata(dateToISO(form.data), form.nome)" class="text-sm text-red-500 flex items-center gap-2">
<i class="pi pi-exclamation-triangle" />
existe um feriado com esse nome nessa data.
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="dlgOpen = false" />
<Button label="Cadastrar" icon="pi pi-check" :disabled="!formValid || (form.data && form.nome && isDuplicata(dateToISO(form.data), form.nome))" :loading="saving" @click="salvar" />
</template>
</Dialog>
</template>
<style scoped>
/* ── Ícone de cadeado por feriado ────────────────────────── */
.pfc-lock {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
flex-shrink: 0;
transition: all 0.14s;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
flex-shrink: 0;
transition: all 0.14s;
}
.pfc-lock--idle {
color: var(--text-color-secondary);
background: transparent;
border: 1.5px solid var(--surface-border);
cursor: pointer;
color: var(--text-color-secondary);
background: transparent;
border: 1.5px solid var(--surface-border);
cursor: pointer;
}
.pfc-lock--idle:hover {
color: var(--red-600, #dc2626);
border-color: var(--red-400, #f87171);
background: color-mix(in srgb, var(--red-400, #f87171) 10%, transparent);
color: var(--red-600, #dc2626);
border-color: var(--red-400, #f87171);
background: color-mix(in srgb, var(--red-400, #f87171) 10%, transparent);
}
.pfc-lock--active {
color: var(--red-600, #dc2626);
border: 1.5px solid var(--red-400, #f87171);
background: color-mix(in srgb, var(--red-400, #f87171) 12%, transparent);
cursor: pointer;
color: var(--red-600, #dc2626);
border: 1.5px solid var(--red-400, #f87171);
background: color-mix(in srgb, var(--red-400, #f87171) 12%, transparent);
cursor: pointer;
}
.pfc-lock--done {
color: var(--text-color-secondary);
opacity: 0.45;
cursor: default;
color: var(--text-color-secondary);
opacity: 0.45;
cursor: default;
}
/* ── Confirmação inline ───────────────────────────────────── */
.pfc-confirm {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--red-400, #f87171) 10%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
margin-left: 2.75rem; /* alinha com o nome, após a data */
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--red-400, #f87171) 10%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
margin-left: 2.75rem; /* alinha com o nome, após a data */
}
.pfc-confirm__icon {
color: var(--red-500, #ef4444);
flex-shrink: 0;
margin-top: 2px;
font-size: 0.8rem;
color: var(--red-500, #ef4444);
flex-shrink: 0;
margin-top: 2px;
font-size: 0.8rem;
}
/* ── Transição expand ─────────────────────────────────────── */
.pfc-expand-enter-active,
.pfc-expand-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
.pfc-expand-enter-from,
.pfc-expand-leave-to {
opacity: 0;
transform: translateY(-4px);
opacity: 0;
transform: translateY(-4px);
}
</style>
</style>
@@ -15,151 +15,104 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue';
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'
})
title: { type: String, default: 'Agenda' },
view: { type: String, default: 'day' }, // 'day' | 'week'
mode: { type: String, default: 'work_hours' } // 'full_24h' | 'work_hours'
});
const emit = defineEmits([
'today',
'prev',
'next',
'changeView',
'toggleMode',
'createSession',
'createBlock',
'search'
])
const emit = defineEmits(['today', 'prev', 'next', 'changeView', 'toggleMode', 'createSession', 'createBlock', 'search']);
// UX: busca com debounce simples
const searchLocal = ref('')
let t = null
const searchLocal = ref('');
let t = null;
watch(searchLocal, (v) => {
clearTimeout(t)
t = setTimeout(() => emit('search', v), 220)
})
clearTimeout(t);
t = setTimeout(() => emit('search', v), 220);
});
// SelectButtons (executivo, direto)
const viewOptions = [
{ label: 'Dia', value: 'day' },
{ label: 'Semana', value: 'week' }
]
{ 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' }
]
{ 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' }
})
return props.mode === 'full_24h' ? { severity: 'info', text: '24h' } : { severity: 'success', text: 'Funcionamento' };
});
function onChangeView(val) {
emit('changeView', val)
emit('changeView', val);
}
function onToggleMode(val) {
emit('toggleMode', val)
emit('toggleMode', val);
}
</script>
<template>
<Card class="mb-3 md:mb-4">
<template #content>
<div class="flex flex-column gap-3">
<Card class="mb-3 md:mb-4">
<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>
<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>
<!-- 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>
<Tag :severity="modeTag.severity" :value="modeTag.text" />
<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>
<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>
</Card>
</template>
@@ -15,139 +15,123 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed } from 'vue'
import { computed } from 'vue';
const props = defineProps({
stats: { type: Object, default: () => ({}) }
})
stats: { type: Object, default: () => ({}) }
});
const emit = 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'
}
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 ?? '—'
}
})
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 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 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'
})
if (s.value.attentions >= 3) return 'danger';
if (s.value.attentions >= 1) return 'warning';
return 'success';
});
</script>
<template>
<Card>
<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>
<Card>
<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 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 class="flex align-items-center gap-2">
<Tag :severity="attentionSeverity" :value="`Atenções: ${s.attentions}`" />
</div>
</div>
</div>
</template>
<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}`" />
<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>
<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>
</Card>
</template>
File diff suppressed because it is too large Load Diff
@@ -9,269 +9,279 @@
* Não usa Supabase — sem mocks necessários.
*/
import { describe, it, expect } from 'vitest'
import { generateDates, expandRules, mergeWithStoredSessions } from '../useRecurrence.js'
import { describe, it, expect } from 'vitest';
import { generateDates, expandRules, mergeWithStoredSessions } from '../useRecurrence.js';
// ─── helpers de fixture ───────────────────────────────────────────────────────
function d (iso) {
const [y, m, day] = iso.split('-').map(Number)
return new Date(y, m - 1, day)
function d(iso) {
const [y, m, day] = iso.split('-').map(Number);
return new Date(y, m - 1, day);
}
function rule (overrides = {}) {
return {
id: 'rule-1',
owner_id: 'owner-1',
tenant_id: 'tenant-1',
patient_id: 'patient-1',
therapist_id: 'therapist-1',
status: 'ativo',
type: 'weekly',
weekdays: [1], // segunda
interval: 1,
start_date: '2026-03-02', // segunda
end_date: null,
max_occurrences: null,
open_ended: true,
start_time: '09:00',
end_time: '10:00',
...overrides,
}
function rule(overrides = {}) {
return {
id: 'rule-1',
owner_id: 'owner-1',
tenant_id: 'tenant-1',
patient_id: 'patient-1',
therapist_id: 'therapist-1',
status: 'ativo',
type: 'weekly',
weekdays: [1], // segunda
interval: 1,
start_date: '2026-03-02', // segunda
end_date: null,
max_occurrences: null,
open_ended: true,
start_time: '09:00',
end_time: '10:00',
...overrides
};
}
function exception (overrides = {}) {
return {
id: 'exc-1',
recurrence_id: 'rule-1',
original_date: '2026-03-09',
type: 'cancel_session',
new_date: null,
...overrides,
}
function exception(overrides = {}) {
return {
id: 'exc-1',
recurrence_id: 'rule-1',
original_date: '2026-03-09',
type: 'cancel_session',
new_date: null,
...overrides
};
}
// ─── generateDates ────────────────────────────────────────────────────────────
describe('generateDates — weekly', () => {
it('gera ocorrências semanais dentro do range', () => {
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02' })
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
const isos = dates.map(d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`)
expect(isos).toEqual(['2026-03-02', '2026-03-09', '2026-03-16', '2026-03-23', '2026-03-30'])
})
it('gera ocorrências semanais dentro do range', () => {
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02' });
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'));
const isos = dates.map((d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
expect(isos).toEqual(['2026-03-02', '2026-03-09', '2026-03-16', '2026-03-23', '2026-03-30']);
});
it('não gera antes do start_date da regra', () => {
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-16' })
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
expect(dates.every(d => d >= new Date(2026, 2, 16))).toBe(true)
})
it('não gera antes do start_date da regra', () => {
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-16' });
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'));
expect(dates.every((d) => d >= new Date(2026, 2, 16))).toBe(true);
});
it('não gera após o end_date da regra', () => {
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02', end_date: '2026-03-16' })
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
expect(dates.length).toBe(3) // 02, 09, 16
})
it('não gera após o end_date da regra', () => {
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02', end_date: '2026-03-16' });
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'));
expect(dates.length).toBe(3); // 02, 09, 16
});
it('respeita max_occurrences dentro do range', () => {
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02', max_occurrences: 2 })
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
expect(dates.length).toBe(2)
})
it('respeita max_occurrences dentro do range', () => {
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02', max_occurrences: 2 });
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'));
expect(dates.length).toBe(2);
});
it('respeita max_occurrences globalmente — range começa na 3ª semana', () => {
// 4 ocorrências totais, range começa na semana 3 → só 2 dentro do range
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02', max_occurrences: 4 })
const dates = generateDates(r, d('2026-03-15'), d('2026-04-30'))
// 16, 23, 30 → mas max=4 globalmente (2 antes do range + 2 no range)
expect(dates.length).toBe(2) // 2026-03-16, 2026-03-23
})
})
it('respeita max_occurrences globalmente — range começa na 3ª semana', () => {
// 4 ocorrências totais, range começa na semana 3 → só 2 dentro do range
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02', max_occurrences: 4 });
const dates = generateDates(r, d('2026-03-15'), d('2026-04-30'));
// 16, 23, 30 → mas max=4 globalmente (2 antes do range + 2 no range)
expect(dates.length).toBe(2); // 2026-03-16, 2026-03-23
});
});
describe('generateDates — biweekly', () => {
it('gera ocorrências a cada 2 semanas', () => {
const r = rule({ type: 'biweekly', weekdays: [1], interval: 2, start_date: '2026-03-02' })
const dates = generateDates(r, d('2026-03-01'), d('2026-04-30'))
const isos = dates.map(d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`)
expect(isos).toEqual(['2026-03-02', '2026-03-16', '2026-03-30', '2026-04-13', '2026-04-27'])
})
})
it('gera ocorrências a cada 2 semanas', () => {
const r = rule({ type: 'biweekly', weekdays: [1], interval: 2, start_date: '2026-03-02' });
const dates = generateDates(r, d('2026-03-01'), d('2026-04-30'));
const isos = dates.map((d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
expect(isos).toEqual(['2026-03-02', '2026-03-16', '2026-03-30', '2026-04-13', '2026-04-27']);
});
});
describe('generateDates — custom_weekdays', () => {
it('gera ocorrências em múltiplos dias da semana', () => {
const r = rule({ type: 'custom_weekdays', weekdays: [1, 3], start_date: '2026-03-02' }) // seg e qua
const dates = generateDates(r, d('2026-03-01'), d('2026-03-08'))
const isos = dates.map(d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`)
expect(isos).toEqual(['2026-03-02', '2026-03-04'])
})
it('gera ocorrências em múltiplos dias da semana', () => {
const r = rule({ type: 'custom_weekdays', weekdays: [1, 3], start_date: '2026-03-02' }); // seg e qua
const dates = generateDates(r, d('2026-03-01'), d('2026-03-08'));
const isos = dates.map((d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
expect(isos).toEqual(['2026-03-02', '2026-03-04']);
});
it('respeita max_occurrences globalmente com custom_weekdays', () => {
// 2 dias/semana, max=3 → semana 1 (02,04), semana 2 (09) e para
const r = rule({ type: 'custom_weekdays', weekdays: [1, 3], start_date: '2026-03-02', max_occurrences: 3 })
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
expect(dates.length).toBe(3)
})
it('respeita max_occurrences globalmente com custom_weekdays', () => {
// 2 dias/semana, max=3 → semana 1 (02,04), semana 2 (09) e para
const r = rule({ type: 'custom_weekdays', weekdays: [1, 3], start_date: '2026-03-02', max_occurrences: 3 });
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'));
expect(dates.length).toBe(3);
});
it('max_occurrences globalmente — range começa na semana 2', () => {
// semana 1 já consumiu 2 ocorrências (02, 04), max=3 → só 1 no range (09)
const r = rule({ type: 'custom_weekdays', weekdays: [1, 3], start_date: '2026-03-02', max_occurrences: 3 })
const dates = generateDates(r, d('2026-03-08'), d('2026-03-31'))
expect(dates.length).toBe(1)
})
})
it('max_occurrences globalmente — range começa na semana 2', () => {
// semana 1 já consumiu 2 ocorrências (02, 04), max=3 → só 1 no range (09)
const r = rule({ type: 'custom_weekdays', weekdays: [1, 3], start_date: '2026-03-02', max_occurrences: 3 });
const dates = generateDates(r, d('2026-03-08'), d('2026-03-31'));
expect(dates.length).toBe(1);
});
});
describe('generateDates — monthly', () => {
it('gera ocorrências mensais no mesmo dia', () => {
const r = rule({ type: 'monthly', weekdays: [1], start_date: '2026-01-15' })
const dates = generateDates(r, d('2026-01-01'), d('2026-04-30'))
expect(dates.length).toBe(4)
expect(dates.every(d => d.getDate() === 15)).toBe(true)
})
it('gera ocorrências mensais no mesmo dia', () => {
const r = rule({ type: 'monthly', weekdays: [1], start_date: '2026-01-15' });
const dates = generateDates(r, d('2026-01-01'), d('2026-04-30'));
expect(dates.length).toBe(4);
expect(dates.every((d) => d.getDate() === 15)).toBe(true);
});
it('respeita max_occurrences globalmente — range começa no mês 3', () => {
const r = rule({ type: 'monthly', weekdays: [1], start_date: '2026-01-15', max_occurrences: 3 })
const dates = generateDates(r, d('2026-03-01'), d('2026-12-31'))
expect(dates.length).toBe(1) // só março (jan+fev já consumiram 2 de 3)
})
})
it('respeita max_occurrences globalmente — range começa no mês 3', () => {
const r = rule({ type: 'monthly', weekdays: [1], start_date: '2026-01-15', max_occurrences: 3 });
const dates = generateDates(r, d('2026-03-01'), d('2026-12-31'));
expect(dates.length).toBe(1); // só março (jan+fev já consumiram 2 de 3)
});
});
describe('generateDates — yearly', () => {
it('gera ocorrências anuais', () => {
const r = rule({ type: 'yearly', weekdays: [1], start_date: '2024-06-15' })
const dates = generateDates(r, d('2024-01-01'), d('2027-12-31'))
expect(dates.length).toBe(4) // 2024, 2025, 2026, 2027
})
it('gera ocorrências anuais', () => {
const r = rule({ type: 'yearly', weekdays: [1], start_date: '2024-06-15' });
const dates = generateDates(r, d('2024-01-01'), d('2027-12-31'));
expect(dates.length).toBe(4); // 2024, 2025, 2026, 2027
});
it('respeita max_occurrences globalmente — range começa no ano 3', () => {
const r = rule({ type: 'yearly', weekdays: [1], start_date: '2024-06-15', max_occurrences: 3 })
const dates = generateDates(r, d('2026-01-01'), d('2030-12-31'))
expect(dates.length).toBe(1) // só 2026 (2024+2025 já consumiram 2 de 3)
})
})
it('respeita max_occurrences globalmente — range começa no ano 3', () => {
const r = rule({ type: 'yearly', weekdays: [1], start_date: '2024-06-15', max_occurrences: 3 });
const dates = generateDates(r, d('2026-01-01'), d('2030-12-31'));
expect(dates.length).toBe(1); // só 2026 (2024+2025 já consumiram 2 de 3)
});
});
// ─── expandRules ─────────────────────────────────────────────────────────────
describe('expandRules', () => {
it('gera ocorrência normal sem exceção', () => {
const rules = [rule()]
const occs = expandRules(rules, [], d('2026-03-01'), d('2026-03-08'))
expect(occs.length).toBe(1)
expect(occs[0].status).toBe('agendado')
expect(occs[0].exception_type).toBeNull()
})
it('gera ocorrência normal sem exceção', () => {
const rules = [rule()];
const occs = expandRules(rules, [], d('2026-03-01'), d('2026-03-08'));
expect(occs.length).toBe(1);
expect(occs[0].status).toBe('agendado');
expect(occs[0].exception_type).toBeNull();
});
it('cancela ocorrência com cancel_session', () => {
const rules = [rule()]
const excs = [exception({ type: 'cancel_session', original_date: '2026-03-02' })]
const occs = expandRules(rules, excs, d('2026-03-01'), d('2026-03-08'))
expect(occs[0].status).toBe('cancelado')
expect(occs[0].exception_type).toBe('cancel_session')
})
it('cancela ocorrência com cancel_session', () => {
const rules = [rule()];
const excs = [exception({ type: 'cancel_session', original_date: '2026-03-02' })];
const occs = expandRules(rules, excs, d('2026-03-01'), d('2026-03-08'));
expect(occs[0].status).toBe('cancelado');
expect(occs[0].exception_type).toBe('cancel_session');
});
it('marca falta com patient_missed', () => {
const rules = [rule()]
const excs = [exception({ type: 'patient_missed', original_date: '2026-03-02' })]
const occs = expandRules(rules, excs, d('2026-03-01'), d('2026-03-08'))
expect(occs[0].status).toBe('faltou')
})
it('marca falta com patient_missed', () => {
const rules = [rule()];
const excs = [exception({ type: 'patient_missed', original_date: '2026-03-02' })];
const occs = expandRules(rules, excs, d('2026-03-01'), d('2026-03-08'));
expect(occs[0].status).toBe('faltou');
});
it('remarca ocorrência para nova data', () => {
const rules = [rule()]
const excs = [exception({
type: 'reschedule_session',
original_date: '2026-03-02',
new_date: '2026-03-04',
})]
const occs = expandRules(rules, excs, d('2026-03-01'), d('2026-03-08'))
// A ocorrência do dia 02 foi movida para 04
expect(occs[0].status).toBe('remarcado')
expect(occs[0].exception_type).toBe('reschedule_session')
// inicio_em reflete a nova data (04); original_date no main loop recebe new_date
expect(occs[0].inicio_em).toContain('2026-03-04')
})
it('remarca ocorrência para nova data', () => {
const rules = [rule()];
const excs = [
exception({
type: 'reschedule_session',
original_date: '2026-03-02',
new_date: '2026-03-04'
})
];
const occs = expandRules(rules, excs, d('2026-03-01'), d('2026-03-08'));
// A ocorrência do dia 02 foi movida para 04
expect(occs[0].status).toBe('remarcado');
expect(occs[0].exception_type).toBe('reschedule_session');
// inicio_em reflete a nova data (04); original_date no main loop recebe new_date
expect(occs[0].inicio_em).toContain('2026-03-04');
});
it('post-pass: remarcação inbound — original fora do range, new_date dentro', () => {
// Regra começa em 02/03 (segunda). original_date = 09/03 está FORA do range 16-22.
// new_date = 17/03 está DENTRO do range.
const rules = [rule({ start_date: '2026-03-02' })]
const excs = [exception({
type: 'reschedule_session',
original_date: '2026-03-09', // fora do range
new_date: '2026-03-17', // dentro do range
})]
const occs = expandRules(rules, excs, d('2026-03-16'), d('2026-03-22'))
const remarcado = occs.find(o => o.status === 'remarcado')
expect(remarcado).toBeDefined()
expect(remarcado.original_date).toBe('2026-03-09')
expect(remarcado.inicio_em).toContain('2026-03-17')
})
it('post-pass: remarcação inbound — original fora do range, new_date dentro', () => {
// Regra começa em 02/03 (segunda). original_date = 09/03 está FORA do range 16-22.
// new_date = 17/03 está DENTRO do range.
const rules = [rule({ start_date: '2026-03-02' })];
const excs = [
exception({
type: 'reschedule_session',
original_date: '2026-03-09', // fora do range
new_date: '2026-03-17' // dentro do range
})
];
const occs = expandRules(rules, excs, d('2026-03-16'), d('2026-03-22'));
const remarcado = occs.find((o) => o.status === 'remarcado');
expect(remarcado).toBeDefined();
expect(remarcado.original_date).toBe('2026-03-09');
expect(remarcado.inicio_em).toContain('2026-03-17');
});
it('ignora regra cancelada', () => {
const rules = [rule({ status: 'cancelado' })]
const occs = expandRules(rules, [], d('2026-03-01'), d('2026-03-31'))
expect(occs.length).toBe(0)
})
})
it('ignora regra cancelada', () => {
const rules = [rule({ status: 'cancelado' })];
const occs = expandRules(rules, [], d('2026-03-01'), d('2026-03-31'));
expect(occs.length).toBe(0);
});
});
// ─── mergeWithStoredSessions ──────────────────────────────────────────────────
describe('mergeWithStoredSessions', () => {
it('sessão real substitui ocorrência virtual para a mesma regra+data', () => {
const occs = [{
recurrence_id: 'rule-1',
original_date: '2026-03-02',
status: 'agendado',
is_occurrence: true,
is_real_session: false,
titulo: 'Virtual',
}]
const storedRows = [{
id: 'ev-real-1',
recurrence_id: 'rule-1',
recurrence_date: '2026-03-02',
status: 'realizado',
titulo: 'Real',
}]
const merged = mergeWithStoredSessions(occs, storedRows)
expect(merged.length).toBe(1)
expect(merged[0].is_real_session).toBe(true)
expect(merged[0].status).toBe('realizado')
expect(merged[0].titulo).toBe('Real')
})
it('sessão real substitui ocorrência virtual para a mesma regra+data', () => {
const occs = [
{
recurrence_id: 'rule-1',
original_date: '2026-03-02',
status: 'agendado',
is_occurrence: true,
is_real_session: false,
titulo: 'Virtual'
}
];
const storedRows = [
{
id: 'ev-real-1',
recurrence_id: 'rule-1',
recurrence_date: '2026-03-02',
status: 'realizado',
titulo: 'Real'
}
];
const merged = mergeWithStoredSessions(occs, storedRows);
expect(merged.length).toBe(1);
expect(merged[0].is_real_session).toBe(true);
expect(merged[0].status).toBe('realizado');
expect(merged[0].titulo).toBe('Real');
});
it('mantém ocorrência virtual quando não há sessão real', () => {
const occs = [{
recurrence_id: 'rule-1',
original_date: '2026-03-02',
status: 'agendado',
is_occurrence: true,
}]
const merged = mergeWithStoredSessions(occs, [])
expect(merged.length).toBe(1)
expect(merged[0].is_occurrence).toBe(true)
})
it('mantém ocorrência virtual quando não há sessão real', () => {
const occs = [
{
recurrence_id: 'rule-1',
original_date: '2026-03-02',
status: 'agendado',
is_occurrence: true
}
];
const merged = mergeWithStoredSessions(occs, []);
expect(merged.length).toBe(1);
expect(merged[0].is_occurrence).toBe(true);
});
it('adiciona sessão real órfã (sem ocorrência correspondente)', () => {
const storedRows = [{
id: 'ev-orphan',
recurrence_id: 'rule-1',
recurrence_date: '2026-03-30', // data fora do range expandido
status: 'agendado',
}]
const merged = mergeWithStoredSessions([], storedRows)
expect(merged.length).toBe(1)
expect(merged[0].is_real_session).toBe(true)
})
it('adiciona sessão real órfã (sem ocorrência correspondente)', () => {
const storedRows = [
{
id: 'ev-orphan',
recurrence_id: 'rule-1',
recurrence_date: '2026-03-30', // data fora do range expandido
status: 'agendado'
}
];
const merged = mergeWithStoredSessions([], storedRows);
expect(merged.length).toBe(1);
expect(merged[0].is_real_session).toBe(true);
});
it('não duplica quando há tanto ocorrência quanto sessão real', () => {
const occs = [
{ recurrence_id: 'rule-1', original_date: '2026-03-02', is_occurrence: true },
{ recurrence_id: 'rule-1', original_date: '2026-03-09', is_occurrence: true },
]
const stored = [
{ recurrence_id: 'rule-1', recurrence_date: '2026-03-02', status: 'realizado' }
]
const merged = mergeWithStoredSessions(occs, stored)
expect(merged.length).toBe(2)
})
})
it('não duplica quando há tanto ocorrência quanto sessão real', () => {
const occs = [
{ recurrence_id: 'rule-1', original_date: '2026-03-02', is_occurrence: true },
{ recurrence_id: 'rule-1', original_date: '2026-03-09', is_occurrence: true }
];
const stored = [{ recurrence_id: 'rule-1', recurrence_date: '2026-03-02', status: 'realizado' }];
const merged = mergeWithStoredSessions(occs, stored);
expect(merged.length).toBe(2);
});
});
@@ -14,77 +14,72 @@
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref } from 'vue'
import {
listClinicEvents,
createClinicAgendaEvento,
updateClinicAgendaEvento,
deleteClinicAgendaEvento
} from '@/features/agenda/services/agendaClinicRepository'
import { ref } from 'vue';
import { listClinicEvents, createClinicAgendaEvento, updateClinicAgendaEvento, deleteClinicAgendaEvento } from '@/features/agenda/services/agendaClinicRepository';
export function useAgendaClinicEvents () {
const loading = ref(false)
const error = ref('')
const rows = ref([])
export function useAgendaClinicEvents() {
const loading = ref(false);
const error = ref('');
const rows = ref([]);
async function loadClinicRange ({ tenantId, ownerIds, startISO, endISO }) {
loading.value = true
error.value = ''
try {
rows.value = await listClinicEvents({ tenantId, ownerIds, startISO, endISO })
} catch (e) {
error.value = e?.message || 'Falha ao carregar eventos.'
} finally {
loading.value = false
async function loadClinicRange({ tenantId, ownerIds, startISO, endISO }) {
loading.value = true;
error.value = '';
try {
rows.value = await listClinicEvents({ tenantId, ownerIds, startISO, endISO });
} catch (e) {
error.value = e?.message || 'Falha ao carregar eventos.';
} finally {
loading.value = false;
}
}
}
async function createClinic (payload, { tenantId } = {}) {
loading.value = true
error.value = ''
try {
return await createClinicAgendaEvento(payload, { tenantId })
} catch (e) {
error.value = e?.message || 'Falha ao criar evento.'
throw e
} finally {
loading.value = false
async function createClinic(payload, { tenantId } = {}) {
loading.value = true;
error.value = '';
try {
return await createClinicAgendaEvento(payload, { tenantId });
} catch (e) {
error.value = e?.message || 'Falha ao criar evento.';
throw e;
} finally {
loading.value = false;
}
}
}
async function updateClinic (id, patch, { tenantId } = {}) {
loading.value = true
error.value = ''
try {
return await updateClinicAgendaEvento(id, patch, { tenantId })
} catch (e) {
error.value = e?.message || 'Falha ao atualizar evento.'
throw e
} finally {
loading.value = false
async function updateClinic(id, patch, { tenantId } = {}) {
loading.value = true;
error.value = '';
try {
return await updateClinicAgendaEvento(id, patch, { tenantId });
} catch (e) {
error.value = e?.message || 'Falha ao atualizar evento.';
throw e;
} finally {
loading.value = false;
}
}
}
async function removeClinic (id, { tenantId } = {}) {
loading.value = true
error.value = ''
try {
return await deleteClinicAgendaEvento(id, { tenantId })
} catch (e) {
error.value = e?.message || 'Falha ao remover evento.'
throw e
} finally {
loading.value = false
async function removeClinic(id, { tenantId } = {}) {
loading.value = true;
error.value = '';
try {
return await deleteClinicAgendaEvento(id, { tenantId });
} catch (e) {
error.value = e?.message || 'Falha ao remover evento.';
throw e;
} finally {
loading.value = false;
}
}
}
return {
loading,
error,
rows,
loadClinicRange,
createClinic,
updateClinic,
removeClinic
}
}
return {
loading,
error,
rows,
loadClinicRange,
createClinic,
updateClinic,
removeClinic
};
}
@@ -14,26 +14,26 @@
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref } from 'vue'
import { listTenantStaff } from '../services/agendaRepository'
import { ref } from 'vue';
import { listTenantStaff } from '../services/agendaRepository';
export function useAgendaClinicStaff () {
const loading = ref(false)
const error = ref('')
const staff = ref([])
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
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 }
}
return { loading, error, staff, load };
}
+113 -136
View File
@@ -22,24 +22,24 @@
* Sessões com recurrence_id são sessões reais de uma série.
*/
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// ─── helpers internos ────────────────────────────────────────────────────────
function assertTenantId (tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar na agenda.')
}
function assertTenantId(tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar na agenda.');
}
}
async function getUid () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
if (!uid) throw new Error('Usuário não autenticado.')
return uid
async function getUid() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Usuário não autenticado.');
return uid;
}
const BASE_SELECT = `
@@ -56,149 +56,126 @@ const BASE_SELECT = `
determined_commitments!agenda_eventos_determined_commitment_fk (
id, bg_color, text_color
)
`.trim()
`.trim();
export function useAgendaEvents () {
const rows = ref([])
const loading = ref(false)
const error = ref(null)
export function useAgendaEvents() {
const rows = ref([]);
const loading = ref(false);
const error = ref(null);
async function loadMyRange (start, end, ownerId) {
if (!ownerId) return
async function loadMyRange(start, end, ownerId) {
if (!ownerId) return;
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
const tenantStore = useTenantStore();
const tenantId = tenantStore.activeTenantId;
assertTenantId(tenantId);
loading.value = true
error.value = null
try {
const { data, error: err } = await supabase
.from('agenda_eventos')
.select(BASE_SELECT)
.eq('tenant_id', tenantId)
.eq('owner_id', ownerId)
.is('mirror_of_event_id', null)
.gte('inicio_em', start)
.lte('inicio_em', end)
.order('inicio_em', { ascending: true })
loading.value = true;
error.value = null;
try {
const { data, error: err } = await supabase
.from('agenda_eventos')
.select(BASE_SELECT)
.eq('tenant_id', tenantId)
.eq('owner_id', ownerId)
.is('mirror_of_event_id', null)
.gte('inicio_em', start)
.lte('inicio_em', end)
.order('inicio_em', { ascending: true });
if (err) throw err
rows.value = (data || []).map(flattenRow)
} catch (e) {
error.value = e?.message || 'Erro ao carregar eventos'
rows.value = []
} finally {
loading.value = false
}
}
/**
* Cria um evento injetando tenant_id e owner_id automaticamente.
* owner_id é sempre o usuário autenticado — nunca vem do payload externo.
* tenant_id vem do tenantStore ativo — nunca do payload externo.
*/
async function create (payload) {
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
const uid = await getUid()
// eslint-disable-next-line no-unused-vars
const { paciente_id: _dropped, ...rest } = payload
const safePayload = {
...rest,
tenant_id: tenantId,
owner_id: uid,
if (err) throw err;
rows.value = (data || []).map(flattenRow);
} catch (e) {
error.value = e?.message || 'Erro ao carregar eventos';
rows.value = [];
} finally {
loading.value = false;
}
}
const { data, error: err } = await supabase
.from('agenda_eventos')
.insert([safePayload])
.select(BASE_SELECT)
.single()
if (err) throw err
return flattenRow(data)
}
/**
* Cria um evento injetando tenant_id e owner_id automaticamente.
* owner_id é sempre o usuário autenticado — nunca vem do payload externo.
* tenant_id vem do tenantStore ativo — nunca do payload externo.
*/
async function create(payload) {
const tenantStore = useTenantStore();
const tenantId = tenantStore.activeTenantId;
assertTenantId(tenantId);
async function update (id, patch) {
if (!id) throw new Error('ID inválido.')
const uid = await getUid();
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
// eslint-disable-next-line no-unused-vars
const { paciente_id: _dropped, ...rest } = payload;
const safePayload = {
...rest,
tenant_id: tenantId,
owner_id: uid
};
// eslint-disable-next-line no-unused-vars
const { paciente_id: _dropped, ...safePatch } = patch
const { data, error: err } = await supabase.from('agenda_eventos').insert([safePayload]).select(BASE_SELECT).single();
if (err) throw err;
return flattenRow(data);
}
const { data, error: err } = await supabase
.from('agenda_eventos')
.update(safePatch)
.eq('id', id)
.eq('tenant_id', tenantId)
.select(BASE_SELECT)
.single()
if (err) throw err
return flattenRow(data)
}
async function update(id, patch) {
if (!id) throw new Error('ID inválido.');
async function remove (id) {
if (!id) throw new Error('ID inválido.')
const tenantStore = useTenantStore();
const tenantId = tenantStore.activeTenantId;
assertTenantId(tenantId);
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
// eslint-disable-next-line no-unused-vars
const { paciente_id: _dropped, ...safePatch } = patch;
const { error: err } = await supabase
.from('agenda_eventos')
.delete()
.eq('id', id)
.eq('tenant_id', tenantId)
if (err) throw err
}
const { data, error: err } = await supabase.from('agenda_eventos').update(safePatch).eq('id', id).eq('tenant_id', tenantId).select(BASE_SELECT).single();
if (err) throw err;
return flattenRow(data);
}
async function removeSeriesFrom (recurrenceId, fromDateISO) {
if (!recurrenceId) throw new Error('recurrenceId inválido.')
async function remove(id) {
if (!id) throw new Error('ID inválido.');
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
const tenantStore = useTenantStore();
const tenantId = tenantStore.activeTenantId;
assertTenantId(tenantId);
const { error: err } = await supabase
.from('agenda_eventos')
.delete()
.eq('recurrence_id', recurrenceId)
.eq('tenant_id', tenantId)
.gte('recurrence_date', fromDateISO)
if (err) throw err
}
const { error: err } = await supabase.from('agenda_eventos').delete().eq('id', id).eq('tenant_id', tenantId);
if (err) throw err;
}
async function removeAllSeries (recurrenceId) {
if (!recurrenceId) throw new Error('recurrenceId inválido.')
async function removeSeriesFrom(recurrenceId, fromDateISO) {
if (!recurrenceId) throw new Error('recurrenceId inválido.');
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
const tenantStore = useTenantStore();
const tenantId = tenantStore.activeTenantId;
assertTenantId(tenantId);
const { error: err } = await supabase
.from('agenda_eventos')
.delete()
.eq('recurrence_id', recurrenceId)
.eq('tenant_id', tenantId)
if (err) throw err
}
const { error: err } = await supabase.from('agenda_eventos').delete().eq('recurrence_id', recurrenceId).eq('tenant_id', tenantId).gte('recurrence_date', fromDateISO);
if (err) throw err;
}
return { rows, loading, error, loadMyRange, create, update, remove, removeSeriesFrom, removeAllSeries }
async function removeAllSeries(recurrenceId) {
if (!recurrenceId) throw new Error('recurrenceId inválido.');
const tenantStore = useTenantStore();
const tenantId = tenantStore.activeTenantId;
assertTenantId(tenantId);
const { error: err } = await supabase.from('agenda_eventos').delete().eq('recurrence_id', recurrenceId).eq('tenant_id', tenantId);
if (err) throw err;
}
return { rows, loading, error, loadMyRange, create, update, remove, removeSeriesFrom, removeAllSeries };
}
function flattenRow (r) {
if (!r) return r
const patient = r.patients || null
const out = { ...r }
delete out.patients
out.paciente_nome = patient?.nome_completo || out.paciente_nome || ''
out.paciente_avatar = patient?.avatar_url || out.paciente_avatar || ''
out.paciente_status = patient?.status || out.paciente_status || ''
return out
}
function flattenRow(r) {
if (!r) return r;
const patient = r.patients || null;
const out = { ...r };
delete out.patients;
out.paciente_nome = patient?.nome_completo || out.paciente_nome || '';
out.paciente_avatar = patient?.avatar_url || out.paciente_avatar || '';
out.paciente_status = patient?.status || out.paciente_status || '';
return out;
}
@@ -14,33 +14,30 @@
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref } from 'vue'
import { getMyAgendaSettings, getMyWorkSchedule } from '../services/agendaRepository'
import { ref } from 'vue';
import { getMyAgendaSettings, getMyWorkSchedule } from '../services/agendaRepository';
export function useAgendaSettings () {
const loading = ref(false)
const error = ref('')
const settings = ref(null)
const workRules = ref([]) // [{ dia_semana, hora_inicio, hora_fim }]
export function useAgendaSettings() {
const loading = ref(false);
const error = ref('');
const settings = ref(null);
const workRules = ref([]); // [{ dia_semana, hora_inicio, hora_fim }]
async function load () {
loading.value = true
error.value = ''
try {
const [cfg, rules] = await Promise.all([
getMyAgendaSettings(),
getMyWorkSchedule()
])
settings.value = cfg
workRules.value = rules
} catch (e) {
error.value = e?.message || 'Falha ao carregar configurações da agenda.'
settings.value = null
workRules.value = []
} finally {
loading.value = false
async function load() {
loading.value = true;
error.value = '';
try {
const [cfg, rules] = await Promise.all([getMyAgendaSettings(), getMyWorkSchedule()]);
settings.value = cfg;
workRules.value = rules;
} catch (e) {
error.value = e?.message || 'Falha ao carregar configurações da agenda.';
settings.value = null;
workRules.value = [];
} finally {
loading.value = false;
}
}
}
return { loading, error, settings, workRules, load }
return { loading, error, settings, workRules, load };
}
@@ -25,7 +25,7 @@
// loadItemsOrTemplate(eventId, ruleId) → Array<CommitmentItem> (próprios ou template)
// propagateToSerie(ruleId, items, opts?) → void (ocorrências materializadas com services_customized=false)
import { supabase } from '@/lib/supabase/client'
import { supabase } from '@/lib/supabase/client';
// Shape interno de CommitmentItem:
// {
@@ -39,201 +39,171 @@ import { supabase } from '@/lib/supabase/client'
// }
/** Mapeia uma linha do banco para CommitmentItem (compartilhado entre commitment_services e recurrence_rule_services) */
function _mapRow (r) {
return {
service_id: r.service_id,
service_name: r.services?.name ?? '',
quantity: Number(r.quantity),
unit_price: Number(r.unit_price),
discount_pct: Number(r.discount_pct ?? 0),
discount_flat: Number(r.discount_flat ?? 0),
final_price: Number(r.final_price),
}
function _mapRow(r) {
return {
service_id: r.service_id,
service_name: r.services?.name ?? '',
quantity: Number(r.quantity),
unit_price: Number(r.unit_price),
discount_pct: Number(r.discount_pct ?? 0),
discount_flat: Number(r.discount_flat ?? 0),
final_price: Number(r.final_price)
};
}
export function useCommitmentServices () {
export function useCommitmentServices() {
// ── Carregar itens de um evento ──────────────────────────────────────
async function loadItems(eventId) {
if (!eventId) return [];
// ── Carregar itens de um evento ──────────────────────────────────────
async function loadItems (eventId) {
if (!eventId) return []
const { data, error } = await supabase.from('commitment_services').select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)').eq('commitment_id', eventId).order('created_at', { ascending: true });
const { data, error } = await supabase
.from('commitment_services')
.select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)')
.eq('commitment_id', eventId)
.order('created_at', { ascending: true })
if (error) throw error
return (data || []).map(_mapRow)
}
// ── Salvar itens de um evento ────────────────────────────────────────
// Estratégia: DELETE dos itens existentes + INSERT dos novos.
// Garante idempotência em edições sem risco de duplicatas.
//
// opts.markCustomized = true: após salvar, marca services_customized = true
// no agenda_eventos correspondente, impedindo que edições do evento raiz
// sobrescrevam os serviços desta ocorrência individual.
async function saveItems (eventId, items, { markCustomized = false } = {}) {
if (!eventId) throw new Error('eventId é obrigatório para salvar commitment_services.')
// 1. Remove itens existentes deste evento
const { error: deleteError } = await supabase
.from('commitment_services')
.delete()
.eq('commitment_id', eventId)
if (deleteError) throw deleteError
// 2. Insere os novos itens (se houver)
if (items?.length) {
const rows = items.map(item => ({
commitment_id: eventId,
service_id: item.service_id,
quantity: item.quantity,
unit_price: item.unit_price,
discount_pct: item.discount_pct ?? 0,
discount_flat: item.discount_flat ?? 0,
final_price: item.final_price,
}))
const { error: insertError } = await supabase
.from('commitment_services')
.insert(rows)
if (insertError) throw insertError
if (error) throw error;
return (data || []).map(_mapRow);
}
// 3. Marca a ocorrência como customizada (impede sobrescrita por edições do raiz)
if (markCustomized) {
const { error: updateError } = await supabase
.from('agenda_eventos')
.update({ services_customized: true })
.eq('id', eventId)
// ── Salvar itens de um evento ────────────────────────────────────────
// Estratégia: DELETE dos itens existentes + INSERT dos novos.
// Garante idempotência em edições sem risco de duplicatas.
//
// opts.markCustomized = true: após salvar, marca services_customized = true
// no agenda_eventos correspondente, impedindo que edições do evento raiz
// sobrescrevam os serviços desta ocorrência individual.
async function saveItems(eventId, items, { markCustomized = false } = {}) {
if (!eventId) throw new Error('eventId é obrigatório para salvar commitment_services.');
if (updateError) throw updateError
}
}
// 1. Remove itens existentes deste evento
const { error: deleteError } = await supabase.from('commitment_services').delete().eq('commitment_id', eventId);
// ── Carregar template de serviços de uma regra ───────────────────────
// Retorna os itens armazenados em recurrence_rule_services para a regra.
async function loadRuleItems (ruleId) {
if (!ruleId) return []
if (deleteError) throw deleteError;
const { data, error } = await supabase
.from('recurrence_rule_services')
.select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)')
.eq('rule_id', ruleId)
.order('created_at', { ascending: true })
// 2. Insere os novos itens (se houver)
if (items?.length) {
const rows = items.map((item) => ({
commitment_id: eventId,
service_id: item.service_id,
quantity: item.quantity,
unit_price: item.unit_price,
discount_pct: item.discount_pct ?? 0,
discount_flat: item.discount_flat ?? 0,
final_price: item.final_price
}));
if (error) throw error
return (data || []).map(_mapRow)
}
const { error: insertError } = await supabase.from('commitment_services').insert(rows);
// ── Salvar template de serviços de uma regra ─────────────────────────
// Estratégia: DELETE + INSERT — mesmo padrão de saveItems.
// Chamado ao criar uma recorrência com serviços ou ao editar o evento
// raiz com escopo 'todos' / 'este_e_seguintes'.
async function saveRuleItems (ruleId, items) {
if (!ruleId) throw new Error('ruleId é obrigatório para salvar recurrence_rule_services.')
if (insertError) throw insertError;
}
const { error: deleteError } = await supabase
.from('recurrence_rule_services')
.delete()
.eq('rule_id', ruleId)
// 3. Marca a ocorrência como customizada (impede sobrescrita por edições do raiz)
if (markCustomized) {
const { error: updateError } = await supabase.from('agenda_eventos').update({ services_customized: true }).eq('id', eventId);
if (deleteError) throw deleteError
if (!items?.length) return
const rows = items.map(item => ({
rule_id: ruleId,
service_id: item.service_id,
quantity: item.quantity,
unit_price: item.unit_price,
discount_pct: item.discount_pct ?? 0,
discount_flat: item.discount_flat ?? 0,
final_price: item.final_price,
}))
const { error: insertError } = await supabase
.from('recurrence_rule_services')
.insert(rows)
if (insertError) throw insertError
}
// ── Carregar itens próprios ou herdar template da regra ──────────────
// Retorna os commitment_services do evento se existirem.
// Se o evento não tiver itens próprios e ruleId for fornecido,
// retorna o template da regra (ocorrência ainda não customizada).
async function loadItemsOrTemplate (eventId, ruleId, { allowEmpty = false } = {}) {
const own = await loadItems(eventId)
if (own.length > 0) return own
if (allowEmpty) return []
if (ruleId) return loadRuleItems(ruleId)
return []
}
// ── Propagar itens para ocorrências materializadas da série ──────────
// Atualiza commitment_services nos agenda_eventos com recurrence_id = ruleId
// onde services_customized = false (não foram editados individualmente).
//
// opts.fromDate: string ISO 'YYYY-MM-DD' — limita a ocorrências a partir
// dessa data inclusive (escopo 'este_e_seguintes'). null = todas da série.
async function propagateToSerie (ruleId, items, { fromDate = null, ignoreCustomized = false } = {}) {
if (!ruleId) return
// Busca IDs das ocorrências materializadas elegíveis
let q = supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', ruleId)
if (!ignoreCustomized) {
q = q.eq('services_customized', false)
if (updateError) throw updateError;
}
}
if (fromDate) {
q = q.gte('inicio_em', fromDate)
// ── Carregar template de serviços de uma regra ───────────────────────
// Retorna os itens armazenados em recurrence_rule_services para a regra.
async function loadRuleItems(ruleId) {
if (!ruleId) return [];
const { data, error } = await supabase.from('recurrence_rule_services').select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)').eq('rule_id', ruleId).order('created_at', { ascending: true });
if (error) throw error;
return (data || []).map(_mapRow);
}
const { data: events, error: queryError } = await q
if (queryError) throw queryError
if (!events?.length) return
// ── Salvar template de serviços de uma regra ─────────────────────────
// Estratégia: DELETE + INSERT — mesmo padrão de saveItems.
// Chamado ao criar uma recorrência com serviços ou ao editar o evento
// raiz com escopo 'todos' / 'este_e_seguintes'.
async function saveRuleItems(ruleId, items) {
if (!ruleId) throw new Error('ruleId é obrigatório para salvar recurrence_rule_services.');
// Para cada evento elegível: delete + insert (padrão idempotente)
for (const ev of events) {
const { error: delErr } = await supabase
.from('commitment_services')
.delete()
.eq('commitment_id', ev.id)
if (delErr) throw delErr
const { error: deleteError } = await supabase.from('recurrence_rule_services').delete().eq('rule_id', ruleId);
if (items?.length) {
const rows = items.map(item => ({
commitment_id: ev.id,
service_id: item.service_id,
quantity: item.quantity,
unit_price: item.unit_price,
discount_pct: item.discount_pct ?? 0,
discount_flat: item.discount_flat ?? 0,
final_price: item.final_price,
}))
const { error: insErr } = await supabase
.from('commitment_services')
.insert(rows)
if (insErr) throw insErr
}
if (deleteError) throw deleteError;
if (!items?.length) return;
const rows = items.map((item) => ({
rule_id: ruleId,
service_id: item.service_id,
quantity: item.quantity,
unit_price: item.unit_price,
discount_pct: item.discount_pct ?? 0,
discount_flat: item.discount_flat ?? 0,
final_price: item.final_price
}));
const { error: insertError } = await supabase.from('recurrence_rule_services').insert(rows);
if (insertError) throw insertError;
}
}
return {
loadItems,
saveItems,
loadRuleItems,
saveRuleItems,
loadItemsOrTemplate,
propagateToSerie,
}
// ── Carregar itens próprios ou herdar template da regra ──────────────
// Retorna os commitment_services do evento se existirem.
// Se o evento não tiver itens próprios e ruleId for fornecido,
// retorna o template da regra (ocorrência ainda não customizada).
async function loadItemsOrTemplate(eventId, ruleId, { allowEmpty = false } = {}) {
const own = await loadItems(eventId);
if (own.length > 0) return own;
if (allowEmpty) return [];
if (ruleId) return loadRuleItems(ruleId);
return [];
}
// ── Propagar itens para ocorrências materializadas da série ──────────
// Atualiza commitment_services nos agenda_eventos com recurrence_id = ruleId
// onde services_customized = false (não foram editados individualmente).
//
// opts.fromDate: string ISO 'YYYY-MM-DD' — limita a ocorrências a partir
// dessa data inclusive (escopo 'este_e_seguintes'). null = todas da série.
async function propagateToSerie(ruleId, items, { fromDate = null, ignoreCustomized = false } = {}) {
if (!ruleId) return;
// Busca IDs das ocorrências materializadas elegíveis
let q = supabase.from('agenda_eventos').select('id').eq('recurrence_id', ruleId);
if (!ignoreCustomized) {
q = q.eq('services_customized', false);
}
if (fromDate) {
q = q.gte('inicio_em', fromDate);
}
const { data: events, error: queryError } = await q;
if (queryError) throw queryError;
if (!events?.length) return;
// Para cada evento elegível: delete + insert (padrão idempotente)
for (const ev of events) {
const { error: delErr } = await supabase.from('commitment_services').delete().eq('commitment_id', ev.id);
if (delErr) throw delErr;
if (items?.length) {
const rows = items.map((item) => ({
commitment_id: ev.id,
service_id: item.service_id,
quantity: item.quantity,
unit_price: item.unit_price,
discount_pct: item.discount_pct ?? 0,
discount_flat: item.discount_flat ?? 0,
final_price: item.final_price
}));
const { error: insErr } = await supabase.from('commitment_services').insert(rows);
if (insErr) throw insErr;
}
}
}
return {
loadItems,
saveItems,
loadRuleItems,
saveRuleItems,
loadItemsOrTemplate,
propagateToSerie
};
}
@@ -14,48 +14,48 @@
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { computed, ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { computed, ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function useDeterminedCommitments (tenantIdRef) {
const loading = ref(false)
const error = ref('')
const rows = ref([])
export function useDeterminedCommitments(tenantIdRef) {
const loading = ref(false);
const error = ref('');
const rows = ref([]);
const tenantId = computed(() => {
const v = tenantIdRef?.value ?? tenantIdRef
return v ? String(v) : ''
})
const tenantId = computed(() => {
const v = tenantIdRef?.value ?? tenantIdRef;
return v ? String(v) : '';
});
async function load () {
try {
if (!tenantId.value) {
rows.value = []
error.value = ''
return
}
if (loading.value) return
async function load() {
try {
if (!tenantId.value) {
rows.value = [];
error.value = '';
return;
}
if (loading.value) return;
loading.value = true
error.value = ''
loading.value = true;
error.value = '';
const { data, error: err } = await supabase
.from('determined_commitments')
.select('id,tenant_id,created_by,is_native,native_key,is_locked,active,name,description,bg_color,text_color,created_at,determined_commitment_fields(id,key,label,field_type,required,sort_order)')
.eq('tenant_id', tenantId.value) // ✅ SOMENTE tenant corrente
.eq('active', true)
.order('is_native', { ascending: false })
.order('name', { ascending: true })
const { data, error: err } = await supabase
.from('determined_commitments')
.select('id,tenant_id,created_by,is_native,native_key,is_locked,active,name,description,bg_color,text_color,created_at,determined_commitment_fields(id,key,label,field_type,required,sort_order)')
.eq('tenant_id', tenantId.value) // ✅ SOMENTE tenant corrente
.eq('active', true)
.order('is_native', { ascending: false })
.order('name', { ascending: true });
if (err) throw err
rows.value = data || []
} catch (e) {
error.value = e?.message || 'Falha ao carregar compromissos determinísticos.'
rows.value = []
} finally {
loading.value = false
if (err) throw err;
rows.value = data || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar compromissos determinísticos.';
rows.value = [];
} finally {
loading.value = false;
}
}
}
return { loading, error, rows, load }
}
return { loading, error, rows, load };
}
@@ -25,93 +25,83 @@
// save(payload) cria ou atualiza (id presente = update dos campos editáveis)
// remove(id) hard delete (apenas registros do próprio owner)
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function useFinancialExceptions () {
const exceptions = ref([])
const loading = ref(false)
const error = ref('')
export function useFinancialExceptions() {
const exceptions = ref([]);
const loading = ref(false);
const error = ref('');
// ── Carregar exceções do owner + regras globais da clínica ───────────
async function load (ownerId) {
if (!ownerId) return
loading.value = true
error.value = ''
try {
const { data, error: err } = await supabase
.from('financial_exceptions')
.select('*')
.or(`owner_id.eq.${ownerId},owner_id.is.null`)
.order('exception_type', { ascending: true })
.order('created_at', { ascending: true })
// ── Carregar exceções do owner + regras globais da clínica ───────────
async function load(ownerId) {
if (!ownerId) return;
loading.value = true;
error.value = '';
try {
const { data, error: err } = await supabase.from('financial_exceptions').select('*').or(`owner_id.eq.${ownerId},owner_id.is.null`).order('exception_type', { ascending: true }).order('created_at', { ascending: true });
if (err) throw err
exceptions.value = data || []
} catch (e) {
error.value = e?.message || 'Falha ao carregar exceções financeiras.'
exceptions.value = []
} finally {
loading.value = false
if (err) throw err;
exceptions.value = data || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar exceções financeiras.';
exceptions.value = [];
} finally {
loading.value = false;
}
}
}
// ── Criar ou atualizar uma exceção ───────────────────────────────────
// Para UPDATE, apenas os campos editáveis são enviados:
// charge_mode, charge_value, charge_pct, min_hours_notice
// Regras globais (owner_id IS NULL) não devem ser editadas — o chamador
// é responsável por não chamar save() nesses registros.
async function save (payload) {
error.value = ''
try {
if (payload.id) {
const { error: err } = await supabase
.from('financial_exceptions')
.update({
charge_mode: payload.charge_mode,
charge_value: payload.charge_value ?? null,
charge_pct: payload.charge_pct ?? null,
min_hours_notice: payload.min_hours_notice ?? null,
})
.eq('id', payload.id)
if (err) throw err
} else {
const { error: err } = await supabase
.from('financial_exceptions')
.insert({
owner_id: payload.owner_id,
tenant_id: payload.tenant_id ?? null,
exception_type: payload.exception_type,
charge_mode: payload.charge_mode,
charge_value: payload.charge_value ?? null,
charge_pct: payload.charge_pct ?? null,
min_hours_notice: payload.min_hours_notice ?? null,
})
if (err) throw err
}
} catch (e) {
error.value = e?.message || 'Falha ao salvar exceção financeira.'
throw e
// ── Criar ou atualizar uma exceção ───────────────────────────────────
// Para UPDATE, apenas os campos editáveis são enviados:
// charge_mode, charge_value, charge_pct, min_hours_notice
// Regras globais (owner_id IS NULL) não devem ser editadas — o chamador
// é responsável por não chamar save() nesses registros.
async function save(payload) {
error.value = '';
try {
if (payload.id) {
const { error: err } = await supabase
.from('financial_exceptions')
.update({
charge_mode: payload.charge_mode,
charge_value: payload.charge_value ?? null,
charge_pct: payload.charge_pct ?? null,
min_hours_notice: payload.min_hours_notice ?? null
})
.eq('id', payload.id);
if (err) throw err;
} else {
const { error: err } = await supabase.from('financial_exceptions').insert({
owner_id: payload.owner_id,
tenant_id: payload.tenant_id ?? null,
exception_type: payload.exception_type,
charge_mode: payload.charge_mode,
charge_value: payload.charge_value ?? null,
charge_pct: payload.charge_pct ?? null,
min_hours_notice: payload.min_hours_notice ?? null
});
if (err) throw err;
}
} catch (e) {
error.value = e?.message || 'Falha ao salvar exceção financeira.';
throw e;
}
}
}
// ── Hard delete — apenas registros do próprio owner ──────────────────
// Regras globais (owner_id IS NULL) são protegidas pelo RLS do banco;
// a UI também deve esconder o botão de remover nesses casos.
async function remove (id) {
error.value = ''
try {
const { error: err } = await supabase
.from('financial_exceptions')
.delete()
.eq('id', id)
if (err) throw err
exceptions.value = exceptions.value.filter(e => e.id !== id)
} catch (e) {
error.value = e?.message || 'Falha ao remover exceção financeira.'
throw e
// ── Hard delete — apenas registros do próprio owner ──────────────────
// Regras globais (owner_id IS NULL) são protegidas pelo RLS do banco;
// a UI também deve esconder o botão de remover nesses casos.
async function remove(id) {
error.value = '';
try {
const { error: err } = await supabase.from('financial_exceptions').delete().eq('id', id);
if (err) throw err;
exceptions.value = exceptions.value.filter((e) => e.id !== id);
} catch (e) {
error.value = e?.message || 'Falha ao remover exceção financeira.';
throw e;
}
}
}
return { exceptions, loading, error, load, save, remove }
return { exceptions, loading, error, load, save, remove };
}
@@ -27,175 +27,166 @@
// togglePlanService(id, active) alterna active do procedimento
// removePlanService(id) DELETE definitivo do procedimento
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function useInsurancePlans () {
const plans = ref([])
const loading = ref(false)
const error = ref(null)
export function useInsurancePlans() {
const plans = ref([]);
const loading = ref(false);
const error = ref(null);
async function load (ownerId) {
if (!ownerId) return
loading.value = true
error.value = null
try {
const { data, error: err } = await supabase
.from('insurance_plans')
.select(`
async function load(ownerId) {
if (!ownerId) return;
loading.value = true;
error.value = null;
try {
const { data, error: err } = await supabase
.from('insurance_plans')
.select(
`
*,
insurance_plan_services (
id, name, value, active
)
`)
.eq('owner_id', ownerId)
.order('name')
if (err) throw err
plans.value = data || []
} catch (e) {
error.value = e?.message || 'Erro ao carregar convênios'
plans.value = []
} finally {
loading.value = false
`
)
.eq('owner_id', ownerId)
.order('name');
if (err) throw err;
plans.value = data || [];
} catch (e) {
error.value = e?.message || 'Erro ao carregar convênios';
plans.value = [];
} finally {
loading.value = false;
}
}
}
async function save (payload) {
error.value = null
try {
if (payload.id) {
const { error: err } = await supabase
.from('insurance_plans')
.update({
name: payload.name,
notes: payload.notes || null,
updated_at: new Date().toISOString(),
})
.eq('id', payload.id)
if (err) throw err
} else {
const { error: err } = await supabase
.from('insurance_plans')
.insert({
owner_id: payload.owner_id,
tenant_id: payload.tenant_id,
name: payload.name,
notes: payload.notes || null,
})
if (err) throw err
}
} catch (e) {
error.value = e?.message || 'Erro ao salvar convênio'
throw e
async function save(payload) {
error.value = null;
try {
if (payload.id) {
const { error: err } = await supabase
.from('insurance_plans')
.update({
name: payload.name,
notes: payload.notes || null,
updated_at: new Date().toISOString()
})
.eq('id', payload.id);
if (err) throw err;
} else {
const { error: err } = await supabase.from('insurance_plans').insert({
owner_id: payload.owner_id,
tenant_id: payload.tenant_id,
name: payload.name,
notes: payload.notes || null
});
if (err) throw err;
}
} catch (e) {
error.value = e?.message || 'Erro ao salvar convênio';
throw e;
}
}
}
async function toggle (id, active) {
error.value = null
try {
const { error: err } = await supabase
.from('insurance_plans')
.update({ active })
.eq('id', id)
if (err) throw err
const plan = plans.value.find(p => p.id === id)
if (plan) plan.active = active
} catch (e) {
error.value = e?.message || 'Erro ao atualizar convênio'
throw e
async function toggle(id, active) {
error.value = null;
try {
const { error: err } = await supabase.from('insurance_plans').update({ active }).eq('id', id);
if (err) throw err;
const plan = plans.value.find((p) => p.id === id);
if (plan) plan.active = active;
} catch (e) {
error.value = e?.message || 'Erro ao atualizar convênio';
throw e;
}
}
}
async function remove (id) {
error.value = null
try {
const { error: err } = await supabase
.from('insurance_plans')
.update({ active: false })
.eq('id', id)
if (err) throw err
const plan = plans.value.find(p => p.id === id)
if (plan) plan.active = false
} catch (e) {
error.value = e?.message || 'Erro ao remover convênio'
throw e
async function remove(id) {
error.value = null;
try {
const { error: err } = await supabase.from('insurance_plans').update({ active: false }).eq('id', id);
if (err) throw err;
const plan = plans.value.find((p) => p.id === id);
if (plan) plan.active = false;
} catch (e) {
error.value = e?.message || 'Erro ao remover convênio';
throw e;
}
}
}
async function savePlanService (payload) {
error.value = null
try {
if (payload.id) {
const { error: err } = await supabase
.from('insurance_plan_services')
.update({
name: payload.name,
value: payload.value,
})
.eq('id', payload.id)
if (err) throw err
} else {
const { error: err } = await supabase
.from('insurance_plan_services')
.insert({
insurance_plan_id: payload.insurance_plan_id,
name: payload.name,
value: payload.value,
})
if (err) throw err
}
} catch (e) {
error.value = e?.message || 'Erro ao salvar procedimento'
throw e
async function savePlanService(payload) {
error.value = null;
try {
if (payload.id) {
const { error: err } = await supabase
.from('insurance_plan_services')
.update({
name: payload.name,
value: payload.value
})
.eq('id', payload.id);
if (err) throw err;
} else {
const { error: err } = await supabase.from('insurance_plan_services').insert({
insurance_plan_id: payload.insurance_plan_id,
name: payload.name,
value: payload.value
});
if (err) throw err;
}
} catch (e) {
error.value = e?.message || 'Erro ao salvar procedimento';
throw e;
}
}
}
async function togglePlanService (id, active) {
error.value = null
try {
const { error: err } = await supabase
.from('insurance_plan_services')
.update({ active })
.eq('id', id)
if (err) throw err
} catch (e) {
error.value = e?.message || 'Erro ao atualizar procedimento'
throw e
async function togglePlanService(id, active) {
error.value = null;
try {
const { error: err } = await supabase.from('insurance_plan_services').update({ active }).eq('id', id);
if (err) throw err;
} catch (e) {
error.value = e?.message || 'Erro ao atualizar procedimento';
throw e;
}
}
}
async function removeDefinitivo (id) {
error.value = null
try {
const { error: err } = await supabase
.from('insurance_plans')
.delete()
.eq('id', id)
if (err) throw err
plans.value = plans.value.filter(p => p.id !== id)
} catch (e) {
error.value = e?.message || 'Erro ao remover convênio'
throw e
async function removeDefinitivo(id) {
error.value = null;
try {
const { error: err } = await supabase.from('insurance_plans').delete().eq('id', id);
if (err) throw err;
plans.value = plans.value.filter((p) => p.id !== id);
} catch (e) {
error.value = e?.message || 'Erro ao remover convênio';
throw e;
}
}
}
async function removePlanService (id) {
error.value = null
try {
const { error: err } = await supabase
.from('insurance_plan_services')
.delete()
.eq('id', id)
if (err) throw err
} catch (e) {
error.value = e?.message || 'Erro ao remover procedimento'
throw e
async function removePlanService(id) {
error.value = null;
try {
const { error: err } = await supabase.from('insurance_plan_services').delete().eq('id', id);
if (err) throw err;
} catch (e) {
error.value = e?.message || 'Erro ao remover procedimento';
throw e;
}
}
}
return {
plans, loading, error,
load, save, toggle, remove, removeDefinitivo,
savePlanService, togglePlanService, removePlanService,
}
return {
plans,
loading,
error,
load,
save,
toggle,
remove,
removeDefinitivo,
savePlanService,
togglePlanService,
removePlanService
};
}
@@ -26,107 +26,94 @@
// remove(id) soft-delete (active = false)
// loadActive(ownerId, patientId) desconto ativo vigente para um paciente
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function usePatientDiscounts () {
const discounts = ref([])
const loading = ref(false)
const error = ref('')
export function usePatientDiscounts() {
const discounts = ref([]);
const loading = ref(false);
const error = ref('');
// ── Carregar todos os descontos do owner ─────────────────────────────
async function load (ownerId) {
if (!ownerId) return
loading.value = true
error.value = ''
try {
const { data, error: err } = await supabase
.from('patient_discounts')
.select('*')
.eq('owner_id', ownerId)
.order('created_at', { ascending: false })
// ── Carregar todos os descontos do owner ─────────────────────────────
async function load(ownerId) {
if (!ownerId) return;
loading.value = true;
error.value = '';
try {
const { data, error: err } = await supabase.from('patient_discounts').select('*').eq('owner_id', ownerId).order('created_at', { ascending: false });
if (err) throw err
discounts.value = data || []
} catch (e) {
error.value = e?.message || 'Falha ao carregar descontos.'
discounts.value = []
} finally {
loading.value = false
if (err) throw err;
discounts.value = data || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar descontos.';
discounts.value = [];
} finally {
loading.value = false;
}
}
}
// ── Criar ou atualizar um desconto ───────────────────────────────────
// payload deve conter: { owner_id, tenant_id, patient_id, discount_pct, discount_flat, ... }
// Se payload.id estiver presente, faz UPDATE; caso contrário, INSERT.
async function save (payload) {
error.value = ''
try {
if (payload.id) {
const { id, owner_id, tenant_id, ...fields } = payload
const { error: err } = await supabase
.from('patient_discounts')
.update(fields)
.eq('id', id)
.eq('owner_id', owner_id)
if (err) throw err
} else {
const { error: err } = await supabase
.from('patient_discounts')
.insert(payload)
if (err) throw err
}
} catch (e) {
error.value = e?.message || 'Falha ao salvar desconto.'
throw e
// ── Criar ou atualizar um desconto ───────────────────────────────────
// payload deve conter: { owner_id, tenant_id, patient_id, discount_pct, discount_flat, ... }
// Se payload.id estiver presente, faz UPDATE; caso contrário, INSERT.
async function save(payload) {
error.value = '';
try {
if (payload.id) {
const { id, owner_id, tenant_id, ...fields } = payload;
const { error: err } = await supabase.from('patient_discounts').update(fields).eq('id', id).eq('owner_id', owner_id);
if (err) throw err;
} else {
const { error: err } = await supabase.from('patient_discounts').insert(payload);
if (err) throw err;
}
} catch (e) {
error.value = e?.message || 'Falha ao salvar desconto.';
throw e;
}
}
}
// ── Soft-delete: marca active = false ───────────────────────────────
async function remove (id) {
error.value = ''
try {
const { error: err } = await supabase
.from('patient_discounts')
.update({ active: false })
.eq('id', id)
if (err) throw err
discounts.value = discounts.value.filter(d => d.id !== id)
} catch (e) {
error.value = e?.message || 'Falha ao desativar desconto.'
throw e
// ── Soft-delete: marca active = false ───────────────────────────────
async function remove(id) {
error.value = '';
try {
const { error: err } = await supabase.from('patient_discounts').update({ active: false }).eq('id', id);
if (err) throw err;
discounts.value = discounts.value.filter((d) => d.id !== id);
} catch (e) {
error.value = e?.message || 'Falha ao desativar desconto.';
throw e;
}
}
}
// ── Desconto ativo vigente para um paciente específico ───────────────
// Retorna o primeiro registro que satisfaz:
// active = true
// active_from IS NULL OR active_from <= now()
// active_to IS NULL OR active_to >= now()
// Ordenado por created_at DESC (mais recente tem precedência).
async function loadActive (ownerId, patientId) {
if (!ownerId || !patientId) return null
try {
const now = new Date().toISOString()
const { data, error: err } = await supabase
.from('patient_discounts')
.select('*')
.eq('owner_id', ownerId)
.eq('patient_id', patientId)
.eq('active', true)
.or(`active_from.is.null,active_from.lte.${now}`)
.or(`active_to.is.null,active_to.gte.${now}`)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
// ── Desconto ativo vigente para um paciente específico ───────────────
// Retorna o primeiro registro que satisfaz:
// active = true
// active_from IS NULL OR active_from <= now()
// active_to IS NULL OR active_to >= now()
// Ordenado por created_at DESC (mais recente tem precedência).
async function loadActive(ownerId, patientId) {
if (!ownerId || !patientId) return null;
try {
const now = new Date().toISOString();
const { data, error: err } = await supabase
.from('patient_discounts')
.select('*')
.eq('owner_id', ownerId)
.eq('patient_id', patientId)
.eq('active', true)
.or(`active_from.is.null,active_from.lte.${now}`)
.or(`active_to.is.null,active_to.gte.${now}`)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (err) throw err
return data || null
} catch (e) {
console.warn('[usePatientDiscounts] loadActive error:', e?.message)
return null
if (err) throw err;
return data || null;
} catch (e) {
console.warn('[usePatientDiscounts] loadActive error:', e?.message);
return null;
}
}
}
return { discounts, loading, error, load, save, remove, loadActive }
return { discounts, loading, error, load, save, remove, loadActive };
}
@@ -20,52 +20,49 @@
// null = commitment_id
// Regra: lookup exato → fallback NULL → null se nenhum configurado
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function useProfessionalPricing () {
const rows = ref([]) // professional_pricing rows
const loading = ref(false)
const error = ref('')
export function useProfessionalPricing() {
const rows = ref([]); // professional_pricing rows
const loading = ref(false);
const error = ref('');
// ── Carregar todos os preços do owner ──────────────────────────────
async function load (ownerId) {
if (!ownerId) return
loading.value = true
error.value = ''
try {
const { data, error: err } = await supabase
.from('professional_pricing')
.select('id, determined_commitment_id, price, notes')
.eq('owner_id', ownerId)
// ── Carregar todos os preços do owner ──────────────────────────────
async function load(ownerId) {
if (!ownerId) return;
loading.value = true;
error.value = '';
try {
const { data, error: err } = await supabase.from('professional_pricing').select('id, determined_commitment_id, price, notes').eq('owner_id', ownerId);
if (err) throw err
rows.value = data || []
} catch (e) {
error.value = e?.message || 'Falha ao carregar precificação.'
rows.value = []
} finally {
loading.value = false
}
}
// ── Consulta: preço para um tipo de compromisso ────────────────────
// 1. Linha com determined_commitment_id === commitmentId
// 2. Fallback: linha com determined_commitment_id === null (preço padrão)
// 3. null se nada configurado
function getPriceFor (commitmentId) {
if (!rows.value.length) return null
// match exato
if (commitmentId) {
const exact = rows.value.find(r => r.determined_commitment_id === commitmentId)
if (exact && exact.price != null) return Number(exact.price)
if (err) throw err;
rows.value = data || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar precificação.';
rows.value = [];
} finally {
loading.value = false;
}
}
// fallback padrão (commitment_id IS NULL)
const def = rows.value.find(r => r.determined_commitment_id === null)
return def && def.price != null ? Number(def.price) : null
}
// ── Consulta: preço para um tipo de compromisso ────────────────────
// 1. Linha com determined_commitment_id === commitmentId
// 2. Fallback: linha com determined_commitment_id === null (preço padrão)
// 3. null se nada configurado
function getPriceFor(commitmentId) {
if (!rows.value.length) return null;
return { rows, loading, error, load, getPriceFor }
// match exato
if (commitmentId) {
const exact = rows.value.find((r) => r.determined_commitment_id === commitmentId);
if (exact && exact.price != null) return Number(exact.price);
}
// fallback padrão (commitment_id IS NULL)
const def = rows.value.find((r) => r.determined_commitment_id === null);
return def && def.price != null ? Number(def.price) : null;
}
return { rows, loading, error, load, getPriceFor };
}
File diff suppressed because it is too large Load Diff
+72 -88
View File
@@ -26,106 +26,90 @@
// getDefaultPrice() preço do primeiro serviço ativo, ou null
// getPriceFor(id) preço de um serviço específico, ou null
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function useServices () {
const services = ref([])
const loading = ref(false)
const error = ref('')
export function useServices() {
const services = ref([]);
const loading = ref(false);
const error = ref('');
async function load (ownerId) {
if (!ownerId) return
loading.value = true
error.value = ''
try {
const { data, error: err } = await supabase
.from('services')
.select('id, name, description, price, duration_min, active')
.eq('owner_id', ownerId)
.order('created_at', { ascending: true })
async function load(ownerId) {
if (!ownerId) return;
loading.value = true;
error.value = '';
try {
const { data, error: err } = await supabase.from('services').select('id, name, description, price, duration_min, active').eq('owner_id', ownerId).order('created_at', { ascending: true });
if (err) throw err
services.value = data || []
} catch (e) {
error.value = e?.message || 'Falha ao carregar serviços.'
services.value = []
} finally {
loading.value = false
if (err) throw err;
services.value = data || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar serviços.';
services.value = [];
} finally {
loading.value = false;
}
}
}
async function save (payload) {
error.value = ''
try {
if (payload.id) {
const { id, owner_id, tenant_id, ...fields } = payload
const { error: err } = await supabase
.from('services')
.update(fields)
.eq('id', id)
.eq('owner_id', owner_id)
if (err) throw err
} else {
const { error: err } = await supabase
.from('services')
.insert(payload)
if (err) throw err
}
} catch (e) {
error.value = e?.message || 'Falha ao salvar serviço.'
throw e
async function save(payload) {
error.value = '';
try {
if (payload.id) {
const { id, owner_id, tenant_id, ...fields } = payload;
const { error: err } = await supabase.from('services').update(fields).eq('id', id).eq('owner_id', owner_id);
if (err) throw err;
} else {
const { error: err } = await supabase.from('services').insert(payload);
if (err) throw err;
}
} catch (e) {
error.value = e?.message || 'Falha ao salvar serviço.';
throw e;
}
}
}
async function toggle (id, active) {
error.value = ''
try {
const { error: err } = await supabase
.from('services')
.update({ active })
.eq('id', id)
if (err) throw err
const svc = services.value.find(s => s.id === id)
if (svc) svc.active = active
} catch (e) {
error.value = e?.message || 'Falha ao atualizar serviço.'
throw e
async function toggle(id, active) {
error.value = '';
try {
const { error: err } = await supabase.from('services').update({ active }).eq('id', id);
if (err) throw err;
const svc = services.value.find((s) => s.id === id);
if (svc) svc.active = active;
} catch (e) {
error.value = e?.message || 'Falha ao atualizar serviço.';
throw e;
}
}
}
async function remove (id) {
error.value = ''
try {
const { error: err } = await supabase
.from('services')
.delete()
.eq('id', id)
if (err) throw err
services.value = services.value.filter(s => s.id !== id)
} catch (e) {
const msg = String(e?.message || '')
if (msg.includes('commitment_services_service_id_fkey') || msg.includes('violates foreign key constraint')) {
error.value = 'Este serviço está vinculado a sessões e não pode ser removido. Use Desativar para ocultá-lo.'
} else {
error.value = e?.message || 'Falha ao remover serviço.'
}
throw e
async function remove(id) {
error.value = '';
try {
const { error: err } = await supabase.from('services').delete().eq('id', id);
if (err) throw err;
services.value = services.value.filter((s) => s.id !== id);
} catch (e) {
const msg = String(e?.message || '');
if (msg.includes('commitment_services_service_id_fkey') || msg.includes('violates foreign key constraint')) {
error.value = 'Este serviço está vinculado a sessões e não pode ser removido. Use Desativar para ocultá-lo.';
} else {
error.value = e?.message || 'Falha ao remover serviço.';
}
throw e;
}
}
}
function getDefaultPrice (serviceId) {
if (serviceId) {
const svc = services.value.find(s => s.id === serviceId)
return svc?.price != null ? Number(svc.price) : null
function getDefaultPrice(serviceId) {
if (serviceId) {
const svc = services.value.find((s) => s.id === serviceId);
return svc?.price != null ? Number(svc.price) : null;
}
const first = services.value.find((s) => s.active);
return first?.price != null ? Number(first.price) : null;
}
const first = services.value.find(s => s.active)
return first?.price != null ? Number(first.price) : null
}
function getPriceFor (serviceId) {
return getDefaultPrice(serviceId)
}
function getPriceFor(serviceId) {
return getDefaultPrice(serviceId);
}
return { services, loading, error, load, save, toggle, remove, getDefaultPrice, getPriceFor }
return { services, loading, error, load, save, toggle, remove, getDefaultPrice, getPriceFor };
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -11,278 +11,275 @@
* - buildWeeklyBreakBackgroundEvents
*/
import { describe, it, expect } from 'vitest'
import {
mapAgendaEventosToCalendarEvents,
mapAgendaEventosToClinicResourceEvents,
buildNextSessions,
minutesToDuration,
tituloFallback,
calcDefaultSlotDuration,
buildWeeklyBreakBackgroundEvents,
} from '../agendaMappers.js'
import { describe, it, expect } from 'vitest';
import { mapAgendaEventosToCalendarEvents, mapAgendaEventosToClinicResourceEvents, buildNextSessions, minutesToDuration, tituloFallback, calcDefaultSlotDuration, buildWeeklyBreakBackgroundEvents } from '../agendaMappers.js';
// ─── fixtures ─────────────────────────────────────────────────────────────────
function evento (overrides = {}) {
return {
id: 'ev-1',
titulo: 'Sessão Teste',
tipo: 'sessao',
status: 'agendado',
inicio_em: '2026-03-10T09:00:00',
fim_em: '2026-03-10T10:00:00',
owner_id: 'owner-1',
tenant_id: 'tenant-1',
patient_id: 'patient-1',
modalidade: 'presencial',
...overrides,
}
function evento(overrides = {}) {
return {
id: 'ev-1',
titulo: 'Sessão Teste',
tipo: 'sessao',
status: 'agendado',
inicio_em: '2026-03-10T09:00:00',
fim_em: '2026-03-10T10:00:00',
owner_id: 'owner-1',
tenant_id: 'tenant-1',
patient_id: 'patient-1',
modalidade: 'presencial',
...overrides
};
}
// ─── mapAgendaEventosToCalendarEvents ─────────────────────────────────────────
describe('mapAgendaEventosToCalendarEvents', () => {
it('mapeia um evento simples para o shape do FullCalendar', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento()])
expect(ev.id).toBe('ev-1')
expect(ev.start).toBe('2026-03-10T09:00:00')
expect(ev.end).toBe('2026-03-10T10:00:00')
expect(ev.extendedProps.tipo).toBe('sessao')
expect(ev.extendedProps.status).toBe('agendado')
})
it('mapeia um evento simples para o shape do FullCalendar', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento()]);
expect(ev.id).toBe('ev-1');
expect(ev.start).toBe('2026-03-10T09:00:00');
expect(ev.end).toBe('2026-03-10T10:00:00');
expect(ev.extendedProps.tipo).toBe('sessao');
expect(ev.extendedProps.status).toBe('agendado');
});
it('filtra rows null/undefined', () => {
const result = mapAgendaEventosToCalendarEvents([null, undefined, evento()])
expect(result.length).toBe(1)
})
it('filtra rows null/undefined', () => {
const result = mapAgendaEventosToCalendarEvents([null, undefined, evento()]);
expect(result.length).toBe(1);
});
it('retorna array vazio para input vazio', () => {
expect(mapAgendaEventosToCalendarEvents([])).toEqual([])
expect(mapAgendaEventosToCalendarEvents(null)).toEqual([])
})
it('retorna array vazio para input vazio', () => {
expect(mapAgendaEventosToCalendarEvents([])).toEqual([]);
expect(mapAgendaEventosToCalendarEvents(null)).toEqual([]);
});
it('inclui ícone ✓ no título para status realizado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'realizado' })])
expect(ev.title).toContain('✓')
})
it('inclui ícone ✓ no título para status realizado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'realizado' })]);
expect(ev.title).toContain('✓');
});
it('inclui ícone ✗ no título para status faltou', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'faltou' })])
expect(ev.title).toContain('✗')
})
it('inclui ícone ✗ no título para status faltou', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'faltou' })]);
expect(ev.title).toContain('✗');
});
it('inclui ícone ∅ no título para status cancelado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'cancelado' })])
expect(ev.title).toContain('∅')
})
it('inclui ícone ∅ no título para status cancelado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'cancelado' })]);
expect(ev.title).toContain('∅');
});
it('inclui ícone ↺ no título para status remarcado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'remarcado' })])
expect(ev.title).toContain('↺')
})
it('inclui ícone ↺ no título para status remarcado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'remarcado' })]);
expect(ev.title).toContain('↺');
});
it('inclui ícone ↻ para ocorrências de série', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ recurrence_id: 'rule-1', is_occurrence: true })])
expect(ev.title).toContain('↻')
})
it('inclui ícone ↻ para ocorrências de série', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ recurrence_id: 'rule-1', is_occurrence: true })]);
expect(ev.title).toContain('↻');
});
it('aplica cor de fundo para status faltou', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'faltou' })])
expect(ev.backgroundColor).toBe('#ef4444')
})
it('aplica cor de fundo para status faltou', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'faltou' })]);
expect(ev.backgroundColor).toBe('#ef4444');
});
it('aplica cor de fundo para status cancelado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'cancelado' })])
expect(ev.backgroundColor).toBe('#f97316')
})
it('aplica cor de fundo para status cancelado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'cancelado' })]);
expect(ev.backgroundColor).toBe('#f97316');
});
it('aplica cor de fundo para status remarcado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'remarcado' })])
expect(ev.backgroundColor).toBe('#a855f7')
})
it('aplica cor de fundo para status remarcado', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'remarcado' })]);
expect(ev.backgroundColor).toBe('#a855f7');
});
it('usa titulo_custom quando disponível', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ titulo_custom: 'Personalizado' })])
expect(ev.title).toContain('Personalizado')
})
it('usa titulo_custom quando disponível', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ titulo_custom: 'Personalizado' })]);
expect(ev.title).toContain('Personalizado');
});
it('usa nome do paciente via patients join quando titulo ausente', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({
titulo: null,
titulo_custom: null,
patients: { nome_completo: 'João Silva', avatar_url: null }
})])
expect(ev.title).toContain('João Silva')
})
it('usa nome do paciente via patients join quando titulo ausente', () => {
const [ev] = mapAgendaEventosToCalendarEvents([
evento({
titulo: null,
titulo_custom: null,
patients: { nome_completo: 'João Silva', avatar_url: null }
})
]);
expect(ev.title).toContain('João Silva');
});
it('mapeia patient_id corretamente', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ patient_id: 'p-123' })])
expect(ev.extendedProps.patient_id).toBe('p-123')
expect(ev.extendedProps.paciente_id).toBe('p-123') // alias
})
it('mapeia patient_id corretamente', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({ patient_id: 'p-123' })]);
expect(ev.extendedProps.patient_id).toBe('p-123');
expect(ev.extendedProps.paciente_id).toBe('p-123'); // alias
});
it('mapeia recurrence_id e original_date', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({
recurrence_id: 'rule-abc',
original_date: '2026-03-10',
})])
expect(ev.extendedProps.recurrence_id).toBe('rule-abc')
expect(ev.extendedProps.original_date).toBe('2026-03-10')
})
it('mapeia recurrence_id e original_date', () => {
const [ev] = mapAgendaEventosToCalendarEvents([
evento({
recurrence_id: 'rule-abc',
original_date: '2026-03-10'
})
]);
expect(ev.extendedProps.recurrence_id).toBe('rule-abc');
expect(ev.extendedProps.original_date).toBe('2026-03-10');
});
it('mapeia exception_type', () => {
const [ev] = mapAgendaEventosToCalendarEvents([evento({
exception_type: 'patient_missed',
status: 'faltou',
})])
expect(ev.extendedProps.exception_type).toBe('patient_missed')
expect(ev.extendedProps.is_exception).toBe(true)
})
})
it('mapeia exception_type', () => {
const [ev] = mapAgendaEventosToCalendarEvents([
evento({
exception_type: 'patient_missed',
status: 'faltou'
})
]);
expect(ev.extendedProps.exception_type).toBe('patient_missed');
expect(ev.extendedProps.is_exception).toBe(true);
});
});
// ─── mapAgendaEventosToClinicResourceEvents ───────────────────────────────────
describe('mapAgendaEventosToClinicResourceEvents', () => {
it('adiciona resourceId baseado em owner_id', () => {
const [ev] = mapAgendaEventosToClinicResourceEvents([evento({ owner_id: 'owner-99' })])
expect(ev.resourceId).toBe('owner-99')
})
it('adiciona resourceId baseado em owner_id', () => {
const [ev] = mapAgendaEventosToClinicResourceEvents([evento({ owner_id: 'owner-99' })]);
expect(ev.resourceId).toBe('owner-99');
});
it('usa terapeuta_id como fallback para resourceId', () => {
const [ev] = mapAgendaEventosToClinicResourceEvents([evento({ owner_id: null, terapeuta_id: 'tera-1' })])
expect(ev.resourceId).toBe('tera-1')
})
})
it('usa terapeuta_id como fallback para resourceId', () => {
const [ev] = mapAgendaEventosToClinicResourceEvents([evento({ owner_id: null, terapeuta_id: 'tera-1' })]);
expect(ev.resourceId).toBe('tera-1');
});
});
// ─── buildNextSessions ────────────────────────────────────────────────────────
describe('buildNextSessions', () => {
it('filtra sessões no passado', () => {
const now = new Date('2026-03-10T12:00:00')
const rows = [
evento({ id: 'past', fim_em: '2026-03-09T10:00:00' }),
evento({ id: 'future', fim_em: '2026-03-11T10:00:00' }),
]
const result = buildNextSessions(rows, now)
expect(result.length).toBe(1)
expect(result[0].id).toBe('future')
})
it('filtra sessões no passado', () => {
const now = new Date('2026-03-10T12:00:00');
const rows = [evento({ id: 'past', fim_em: '2026-03-09T10:00:00' }), evento({ id: 'future', fim_em: '2026-03-11T10:00:00' })];
const result = buildNextSessions(rows, now);
expect(result.length).toBe(1);
expect(result[0].id).toBe('future');
});
it('inclui sessão cujo fim_em é agora (mesmo ms)', () => {
const now = new Date('2026-03-10T10:00:00')
const rows = [evento({ fim_em: '2026-03-10T10:00:00' })]
const result = buildNextSessions(rows, now)
expect(result.length).toBe(1)
})
it('inclui sessão cujo fim_em é agora (mesmo ms)', () => {
const now = new Date('2026-03-10T10:00:00');
const rows = [evento({ fim_em: '2026-03-10T10:00:00' })];
const result = buildNextSessions(rows, now);
expect(result.length).toBe(1);
});
it('limita a 6 sessões', () => {
const now = new Date('2026-01-01')
const rows = Array.from({ length: 10 }, (_, i) => evento({
id: `ev-${i}`,
fim_em: `2026-03-${String(i + 10).padStart(2,'0')}T10:00:00`,
}))
const result = buildNextSessions(rows, now)
expect(result.length).toBe(6)
})
it('limita a 6 sessões', () => {
const now = new Date('2026-01-01');
const rows = Array.from({ length: 10 }, (_, i) =>
evento({
id: `ev-${i}`,
fim_em: `2026-03-${String(i + 10).padStart(2, '0')}T10:00:00`
})
);
const result = buildNextSessions(rows, now);
expect(result.length).toBe(6);
});
it('retorna shape correto', () => {
const now = new Date('2026-01-01')
const [s] = buildNextSessions([evento()], now)
expect(s).toMatchObject({
id: 'ev-1',
title: 'Sessão Teste',
startISO: '2026-03-10T09:00:00',
endISO: '2026-03-10T10:00:00',
tipo: 'sessao',
status: 'agendado',
})
})
it('retorna shape correto', () => {
const now = new Date('2026-01-01');
const [s] = buildNextSessions([evento()], now);
expect(s).toMatchObject({
id: 'ev-1',
title: 'Sessão Teste',
startISO: '2026-03-10T09:00:00',
endISO: '2026-03-10T10:00:00',
tipo: 'sessao',
status: 'agendado'
});
});
it('mapeia pacienteId de patient_id', () => {
const now = new Date('2026-01-01')
const [s] = buildNextSessions([evento({ patient_id: 'p-999' })], now)
expect(s.pacienteId).toBe('p-999')
})
})
it('mapeia pacienteId de patient_id', () => {
const now = new Date('2026-01-01');
const [s] = buildNextSessions([evento({ patient_id: 'p-999' })], now);
expect(s.pacienteId).toBe('p-999');
});
});
// ─── minutesToDuration ────────────────────────────────────────────────────────
describe('minutesToDuration', () => {
it('30 minutos → 00:30:00', () => expect(minutesToDuration(30)).toBe('00:30:00'))
it('60 minutos → 01:00:00', () => expect(minutesToDuration(60)).toBe('01:00:00'))
it('90 minutos → 01:30:00', () => expect(minutesToDuration(90)).toBe('01:30:00'))
it('0 minutos → 00:00:00', () => expect(minutesToDuration(0)).toBe('00:00:00'))
})
it('30 minutos → 00:30:00', () => expect(minutesToDuration(30)).toBe('00:30:00'));
it('60 minutos → 01:00:00', () => expect(minutesToDuration(60)).toBe('01:00:00'));
it('90 minutos → 01:30:00', () => expect(minutesToDuration(90)).toBe('01:30:00'));
it('0 minutos → 00:00:00', () => expect(minutesToDuration(0)).toBe('00:00:00'));
});
// ─── tituloFallback ───────────────────────────────────────────────────────────
describe('tituloFallback', () => {
it('sessao → Sessão', () => expect(tituloFallback('sessao')).toBe('Sessão'))
it('bloqueio → Bloqueio', () => expect(tituloFallback('bloqueio')).toBe('Bloqueio'))
it('pessoal → Pessoal', () => expect(tituloFallback('pessoal')).toBe('Pessoal'))
it('clinica → Clínica', () => expect(tituloFallback('clinica')).toBe('Clínica'))
it('desconhecido → Compromisso', () => expect(tituloFallback('outro')).toBe('Compromisso'))
it('null → Compromisso', () => expect(tituloFallback(null)).toBe('Compromisso'))
})
it('sessao → Sessão', () => expect(tituloFallback('sessao')).toBe('Sessão'));
it('bloqueio → Bloqueio', () => expect(tituloFallback('bloqueio')).toBe('Bloqueio'));
it('pessoal → Pessoal', () => expect(tituloFallback('pessoal')).toBe('Pessoal'));
it('clinica → Clínica', () => expect(tituloFallback('clinica')).toBe('Clínica'));
it('desconhecido → Compromisso', () => expect(tituloFallback('outro')).toBe('Compromisso'));
it('null → Compromisso', () => expect(tituloFallback(null)).toBe('Compromisso'));
});
// ─── calcDefaultSlotDuration ──────────────────────────────────────────────────
describe('calcDefaultSlotDuration', () => {
it('usa granularidade custom quando ativa', () => {
const s = { usar_granularidade_custom: true, granularidade_min: 15 }
expect(calcDefaultSlotDuration(s)).toBe('00:15:00')
})
it('usa granularidade custom quando ativa', () => {
const s = { usar_granularidade_custom: true, granularidade_min: 15 };
expect(calcDefaultSlotDuration(s)).toBe('00:15:00');
});
it('usa admin_slot_visual_minutos como fallback', () => {
const s = { admin_slot_visual_minutos: 20 }
expect(calcDefaultSlotDuration(s)).toBe('00:20:00')
})
it('usa admin_slot_visual_minutos como fallback', () => {
const s = { admin_slot_visual_minutos: 20 };
expect(calcDefaultSlotDuration(s)).toBe('00:20:00');
});
it('usa 30 min como padrão quando nenhuma configuração', () => {
expect(calcDefaultSlotDuration({})).toBe('00:30:00')
expect(calcDefaultSlotDuration(null)).toBe('00:30:00')
})
})
it('usa 30 min como padrão quando nenhuma configuração', () => {
expect(calcDefaultSlotDuration({})).toBe('00:30:00');
expect(calcDefaultSlotDuration(null)).toBe('00:30:00');
});
});
// ─── buildWeeklyBreakBackgroundEvents ────────────────────────────────────────
describe('buildWeeklyBreakBackgroundEvents', () => {
it('retorna vazio para input vazio', () => {
const result = buildWeeklyBreakBackgroundEvents([], new Date('2026-03-01'), new Date('2026-03-08'))
expect(result).toEqual([])
})
it('retorna vazio para input vazio', () => {
const result = buildWeeklyBreakBackgroundEvents([], new Date('2026-03-01'), new Date('2026-03-08'));
expect(result).toEqual([]);
});
it('gera eventos de background para pausa no dia correto', () => {
const pausas = [{ weekday: 1, start: '12:00', end: '13:00', label: 'Almoço' }] // segunda
const result = buildWeeklyBreakBackgroundEvents(
pausas,
new Date(2026, 2, 1), // dom
new Date(2026, 2, 8), // dom
)
expect(result.length).toBe(1)
expect(result[0].display).toBe('background')
expect(result[0].start).toContain('2026-03-02') // segunda
expect(result[0].extendedProps.label).toBe('Almoço')
})
it('gera eventos de background para pausa no dia correto', () => {
const pausas = [{ weekday: 1, start: '12:00', end: '13:00', label: 'Almoço' }]; // segunda
const result = buildWeeklyBreakBackgroundEvents(
pausas,
new Date(2026, 2, 1), // dom
new Date(2026, 2, 8) // dom
);
expect(result.length).toBe(1);
expect(result[0].display).toBe('background');
expect(result[0].start).toContain('2026-03-02'); // segunda
expect(result[0].extendedProps.label).toBe('Almoço');
});
it('gera uma pausa por semana quando range cobre 2 semanas', () => {
const pausas = [{ weekday: 1, start: '12:00', end: '13:00' }] // toda segunda
const result = buildWeeklyBreakBackgroundEvents(
pausas,
new Date(2026, 2, 1), // dom 01/03
new Date(2026, 2, 15), // dom 15/03
)
expect(result.length).toBe(2) // seg 02 e seg 09
})
it('gera uma pausa por semana quando range cobre 2 semanas', () => {
const pausas = [{ weekday: 1, start: '12:00', end: '13:00' }]; // toda segunda
const result = buildWeeklyBreakBackgroundEvents(
pausas,
new Date(2026, 2, 1), // dom 01/03
new Date(2026, 2, 15) // dom 15/03
);
expect(result.length).toBe(2); // seg 02 e seg 09
});
it('não gera para dias diferentes', () => {
const pausas = [{ weekday: 5, start: '12:00', end: '13:00' }] // sexta
const result = buildWeeklyBreakBackgroundEvents(
pausas,
new Date(2026, 2, 2), // seg
new Date(2026, 2, 5), // qui
)
expect(result.length).toBe(0)
})
})
it('não gera para dias diferentes', () => {
const pausas = [{ weekday: 5, start: '12:00', end: '13:00' }]; // sexta
const result = buildWeeklyBreakBackgroundEvents(
pausas,
new Date(2026, 2, 2), // seg
new Date(2026, 2, 5) // qui
);
expect(result.length).toBe(0);
});
});
@@ -14,62 +14,58 @@
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client'
import { supabase } from '@/lib/supabase/client';
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.')
}
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.');
}
}
function assertValidIsoRange (startISO, endISO) {
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).')
function assertValidIsoRange(startISO, endISO) {
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).');
}
function sanitizeOwnerIds (ownerIds) {
return (ownerIds || [])
.filter(id => typeof id === 'string' && id && id !== 'null' && id !== 'undefined')
function sanitizeOwnerIds(ownerIds) {
return (ownerIds || []).filter((id) => typeof id === 'string' && id && id !== 'null' && id !== 'undefined');
}
/**
* 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 []
assertValidIsoRange(startISO, endISO)
export async function listClinicEvents({ tenantId, ownerIds, startISO, endISO } = {}) {
assertValidTenantId(tenantId);
if (!ownerIds?.length) return [];
assertValidIsoRange(startISO, endISO);
const safeOwnerIds = sanitizeOwnerIds(ownerIds)
if (!safeOwnerIds.length) return []
const safeOwnerIds = sanitizeOwnerIds(ownerIds);
if (!safeOwnerIds.length) return [];
const { data, error } = await supabase
.from('agenda_eventos')
.select('*, patients!agenda_eventos_patient_id_fkey(id, nome_completo, avatar_url, status), determined_commitments!agenda_eventos_determined_commitment_fk(id, bg_color, text_color)')
.eq('tenant_id', tenantId)
.in('owner_id', safeOwnerIds)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
.order('inicio_em', { ascending: true })
const { data, error } = await supabase
.from('agenda_eventos')
.select('*, patients!agenda_eventos_patient_id_fkey(id, nome_completo, avatar_url, status), determined_commitments!agenda_eventos_determined_commitment_fk(id, bg_color, text_color)')
.eq('tenant_id', tenantId)
.in('owner_id', safeOwnerIds)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
.order('inicio_em', { ascending: true });
if (error) throw error
return data || []
if (error) throw error;
return data || [];
}
/**
* Lista profissionais/membros para montar colunas no mosaico.
* Usando view "v_tenant_staff" (como você já tem).
*/
export async function listTenantStaff (tenantId) {
assertValidTenantId(tenantId)
export async function listTenantStaff(tenantId) {
assertValidTenantId(tenantId);
const { data, error } = await supabase
.from('v_tenant_staff')
.select('*')
.eq('tenant_id', tenantId)
const { data, error } = await supabase.from('v_tenant_staff').select('*').eq('tenant_id', tenantId);
if (error) throw error
return data || []
if (error) throw error;
return data || [];
}
/**
@@ -81,28 +77,24 @@ export async function listTenantStaff (tenantId) {
* - 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 } = {}) {
assertValidTenantId(tenantId)
if (!payload) throw new Error('Payload vazio.')
export async function createClinicAgendaEvento(payload, { tenantId } = {}) {
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 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 insertPayload = {
...payload,
tenant_id: tenantId
};
const { data, error } = await supabase
.from('agenda_eventos')
.insert(insertPayload)
.select('*')
.single()
const { data, error } = await supabase.from('agenda_eventos').insert(insertPayload).select('*').single();
if (error) throw error
return data
if (error) throw error;
return data;
}
/**
@@ -110,37 +102,27 @@ export async function createClinicAgendaEvento (payload, { tenantId } = {}) {
* - filtra por id + tenant_id (evita update cruzado)
* - permite editar owner_id (caso você mova evento para outro profissional)
*/
export async function updateClinicAgendaEvento (id, patch, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.')
if (!patch) throw new Error('Patch vazio.')
assertValidTenantId(tenantId)
export async function updateClinicAgendaEvento(id, patch, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
if (!patch) throw new Error('Patch vazio.');
assertValidTenantId(tenantId);
const { data, error } = await supabase
.from('agenda_eventos')
.update(patch)
.eq('id', id)
.eq('tenant_id', tenantId)
.select('*')
.single()
const { data, error } = await supabase.from('agenda_eventos').update(patch).eq('id', id).eq('tenant_id', tenantId).select('*').single();
if (error) throw error
return data
if (error) throw error;
return data;
}
/**
* Delete seguro para clínica:
* - filtra por id + tenant_id
*/
export async function deleteClinicAgendaEvento (id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.')
assertValidTenantId(tenantId)
export async function deleteClinicAgendaEvento(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
assertValidTenantId(tenantId);
const { error } = await supabase
.from('agenda_eventos')
.delete()
.eq('id', id)
.eq('tenant_id', tenantId)
const { error } = await supabase.from('agenda_eventos').delete().eq('id', id).eq('tenant_id', tenantId);
if (error) throw error
return true
}
if (error) throw error;
return true;
}
+190 -191
View File
@@ -24,278 +24,277 @@
// mapAgendaEventosToCalendarEvents
// ─────────────────────────────────────────────────────────────────────────────
export function mapAgendaEventosToCalendarEvents (rows) {
return (rows || []).map(_mapRow).filter(Boolean)
export function mapAgendaEventosToCalendarEvents(rows) {
return (rows || []).map(_mapRow).filter(Boolean);
}
// ─────────────────────────────────────────────────────────────────────────────
// mapAgendaEventosToClinicResourceEvents
// ─────────────────────────────────────────────────────────────────────────────
export function mapAgendaEventosToClinicResourceEvents (rows) {
return (rows || []).map((r) => {
const ev = _mapRow(r)
if (!ev) return null
ev.resourceId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
return ev
}).filter(Boolean)
export function mapAgendaEventosToClinicResourceEvents(rows) {
return (rows || [])
.map((r) => {
const ev = _mapRow(r);
if (!ev) return null;
ev.resourceId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null);
return ev;
})
.filter(Boolean);
}
// ─────────────────────────────────────────────────────────────────────────────
// mapper interno
// ─────────────────────────────────────────────────────────────────────────────
function _mapRow (r) {
if (!r) return null
function _mapRow(r) {
if (!r) return null;
const isOccurrence = !!r.is_occurrence
const isRealSession = !isOccurrence
const isOccurrence = !!r.is_occurrence;
const isRealSession = !isOccurrence;
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null);
// commitment / cores
const commitment = r.determined_commitments ?? r.commitment ?? null
const baseBg = commitment?.bg_color ? `#${commitment.bg_color}` : null
const baseTxt = commitment?.text_color ? `#${commitment.text_color}` : null
const statusBg = _statusBgColor(r.status)
const bgColor = statusBg ?? baseBg ?? undefined
const txtColor = baseTxt ?? (statusBg ? '#ffffff' : undefined)
// commitment / cores
const commitment = r.determined_commitments ?? r.commitment ?? null;
const baseBg = commitment?.bg_color ? `#${commitment.bg_color}` : null;
const baseTxt = commitment?.text_color ? `#${commitment.text_color}` : null;
const statusBg = _statusBgColor(r.status);
const bgColor = statusBg ?? baseBg ?? undefined;
const txtColor = baseTxt ?? (statusBg ? '#ffffff' : undefined);
// título
const nomeP = r.patients?.nome_completo ?? r.paciente_nome ?? r.patient_name ?? ''
const titleBase = r.titulo_custom || r.titulo || (nomeP ? nomeP : tituloFallback(r.tipo))
const icon = _statusIcon(r.status, isOccurrence, !!r.recurrence_id)
const title = `${icon}${titleBase}`
// título
const nomeP = r.patients?.nome_completo ?? r.paciente_nome ?? r.patient_name ?? '';
const titleBase = r.titulo_custom || r.titulo || (nomeP ? nomeP : tituloFallback(r.tipo));
const icon = _statusIcon(r.status, isOccurrence, !!r.recurrence_id);
const title = `${icon}${titleBase}`;
// recorrência — nova + fallback legada
const recurrenceId = r.recurrence_id ?? null
const originalDate = r.original_date ?? r.recurrence_date ?? null
const exceptionType = r.exception_type ?? null
// recorrência — nova + fallback legada
const recurrenceId = r.recurrence_id ?? null;
const originalDate = r.original_date ?? r.recurrence_date ?? null;
const exceptionType = r.exception_type ?? null;
return {
id: r.id ?? `occ::${recurrenceId}::${originalDate}`,
title,
start: r.inicio_em,
end: r.fim_em,
return {
id: r.id ?? `occ::${recurrenceId}::${originalDate}`,
title,
start: r.inicio_em,
end: r.fim_em,
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
...(txtColor && { textColor: txtColor }),
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
...(txtColor && { textColor: txtColor }),
extendedProps: {
// identidade
dbId: r.id ?? null,
isOccurrence,
isRealSession,
extendedProps: {
// identidade
dbId: r.id ?? null,
isOccurrence,
isRealSession,
// owner
owner_id: ownerId,
terapeuta_id: normalizeId(r?.terapeuta_id ?? null),
// owner
owner_id: ownerId,
terapeuta_id: normalizeId(r?.terapeuta_id ?? null),
// compromisso
tipo: r.tipo ?? null,
status: r.status ?? null,
determined_commitment_id: r.determined_commitment_id ?? null,
commitment_bg_color: bgColor ?? null,
commitment_text_color: txtColor ?? null,
// compromisso
tipo: r.tipo ?? null,
status: r.status ?? null,
determined_commitment_id: r.determined_commitment_id ?? null,
commitment_bg_color: bgColor ?? null,
commitment_text_color: txtColor ?? null,
// paciente
patient_id: r.patient_id ?? null,
paciente_id: r.patient_id ?? null, // alias para compatibilidade com dialog/form
paciente_nome: nomeP,
paciente_avatar: r.patients?.avatar_url ?? r.paciente_avatar ?? null,
paciente_status: r.patients?.status ?? r.paciente_status ?? null,
// paciente
patient_id: r.patient_id ?? null,
paciente_id: r.patient_id ?? null, // alias para compatibilidade com dialog/form
paciente_nome: nomeP,
paciente_avatar: r.patients?.avatar_url ?? r.paciente_avatar ?? null,
paciente_status: r.patients?.status ?? r.paciente_status ?? null,
// campos
observacoes: r.observacoes ?? null,
titulo_custom: r.titulo_custom ?? null,
extra_fields: r.extra_fields ?? null,
modalidade: r.modalidade ?? null,
// campos
observacoes: r.observacoes ?? null,
titulo_custom: r.titulo_custom ?? null,
extra_fields: r.extra_fields ?? null,
modalidade: r.modalidade ?? null,
// privacidade (clínica)
visibility_scope: r.visibility_scope ?? null,
masked: !!r.masked,
// privacidade (clínica)
visibility_scope: r.visibility_scope ?? null,
masked: !!r.masked,
// recorrência — NOVA arquitetura
recurrence_id: recurrenceId,
original_date: originalDate,
exception_type: exceptionType,
exception_id: r.exception_id ?? null,
exception_reason: r.exception_reason ?? null,
// recorrência — NOVA arquitetura
recurrence_id: recurrenceId,
original_date: originalDate,
exception_type: exceptionType,
exception_id: r.exception_id ?? null,
exception_reason: r.exception_reason ?? null,
// recorrência — fallback LEGADA (não quebra enquanto migra)
serie_id: r.serie_id ?? recurrenceId ?? null,
serie_dia_semana: r.agenda_series?.dia_semana ?? r.serie_dia_semana ?? null,
serie_hora: r.agenda_series?.hora_inicio ?? r.serie_hora ?? null,
serie_duracao: r.agenda_series?.duracao_min ?? r.serie_duracao ?? null,
serie_status: r.agenda_series?.status ?? r.serie_status ?? null,
is_exception: r.is_exception ?? (exceptionType != null),
// recorrência — fallback LEGADA (não quebra enquanto migra)
serie_id: r.serie_id ?? recurrenceId ?? null,
serie_dia_semana: r.agenda_series?.dia_semana ?? r.serie_dia_semana ?? null,
serie_hora: r.agenda_series?.hora_inicio ?? r.serie_hora ?? null,
serie_duracao: r.agenda_series?.duracao_min ?? r.serie_duracao ?? null,
serie_status: r.agenda_series?.status ?? r.serie_status ?? null,
is_exception: r.is_exception ?? exceptionType != null,
// financeiro
price: r.price ?? null,
billed: r.billed ?? false,
billing_contract_id: r.billing_contract_id ?? null,
insurance_plan_id: r.insurance_plan_id ?? null,
insurance_guide_number: r.insurance_guide_number ?? null,
insurance_value: r.insurance_value != null ? Number(r.insurance_value) : null,
insurance_plan_service_id: r.insurance_plan_service_id ?? null,
// financeiro
price: r.price ?? null,
billed: r.billed ?? false,
billing_contract_id: r.billing_contract_id ?? null,
insurance_plan_id: r.insurance_plan_id ?? null,
insurance_guide_number: r.insurance_guide_number ?? null,
insurance_value: r.insurance_value != null ? Number(r.insurance_value) : null,
insurance_plan_service_id: r.insurance_plan_service_id ?? null,
// timestamps
inicio_em: r.inicio_em,
fim_em: r.fim_em,
tenant_id: r.tenant_id ?? null,
}
}
// timestamps
inicio_em: r.inicio_em,
fim_em: r.fim_em,
tenant_id: r.tenant_id ?? null
}
};
}
// ─────────────────────────────────────────────────────────────────────────────
// buildNextSessions
// ─────────────────────────────────────────────────────────────────────────────
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.patient_id || null
}))
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.patient_id || null
}));
}
// ─────────────────────────────────────────────────────────────────────────────
// calcDefaultSlotDuration
// ─────────────────────────────────────────────────────────────────────────────
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 calcDefaultSlotDuration(settings) {
const min = (settings?.usar_granularidade_custom && settings?.granularidade_min) || 0 || settings?.admin_slot_visual_minutos || 30;
return minutesToDuration(min);
}
// ─────────────────────────────────────────────────────────────────────────────
// buildWeeklyBreakBackgroundEvents — código original preservado integralmente
// ─────────────────────────────────────────────────────────────────────────────
export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd) {
if (!Array.isArray(pausas) || pausas.length === 0) return []
export function buildWeeklyBreakBackgroundEvents(pausas, rangeStart, rangeEnd) {
if (!Array.isArray(pausas) || pausas.length === 0) return [];
const out = []
const dayMs = 24 * 60 * 60 * 1000
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()
for (let ts = startOfDay(rangeStart).getTime(); ts < rangeEnd.getTime(); ts += dayMs) {
const d = new Date(ts);
const dow = d.getDay();
for (const p of pausas) {
const wd = normalizeWeekday(p?.weekday ?? p?.dia_semana)
if (wd === null || wd !== dow) continue
for (const p of pausas) {
const wd = normalizeWeekday(p?.weekday ?? p?.dia_semana);
if (wd === null || 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
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' }
})
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
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// minutesToDuration / tituloFallback
// ─────────────────────────────────────────────────────────────────────────────
export function minutesToDuration (min) {
const h = Math.floor(min / 60)
const m = min % 60
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:00`
export function minutesToDuration(min) {
const h = Math.floor(min / 60);
const m = min % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}: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'
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';
}
// ─────────────────────────────────────────────────────────────────────────────
// helpers de status
// ─────────────────────────────────────────────────────────────────────────────
function _statusBgColor (status) {
const map = {
agendado: '#3b82f6', // azul
realizado: '#22c55e', // verde
faltou: '#f97316', // laranja
cancelado: '#ef4444', // vermelho
remarcar: '#a855f7', // roxo
bloqueado: '#6b7280', // cinza
}
return map[status] ?? null
function _statusBgColor(status) {
const map = {
agendado: '#3b82f6', // azul
realizado: '#22c55e', // verde
faltou: '#f97316', // laranja
cancelado: '#ef4444', // vermelho
remarcar: '#a855f7', // roxo
bloqueado: '#6b7280' // cinza
};
return map[status] ?? null;
}
function _statusIcon (status, isOccurrence, hasSerie) {
if (status === 'realizado') return '✓ '
if (status === 'faltou') return '✗ '
if (status === 'cancelado') return '∅ '
if (status === 'remarcar') return '↺ '
if (status === 'bloqueado') return '⊘ '
if (hasSerie || isOccurrence) return '↻ '
return ''
function _statusIcon(status, isOccurrence, hasSerie) {
if (status === 'realizado') return '✓ ';
if (status === 'faltou') return '✗ ';
if (status === 'cancelado') return '∅ ';
if (status === 'remarcar') return '↺ ';
if (status === 'bloqueado') return '⊘ ';
if (hasSerie || isOccurrence) return '↻ ';
return '';
}
// ─────────────────────────────────────────────────────────────────────────────
// helpers internos — originais preservados
// ─────────────────────────────────────────────────────────────────────────────
function normalizeId (v) {
if (v === null || v === undefined) return null
const s = String(v).trim()
return s ? s : null
function normalizeId(v) {
if (v === null || v === undefined) return null;
const s = String(v).trim();
return s ? s : null;
}
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 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 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 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}`
}
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}`;
}
+108 -141
View File
@@ -14,125 +14,110 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
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.')
}
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()
if (userErr) throw userErr
async function getUid() {
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.')
return uid
const uid = userRes?.user?.id;
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()
export async function getMyAgendaSettings() {
const uid = await getUid();
const { data, error } = await supabase
.from('agenda_configuracoes')
.select('*')
.eq('owner_id', uid)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
const { data, error } = await supabase.from('agenda_configuracoes').select('*').eq('owner_id', uid).order('created_at', { ascending: false }).limit(1).maybeSingle();
if (error) throw error
return data
if (error) throw error;
return data;
}
/**
* Regras semanais de jornada (agenda_regras_semanais):
* retorna os dias ativos com hora_inicio/hora_fim por dia.
*/
export async function getMyWorkSchedule () {
const uid = await getUid()
export async function getMyWorkSchedule() {
const uid = await getUid();
const { data, error } = await supabase
.from('agenda_regras_semanais')
.select('dia_semana, hora_inicio, hora_fim, ativo')
.eq('owner_id', uid)
.eq('ativo', true)
.order('dia_semana')
const { data, error } = await supabase.from('agenda_regras_semanais').select('dia_semana, hora_inicio, hora_fim, ativo').eq('owner_id', uid).eq('ativo', true).order('dia_semana');
if (error) throw error
return data || []
if (error) throw error;
return data || [];
}
/**
* Lista agenda do terapeuta (somente do owner logado) dentro do tenant ativo.
* 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()
export async function listMyAgendaEvents({ startISO, endISO, tenantId: tenantIdArg } = {}) {
const uid = await getUid();
const tenantStore = useTenantStore()
const tenantId = tenantIdArg || tenantStore.activeTenantId
assertValidTenantId(tenantId)
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId;
assertValidTenantId(tenantId);
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).')
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).');
const { data, error } = await supabase
.from('agenda_eventos')
.select('*, patients(id, nome_completo, avatar_url), determined_commitments!determined_commitment_id(id, bg_color, text_color)')
.eq('tenant_id', tenantId)
.eq('owner_id', uid)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
.order('inicio_em', { ascending: true })
const { data, error } = await supabase
.from('agenda_eventos')
.select('*, patients(id, nome_completo, avatar_url), determined_commitments!determined_commitment_id(id, bg_color, text_color)')
.eq('tenant_id', tenantId)
.eq('owner_id', uid)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
.order('inicio_em', { ascending: true });
if (error) throw error
return data || []
if (error) throw error;
return data || [];
}
/**
* 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 (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).')
export async function listClinicEvents({ tenantId, ownerIds, startISO, endISO }) {
assertValidTenantId(tenantId);
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')
// Sanitiza ownerIds
const safeOwnerIds = ownerIds.filter((id) => typeof id === 'string' && id && id !== 'null' && id !== 'undefined');
if (!safeOwnerIds.length) return []
if (!safeOwnerIds.length) return [];
const { data, error } = await supabase
.from('agenda_eventos')
.select('*, determined_commitments!determined_commitment_id(id, bg_color, text_color)')
.eq('tenant_id', tenantId)
.in('owner_id', safeOwnerIds)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
.order('inicio_em', { ascending: true })
const { data, error } = await supabase
.from('agenda_eventos')
.select('*, determined_commitments!determined_commitment_id(id, bg_color, text_color)')
.eq('tenant_id', tenantId)
.in('owner_id', safeOwnerIds)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
.order('inicio_em', { ascending: true });
if (error) throw error
return data || []
if (error) throw error;
return data || [];
}
export async function listTenantStaff (tenantId) {
assertValidTenantId(tenantId)
export async function listTenantStaff(tenantId) {
assertValidTenantId(tenantId);
const { data, error } = await supabase
.from('v_tenant_staff')
.select('*')
.eq('tenant_id', tenantId)
const { data, error } = await supabase.from('v_tenant_staff').select('*').eq('tenant_id', tenantId);
if (error) throw error
return data || []
if (error) throw error;
return data || [];
}
/**
@@ -145,28 +130,24 @@ export async function listTenantStaff (tenantId) {
* (ex.: createClinicAgendaEvento) que permita owner_id explicitamente.
* Por enquanto, deixo esta função como "safe default" para terapeuta.
*/
export async function createAgendaEvento (payload) {
const uid = await getUid()
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertValidTenantId(tenantId)
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.')
if (!payload) throw new Error('Payload vazio.');
const insertPayload = {
...payload,
tenant_id: tenantId,
owner_id: uid
}
const insertPayload = {
...payload,
tenant_id: tenantId,
owner_id: uid
};
const { data, error } = await supabase
.from('agenda_eventos')
.insert(insertPayload)
.select('*')
.single()
const { data, error } = await supabase.from('agenda_eventos').insert(insertPayload).select('*').single();
if (error) throw error
return data
if (error) throw error;
return data;
}
/**
@@ -174,45 +155,35 @@ export async function createAgendaEvento (payload) {
* - 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.')
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 tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId;
assertValidTenantId(tenantId);
const { data, error } = await supabase
.from('agenda_eventos')
.update(patch)
.eq('id', id)
.eq('tenant_id', tenantId)
.select('*')
.single()
const { data, error } = await supabase.from('agenda_eventos').update(patch).eq('id', id).eq('tenant_id', tenantId).select('*').single();
if (error) throw error
return data
if (error) throw error;
return data;
}
/**
* Delete seguro:
* - filtra por id + tenant_id
*/
export async function deleteAgendaEvento (id, { tenantId: tenantIdArg } = {}) {
if (!id) throw new Error('ID inválido.')
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 tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId;
assertValidTenantId(tenantId);
const { error } = await supabase
.from('agenda_eventos')
.delete()
.eq('id', id)
.eq('tenant_id', tenantId)
const { error } = await supabase.from('agenda_eventos').delete().eq('id', id).eq('tenant_id', tenantId);
if (error) throw error
return true
if (error) throw error;
return true;
}
// Adicione no mesmo arquivo: src/features/agenda/services/agendaRepository.js
@@ -226,29 +197,25 @@ export async function deleteAgendaEvento (id, { tenantId: tenantIdArg } = {}) {
* - 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)
export async function createClinicAgendaEvento(payload, { tenantId: tenantIdArg } = {}) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId;
assertValidTenantId(tenantId);
if (!payload) throw new Error('Payload vazio.')
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 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 insertPayload = {
...payload,
tenant_id: tenantId
};
const { data, error } = await supabase
.from('agenda_eventos')
.insert(insertPayload)
.select('*')
.single()
const { data, error } = await supabase.from('agenda_eventos').insert(insertPayload).select('*').single();
if (error) throw error
return data
}
if (error) throw error;
return data;
}