Agenda, Agendador, Configurações

This commit is contained in:
Leonardo
2026-03-12 08:58:36 -03:00
parent f733db8436
commit f4b185ae17
197 changed files with 33405 additions and 6507 deletions
@@ -34,7 +34,6 @@ const cfg = ref({
agenda_custom_end: null,
session_duration_min: 40,
session_break_min: 10,
session_start_offset_min: 0,
pausas_semanais: [],
online_ativo: false,
setup_clinica_concluido: false,
@@ -136,6 +135,17 @@ function dateForDayOfWeek (dayValue) {
d.setDate(d.getDate() + delta)
return d
}
function floorTo30 (hhmm) {
const [h, m] = String(hhmm || '00:00').slice(0, 5).split(':').map(Number)
return String(h).padStart(2,'0') + ':' + (m < 30 ? '00' : '30')
}
function ceilTo30 (hhmm) {
const [h, m] = String(hhmm || '00:00').slice(0, 5).split(':').map(Number)
if (m === 0 || m === 30) return String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0')
if (m < 30) return String(h).padStart(2,'0') + ':30'
return String(h + 1).padStart(2,'0') + ':00'
}
function toLocalIsoAt (dateBase, hhmm) {
const [h, m] = String(hhmm).split(':').map(Number)
const d = new Date(dateBase); d.setHours(h, m, 0, 0)
@@ -159,8 +169,7 @@ const jornadaOk = computed(() => selectedDays.value.length > 0 && isValidHHMM(jo
const resumoRitmo = computed(() => {
const d = cfg.value.session_duration_min || 50
const i = cfg.value.session_break_min || 0
const off = cfg.value.session_start_offset_min || 0
return `${d} min sessão · ${i > 0 ? `${i} min intervalo` : 'sem intervalo'} · início :${String(off).padStart(2,'0')}`
return `${d} min sessão · ${i > 0 ? `${i} min intervalo` : 'sem intervalo'}`
})
const resumoOnline = computed(() => {
@@ -175,6 +184,14 @@ const resumoOnline = computed(() => {
return `Ativo · ${total} slot${total !== 1 ? 's' : ''} configurado${total !== 1 ? 's' : ''} em ${dias} dia${dias !== 1 ? 's' : ''}`
})
// Dias que têm slots no banco mas não estão mais na jornada (órfãos)
const orphanSlotDays = computed(() => {
const active = new Set(selectedDays.value.map(d => d.value))
return diasSemana
.filter(d => !active.has(d.value) && (onlineSlotsByDay.value[d.value]?.size || 0) > 0)
.map(d => d.short)
})
// ══ SYNC / HYDRATE ════════════════════════════════════════════
watch([selectedDays, jornadaStart, jornadaEnd], () => {
if (!isValidHHMM(jornadaStart.value) || !isValidHHMM(jornadaEnd.value)) return
@@ -189,6 +206,32 @@ function getPausasForDay (dayValue) {
return pausasPorDia.value[dayValue] || []
}
// ── Toggle igual/diferente ─────────────────────────────────────
function switchToIgual () {
// Copia global para todos os dias (zera divergências por dia)
if (isValidHHMM(jornadaStart.value) && isValidHHMM(jornadaEnd.value)) {
selectedDays.value.forEach(d => {
jornadaPorDia.value[d.value] = { inicio: jornadaStart.value, fim: jornadaEnd.value }
})
}
// Copia pausas globais para todos os dias e usa apenas pausasGlobais
selectedDays.value.forEach(d => { pausasPorDia.value[d.value] = [] })
jornadaIgualTodos.value = true
}
function switchToDiferente () {
// Inicializa cada dia com o horário global atual e as pausas globais
if (isValidHHMM(jornadaStart.value) && isValidHHMM(jornadaEnd.value)) {
selectedDays.value.forEach(d => {
jornadaPorDia.value[d.value] = { inicio: jornadaStart.value, fim: jornadaEnd.value }
})
}
selectedDays.value.forEach(d => {
pausasPorDia.value[d.value] = pausasGlobais.value.map(p => ({ ...p, id: newId() }))
})
jornadaIgualTodos.value = false
}
function hydratePausasFromCfg () {
const byDay = { 1: [], 2: [], 3: [], 4: [], 5: [], 6: [], 0: [] }
for (const p of cfg.value.pausas_semanais || []) {
@@ -401,6 +444,19 @@ async function saveJornada () {
if (insErr) throw insErr
}
// Limpar slots online de dias removidos da jornada
const activeDays = new Set(selectedDays.value.map(d => d.value))
const orphanDays = [0,1,2,3,4,5,6].filter(d => !activeDays.has(d))
if (orphanDays.length) {
const { error: orphanErr } = await supabase
.from('agenda_online_slots')
.delete()
.eq('owner_id', uid)
.in('weekday', orphanDays)
if (orphanErr) console.warn('[CFG] limpeza órfãos:', orphanErr)
else for (const d of orphanDays) _setDay(d, new Set())
}
cfg.value.setup_clinica_concluido = true
cfg.value.jornada_igual_todos = igualTodos
toast.add({ severity: 'success', summary: 'Jornada salva', detail: 'Horários de trabalho atualizados.', life: 1800 })
@@ -414,11 +470,9 @@ async function saveJornada () {
async function saveRitmo () {
const dur = Number(cfg.value.session_duration_min || 0)
const gap = Number(cfg.value.session_break_min || 0)
const off = Number(cfg.value.session_start_offset_min ?? 0)
if (dur < 10 || dur > 240) { toast.add({ severity: 'warn', summary: 'Ritmo', detail: 'Duração deve ser entre 10 e 240 min.', life: 3500 }); return }
if (gap < 0 || gap > 60) { toast.add({ severity: 'warn', summary: 'Ritmo', detail: 'Intervalo deve ser entre 0 e 60 min.', life: 3500 }); return }
if (![0,15,30,45].includes(off)) { toast.add({ severity: 'warn', summary: 'Ritmo', detail: 'Início deve ser :00, :15, :30 ou :45.', life: 3500 }); return }
savingCard.value = 'ritmo'
try {
@@ -427,9 +481,8 @@ async function saveRitmo () {
const { error } = await supabase.from('agenda_configuracoes').upsert({
owner_id: uid,
session_duration_min: dur,
session_break_min: gap,
session_start_offset_min: off
session_duration_min: dur,
session_break_min: gap,
}, { onConflict: 'owner_id' })
if (error) throw error
@@ -498,7 +551,6 @@ function generateSlotsForDay (dayValue) {
const duration = Number(cfg.value.session_duration_min || 50)
const gap = Number(cfg.value.session_break_min || 10)
const offset = Number(cfg.value.session_start_offset_min || 0)
const cycle = Math.max(1, duration + gap)
const out = []
@@ -506,14 +558,12 @@ function generateSlotsForDay (dayValue) {
const wStart = hhmmToMin(w.start)
const wEnd = hhmmToMin(w.end)
let t = wStart
const rem = t % 60
if (rem !== offset) t = t + ((offset - rem + 60) % 60)
while (t < wEnd) {
const aEnd = t + duration
if (aEnd > wEnd) break
const conflict = breaks.find(b => { const bS = hhmmToMin(b.start), bE = hhmmToMin(b.end); return !(aEnd <= bS || t >= bE) })
if (conflict) { t = hhmmToMin(conflict.end); const r2 = t % 60; if (r2 !== offset) t += (offset - r2 + 60) % 60; continue }
if (conflict) { t = hhmmToMin(conflict.end); continue }
out.push({ hhmm: minToHHMM(t), endHHMM: minToHHMM(aEnd) })
t += cycle
}
@@ -624,15 +674,23 @@ const previewFcEvents = computed(() => {
})
const previewBounds = computed(() => {
const active = liveRegras.value.filter(r => r.ativo)
const day = previewDay.value
const active = liveRegras.value.filter(r => r.ativo && (day == null || r.dia_semana === day))
if (!active.length) return { start: '06:00', end: '22:00' }
const start = active.reduce((acc, r) => r.hora_inicio < acc ? r.hora_inicio : acc, active[0].hora_inicio)
const end = active.reduce((acc, r) => r.hora_fim > acc ? r.hora_fim : acc, active[0].hora_fim)
const padded_start = minToHHMM(Math.max(0, hhmmToMin(String(start).slice(0,5)) - 60))
const padded_end = minToHHMM(Math.min(24*60, hhmmToMin(String(end).slice(0,5)) + 60))
return { start: padded_start, end: padded_end }
return { start: floorTo30(String(start).slice(0,5)), end: ceilTo30(String(end).slice(0,5)) }
})
function previewSlotLabelContent (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">:30</span>' }
}
const previewFcOptions = computed(() => {
const day = previewDay.value
const base = day != null ? dateForDayOfWeek(day) : new Date()
@@ -646,8 +704,10 @@ const previewFcOptions = computed(() => {
allDaySlot: false,
slotMinTime: previewBounds.value.start + ':00',
slotMaxTime: previewBounds.value.end + ':00',
slotDuration: '00:30:00',
slotLabelInterval: '01:00',
slotDuration: '00:15:00',
snapDuration: '00:15:00',
slotLabelInterval: '00:30',
slotLabelContent: previewSlotLabelContent,
expandRows: true,
height: 'auto',
editable: false,
@@ -662,6 +722,7 @@ watch(previewFcEvents, async () => { await nextTick(); fcRef.value?.getApi?.()?.
// presets de duração
const durationPresets = [
{ label: '30 min', dur: 30, gap: 0, off: 0 },
{ label: '45 min', dur: 45, gap: 15, off: 0 },
{ label: '50 min', dur: 50, gap: 10, off: 0 },
{ label: '60 min', dur: 60, gap: 0, off: 0 },
@@ -736,6 +797,7 @@ const jornadaEndDate = computed({
<div v-show="expandedCard === 'jornada'" class="cfg-card__body">
<div class="border-t border-[var(--surface-border)] pt-4">
<!-- Início das sessões (alinhamento de horário) -->
<!-- Dias da semana -->
<div class="mb-5">
<div class="cfg-label mb-2">Quais dias você trabalha?</div>
@@ -761,14 +823,14 @@ const jornadaEndDate = computed({
<button
class="toggle-opt"
:class="jornadaIgualTodos !== false ? 'toggle-opt--active' : ''"
@click="jornadaIgualTodos = true"
@click="switchToIgual"
>
Igual para todos os dias
</button>
<button
class="toggle-opt"
:class="jornadaIgualTodos === false ? 'toggle-opt--active' : ''"
@click="jornadaIgualTodos = false"
@click="switchToDiferente"
>
Diferente por dia
</button>
@@ -779,7 +841,7 @@ const jornadaEndDate = computed({
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-color-secondary)]">Das</span>
<div class="w-32">
<DatePicker v-model="jornadaStartDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
<DatePicker v-model="jornadaStartDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
@@ -787,7 +849,7 @@ const jornadaEndDate = computed({
</div>
<span class="text-sm text-[var(--text-color-secondary)]">até</span>
<div class="w-32">
<DatePicker v-model="jornadaEndDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
<DatePicker v-model="jornadaEndDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
@@ -808,7 +870,7 @@ const jornadaEndDate = computed({
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[d.value]?.inicio)"
@update:modelValue="v => { const h = dateToHHMM(v); if (h) { jornadaPorDia[d.value] = { ...jornadaPorDia[d.value], inicio: h }; previewDay = d.value } }"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false"
>
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
@@ -820,7 +882,7 @@ const jornadaEndDate = computed({
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[d.value]?.fim)"
@update:modelValue="v => { const h = dateToHHMM(v); if (h) { jornadaPorDia[d.value] = { ...jornadaPorDia[d.value], fim: h }; previewDay = d.value } }"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false"
>
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
@@ -895,7 +957,8 @@ const jornadaEndDate = computed({
@click="applyDurationPreset(p)"
>
{{ p.label }}
<span v-if="p.gap" class="text-xs opacity-70 ml-1">· {{ p.gap }}min intervalo</span>
<span v-if="p.gap > 0" class="text-xs opacity-70 ml-1">· {{ p.gap }}min intervalo</span>
<span v-else class="text-xs opacity-70 ml-1">· sem pausa</span>
</button>
<button
class="preset-chip"
@@ -913,7 +976,7 @@ const jornadaEndDate = computed({
<div class="flex flex-row gap-6">
<div class="flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Duração</label>
<DatePicker v-model="durationDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
<DatePicker v-model="durationDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="5" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
@@ -921,7 +984,7 @@ const jornadaEndDate = computed({
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Intervalo</label>
<DatePicker v-model="breakDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
<DatePicker v-model="breakDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="5" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
@@ -930,25 +993,6 @@ const jornadaEndDate = computed({
</div>
</div>
<!-- Alinhamento de início (sempre visível) -->
<div class="mb-5">
<div class="cfg-label mb-2">Início das sessões na jornada de trabalho</div>
<div class="flex gap-2 flex-wrap">
<button
v-for="off in [0, 15, 30, 45]"
:key="off"
class="preset-chip"
:class="cfg.session_start_offset_min === off ? 'preset-chip--active' : ''"
@click="cfg.session_start_offset_min = off"
>
:{{ String(off).padStart(2,'0') }}
</button>
</div>
<p class="text-xs text-[var(--text-color-secondary)] mt-2">
Ex.: :00 = sessões em 08:00, 09:00 · :30 = em 08:30, 09:30
</p>
</div>
<div class="flex justify-end">
<Button
label="Salvar ritmo"
@@ -987,6 +1031,15 @@ const jornadaEndDate = computed({
<div v-show="expandedCard === 'online'" class="cfg-card__body">
<div class="border-t border-[var(--surface-border)] pt-4">
<!-- Aviso slots órfãos -->
<div v-if="orphanSlotDays.length" class="flex items-start gap-2 mb-4 p-3 rounded-xl bg-[var(--yellow-50)] border border-[var(--yellow-200)] text-sm text-[var(--yellow-800)]">
<i class="pi pi-exclamation-triangle mt-0.5 shrink-0" />
<span>
slots configurados para <b>{{ orphanSlotDays.join(', ') }}</b>, mas esses dias não estão mais na sua jornada.
Eles serão removidos automaticamente ao salvar a jornada.
</span>
</div>
<!-- Toggle ativo -->
<div class="flex items-center justify-between mb-5 p-4 rounded-2xl bg-[var(--surface-ground)]">
<div>
@@ -1131,7 +1184,7 @@ const jornadaEndDate = computed({
<!-- FullCalendar -->
<div v-if="previewDay != null && !loading" class="p-2">
<FullCalendar ref="fcRef" :key="`preview-${previewDay}`" :options="previewFcOptions" />
<FullCalendar ref="fcRef" :key="`preview-${previewDay}-${previewBounds.start}-${previewBounds.end}`" :options="previewFcOptions" />
</div>
<div v-else class="flex items-center justify-center py-16 text-[var(--text-color-secondary)] text-sm">
Selecione um dia de trabalho para ver o preview.
@@ -1142,6 +1195,26 @@ const jornadaEndDate = computed({
</div>
</template>
<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;
}
.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;
}
</style>
<style scoped>
/* ── Cards ─────────────────────────────────────────────────── */
.cfg-card {