Agenda, Agendador, Configurações
This commit is contained in:
@@ -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>
|
||||
Há 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 {
|
||||
|
||||
Reference in New Issue
Block a user