Agenda, Agendador, Configurações
This commit is contained in:
@@ -11,9 +11,9 @@ 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: 'America/Sao_Paulo' },
|
||||
timezone: { type: String, default: 'local' },
|
||||
|
||||
slotDuration: { type: String, default: '00:30:00' },
|
||||
slotDuration: { type: String, default: '00:15:00' },
|
||||
slotMinTime: { type: String, default: '06:00:00' },
|
||||
slotMaxTime: { type: String, default: '22:00:00' },
|
||||
|
||||
@@ -35,7 +35,14 @@ const props = defineProps({
|
||||
clinicSubtitle: { type: String, default: 'Agenda da clínica' },
|
||||
|
||||
// subtitle terapeutas
|
||||
staffSubtitle: { type: String, default: 'Visão diária operacional' }
|
||||
staffSubtitle: { type: String, default: 'Visão diária operacional' },
|
||||
|
||||
// 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: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -44,7 +51,6 @@ const emit = defineEmits([
|
||||
'eventClick',
|
||||
'eventDrop',
|
||||
'eventResize',
|
||||
// ✅ debug
|
||||
'debugColumn'
|
||||
])
|
||||
|
||||
@@ -89,13 +95,18 @@ function forEachApi (fn) {
|
||||
}
|
||||
}
|
||||
|
||||
function goToday () { forEachApi(api => api.today()) }
|
||||
function prev () { forEachApi(api => api.prev()) }
|
||||
function next () { forEachApi(api => api.next()) }
|
||||
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) // anti “voltar dia”
|
||||
dt.setHours(12, 0, 0, 0)
|
||||
trackedDate = new Date(dt)
|
||||
forEachApi(api => api.gotoDate(dt))
|
||||
}
|
||||
|
||||
@@ -107,16 +118,70 @@ function setMode () {}
|
||||
|
||||
defineExpose({ goToday, prev, next, gotoDate, setView, setMode })
|
||||
|
||||
// ── Dias bloqueados ────────────────────────────────────────────
|
||||
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']
|
||||
}))
|
||||
)
|
||||
|
||||
// ISO do dia sendo exibido atualmente — atualizado pelo datesSet
|
||||
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 eventsFor (ownerId) {
|
||||
const list = props.events || []
|
||||
return list.filter(e => String(e?.extendedProps?.owner_id || '') === String(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)
|
||||
|
||||
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 })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${arg.startStr}__${arg.endStr}__${arg.view?.type || ''}`
|
||||
if (key === lastRangeKey) return
|
||||
lastRangeKey = key
|
||||
@@ -127,13 +192,13 @@ function onDatesSet (arg) {
|
||||
startStr: arg.startStr,
|
||||
endStr: arg.endStr,
|
||||
viewType: arg.view.type,
|
||||
currentDate: arg.view?.currentStart || arg.start
|
||||
currentDate: cd
|
||||
})
|
||||
|
||||
if (suppressSync) return
|
||||
suppressSync = true
|
||||
|
||||
const masterDate = arg.view?.currentStart || arg.start
|
||||
const masterDate = cd
|
||||
forEachApi((api) => {
|
||||
const cur = api.view?.currentStart
|
||||
if (!cur || !masterDate) return
|
||||
@@ -148,6 +213,16 @@ watch(() => props.view, async () => {
|
||||
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?.()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- helpers UI ----------
|
||||
function colSubtitle (p) {
|
||||
return p?.__kind === 'clinic' ? props.clinicSubtitle : props.staffSubtitle
|
||||
@@ -179,8 +254,20 @@ function buildFcOptions (ownerId) {
|
||||
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>` }
|
||||
},
|
||||
|
||||
businessHours: props.businessHours,
|
||||
|
||||
height: 'auto',
|
||||
expandRows: true,
|
||||
@@ -191,7 +278,47 @@ function buildFcOptions (ownerId) {
|
||||
|
||||
eventClick: (info) => emit('eventClick', info),
|
||||
eventDrop: (info) => emit('eventDrop', info),
|
||||
eventResize: (info) => emit('eventResize', 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 esc = (s) => String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
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 obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
|
||||
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
|
||||
|
||||
return {
|
||||
html: `<div class="ev-custom">
|
||||
${avatarHtml}
|
||||
<div class="ev-body">
|
||||
${timeHtml}
|
||||
<div class="ev-title">${esc(title)}</div>
|
||||
${obsHtml}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
base.select = (selection) => {
|
||||
@@ -230,6 +357,12 @@ function buildFcOptions (ownerId) {
|
||||
</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)"
|
||||
@@ -260,6 +393,12 @@ function buildFcOptions (ownerId) {
|
||||
</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))"
|
||||
@@ -273,48 +412,120 @@ function buildFcOptions (ownerId) {
|
||||
</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%;
|
||||
}
|
||||
.ev-avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
.mosaic-shell {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
@media (min-width: 768px){
|
||||
.mosaic-shell{ padding: 12px; }
|
||||
@media (min-width: 768px) {
|
||||
.mosaic-shell { padding: 12px; }
|
||||
}
|
||||
|
||||
.mosaic-fixed{
|
||||
.mosaic-fixed {
|
||||
flex: 0 0 auto;
|
||||
width: 420px;
|
||||
min-width: 320px;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.mosaic-scroll{
|
||||
.mosaic-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mosaic-grid{
|
||||
display:grid;
|
||||
.mosaic-grid {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap:12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mosaic-col{
|
||||
.mosaic-col {
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: color-mix(in_srgb, var(--surface-card), transparent 12%);
|
||||
overflow:hidden;
|
||||
background: color-mix(in srgb, var(--surface-card), transparent 12%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mosaic-col-head{
|
||||
.mosaic-col-head {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
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);
|
||||
}
|
||||
</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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
/* Garante opacidade total nos dias bloqueados (background event do FullCalendar) */
|
||||
.fc-blocked-day {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,603 @@
|
||||
<!-- src/features/agenda/components/BloqueioDialog.vue -->
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useFeriados } from '@/composables/useFeriados'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import DatePicker from 'primevue/datepicker'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
mode: { type: String, default: 'horario' }, // 'horario' | 'periodo' | 'dia' | 'feriados'
|
||||
workRules: { type: Array, default: () => [] },
|
||||
settings: { type: Object, default: null },
|
||||
ownerId: { type: String, default: '' },
|
||||
tenantId: { type: [String, null], default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'saved'])
|
||||
const toast = useToast()
|
||||
const saving = ref(false)
|
||||
|
||||
// ── Feriados ──────────────────────────────────────────────────────────────
|
||||
const { proximos, load: loadFeriados, criar: criarFeriado } = useFeriados()
|
||||
|
||||
// ── Mode: horario ─────────────────────────────────────────────────────────
|
||||
const todayDow = new Date().getDay()
|
||||
|
||||
const timeSlots = computed(() => {
|
||||
const rule = props.workRules.find(r => Number(r.dia_semana) === todayDow)
|
||||
if (!rule) return []
|
||||
const dur = props.settings?.session_duration_min ?? props.settings?.duracao_padrao_minutos ?? 50
|
||||
const [sh, sm] = String(rule.hora_inicio || '08:00').slice(0, 5).split(':').map(Number)
|
||||
const [eh, em] = String(rule.hora_fim || '18:00').slice(0, 5).split(':').map(Number)
|
||||
const startMin = sh * 60 + sm
|
||||
const endMin = eh * 60 + em
|
||||
const slots = []
|
||||
for (let t = startMin; t + dur <= endMin; t += dur) {
|
||||
const h1 = Math.floor(t / 60), m1 = t % 60
|
||||
const t2 = t + dur, h2 = Math.floor(t2 / 60), m2 = t2 % 60
|
||||
const hi = `${String(h1).padStart(2, '0')}:${String(m1).padStart(2, '0')}`
|
||||
const hf = `${String(h2).padStart(2, '0')}:${String(m2).padStart(2, '0')}`
|
||||
slots.push({ label: `${hi} – ${hf}`, hora_inicio: hi, hora_fim: hf })
|
||||
}
|
||||
return slots
|
||||
})
|
||||
|
||||
const selectedSlotIndices = ref(new Set())
|
||||
|
||||
function toggleSlot (idx) {
|
||||
const s = new Set(selectedSlotIndices.value)
|
||||
if (s.has(idx)) s.delete(idx)
|
||||
else s.add(idx)
|
||||
selectedSlotIndices.value = s
|
||||
}
|
||||
|
||||
// ── Mode: periodo ─────────────────────────────────────────────────────────
|
||||
const periodos = ref([
|
||||
{ label: 'Manhã', sub: '06:00 – 12:00', icon: 'pi pi-sun', hora_inicio: '06:00', hora_fim: '12:00', selected: false },
|
||||
{ label: 'Tarde', sub: '12:00 – 18:00', icon: 'pi pi-cloud-sun', hora_inicio: '12:00', hora_fim: '18:00', selected: false },
|
||||
{ label: 'Noite', sub: '18:00 – 23:00', icon: 'pi pi-moon', hora_inicio: '18:00', hora_fim: '23:00', selected: false }
|
||||
])
|
||||
const periodoDate = ref(new Date())
|
||||
|
||||
// ── Mode: dia ─────────────────────────────────────────────────────────────
|
||||
const selectedDays = ref([])
|
||||
|
||||
// ── Mode: feriados ────────────────────────────────────────────────────────
|
||||
const upcomingFeriados = computed(() => proximos(90))
|
||||
const feriadosDecisao = ref({}) // { [iso]: true (trabalha) | false (não trabalha) }
|
||||
|
||||
// Dialog feriado municipal
|
||||
const fdlgOpen = ref(false)
|
||||
const fsaving = ref(false)
|
||||
const fform = ref({ nome: '', data: null, observacao: '' })
|
||||
|
||||
// ── Reset ao abrir ────────────────────────────────────────────────────────
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (!v) return
|
||||
selectedSlotIndices.value = new Set()
|
||||
periodos.value.forEach(p => { p.selected = false })
|
||||
periodoDate.value = new Date()
|
||||
selectedDays.value = []
|
||||
feriadosDecisao.value = {}
|
||||
if (props.mode === 'feriados' && props.tenantId) {
|
||||
loadFeriados(props.tenantId)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function toISO (d) {
|
||||
if (!d) return null
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function fmtDateLong (iso) {
|
||||
if (!iso) return ''
|
||||
const [y, m, d] = iso.split('-').map(Number)
|
||||
return new Date(y, m - 1, d).toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' })
|
||||
}
|
||||
|
||||
function setFeriadoDecisao (data, rawVal) {
|
||||
const val = rawVal === 'sim' ? true : rawVal === 'nao' ? false : undefined
|
||||
const copy = { ...feriadosDecisao.value }
|
||||
if (val === undefined) delete copy[data]
|
||||
else copy[data] = val
|
||||
feriadosDecisao.value = copy
|
||||
}
|
||||
|
||||
// ── UI ────────────────────────────────────────────────────────────────────
|
||||
const dialogTitle = computed(() => ({
|
||||
horario: 'Bloquear por Horário',
|
||||
periodo: 'Bloquear por Período',
|
||||
dia: 'Bloquear por Dia',
|
||||
feriados: 'Bloqueio por Feriados'
|
||||
}[props.mode] || 'Bloquear'))
|
||||
|
||||
const canConfirm = computed(() => {
|
||||
if (props.mode === 'horario') return selectedSlotIndices.value.size > 0
|
||||
if (props.mode === 'periodo') return periodos.value.some(p => p.selected)
|
||||
if (props.mode === 'dia') return selectedDays.value.length > 0
|
||||
if (props.mode === 'feriados') return Object.values(feriadosDecisao.value).some(v => v === false)
|
||||
return false
|
||||
})
|
||||
|
||||
function close () { emit('update:modelValue', false) }
|
||||
|
||||
// ── Confirmar bloqueio ────────────────────────────────────────────────────
|
||||
async function confirmar () {
|
||||
if (!props.ownerId || !props.tenantId) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Configurações da agenda não carregadas.', life: 3000 })
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const base = {
|
||||
owner_id: props.ownerId,
|
||||
tenant_id: props.tenantId,
|
||||
tipo: 'bloqueio',
|
||||
recorrente: false
|
||||
}
|
||||
const rows = []
|
||||
|
||||
if (props.mode === 'horario') {
|
||||
const iso = toISO(new Date())
|
||||
timeSlots.value.forEach((slot, idx) => {
|
||||
if (!selectedSlotIndices.value.has(idx)) return
|
||||
rows.push({ ...base,
|
||||
titulo: `Bloqueio ${slot.hora_inicio}–${slot.hora_fim}`,
|
||||
data_inicio: iso,
|
||||
data_fim: iso,
|
||||
hora_inicio: slot.hora_inicio,
|
||||
hora_fim: slot.hora_fim,
|
||||
origem: 'agenda_horario'
|
||||
})
|
||||
})
|
||||
} else if (props.mode === 'periodo') {
|
||||
const iso = toISO(periodoDate.value)
|
||||
periodos.value.filter(p => p.selected).forEach(p => {
|
||||
rows.push({ ...base,
|
||||
titulo: `Bloqueio ${p.label}`,
|
||||
data_inicio: iso,
|
||||
data_fim: iso,
|
||||
hora_inicio: p.hora_inicio,
|
||||
hora_fim: p.hora_fim,
|
||||
origem: 'agenda_periodo'
|
||||
})
|
||||
})
|
||||
} else if (props.mode === 'dia') {
|
||||
selectedDays.value.forEach(d => {
|
||||
rows.push({ ...base,
|
||||
titulo: 'Dia bloqueado',
|
||||
data_inicio: toISO(d),
|
||||
data_fim: toISO(d),
|
||||
hora_inicio: null,
|
||||
hora_fim: null,
|
||||
origem: 'agenda_dia'
|
||||
})
|
||||
})
|
||||
} else if (props.mode === 'feriados') {
|
||||
for (const [data, trabalha] of Object.entries(feriadosDecisao.value)) {
|
||||
if (trabalha !== false) continue
|
||||
const f = upcomingFeriados.value.find(f => f.data === data)
|
||||
rows.push({ ...base,
|
||||
titulo: f ? `Feriado: ${f.nome}` : 'Feriado bloqueado',
|
||||
data_inicio: data,
|
||||
data_fim: data,
|
||||
hora_inicio: null,
|
||||
hora_fim: null,
|
||||
origem: 'agenda_feriado'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
toast.add({ severity: 'warn', summary: 'Seleção vazia', detail: 'Selecione ao menos um item para bloquear.', life: 2500 })
|
||||
return
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('agenda_bloqueios').insert(rows)
|
||||
if (error) throw error
|
||||
|
||||
// Marcar sessões existentes como "remarcar"
|
||||
await marcarSessoesParaRemarcar(rows)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Bloqueio criado',
|
||||
detail: `${rows.length} bloqueio(s) registrado(s). Sessões existentes marcadas para reagendamento.`,
|
||||
life: 4500
|
||||
})
|
||||
emit('saved')
|
||||
close()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao criar bloqueio.', life: 4000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function marcarSessoesParaRemarcar (bloqueios) {
|
||||
// Para cada bloqueio, tenta marcar sessões existentes como 'remarcar'
|
||||
for (const b of bloqueios) {
|
||||
try {
|
||||
let query = supabase
|
||||
.from('agenda_eventos')
|
||||
.update({ status: 'remarcar' })
|
||||
.eq('owner_id', props.ownerId)
|
||||
.eq('tipo', 'sessao')
|
||||
.gte('inicio_em', `${b.data_inicio}T00:00:00`)
|
||||
.lte('inicio_em', `${b.data_fim}T23:59:59`)
|
||||
|
||||
if (b.hora_inicio && b.hora_fim) {
|
||||
// filtra pela hora aproximada — comparação UTC simplificada
|
||||
query = query
|
||||
.gte('inicio_em', `${b.data_inicio}T${b.hora_inicio}:00`)
|
||||
.lte('inicio_em', `${b.data_inicio}T${b.hora_fim}:00`)
|
||||
}
|
||||
|
||||
await query
|
||||
} catch { /* ignora erros parciais — o bloqueio já foi criado */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feriado municipal ─────────────────────────────────────────────────────
|
||||
async function salvarFeriadoMunicipal () {
|
||||
if (!fform.value.nome || !fform.value.data) return
|
||||
fsaving.value = true
|
||||
const iso = toISO(fform.value.data)
|
||||
try {
|
||||
await criarFeriado({
|
||||
tenant_id: props.tenantId,
|
||||
owner_id: props.ownerId,
|
||||
tipo: 'municipal',
|
||||
nome: fform.value.nome.trim(),
|
||||
data: iso,
|
||||
observacao: fform.value.observacao || null,
|
||||
bloqueia_sessoes: true
|
||||
})
|
||||
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
|
||||
// Auto-marca como "não trabalha" para facilitar
|
||||
feriadosDecisao.value = { ...feriadosDecisao.value, [iso]: false }
|
||||
fdlgOpen.value = false
|
||||
fform.value = { nome: '', data: null, observacao: '' }
|
||||
// Recarrega feriados
|
||||
if (props.tenantId) loadFeriados(props.tenantId)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||
} finally {
|
||||
fsaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Dialog principal -->
|
||||
<Dialog
|
||||
:visible="modelValue"
|
||||
modal
|
||||
:draggable="false"
|
||||
:header="dialogTitle"
|
||||
:style="{ width: '540px', maxWidth: '96vw' }"
|
||||
@update:visible="emit('update:modelValue', $event)"
|
||||
>
|
||||
<!-- ── Banner de aviso ────────────────────────────────── -->
|
||||
<div class="blq-warning mb-4">
|
||||
<i class="pi pi-exclamation-triangle blq-warning__icon" />
|
||||
<div class="text-sm leading-relaxed">
|
||||
<b>Atenção:</b> sessões existentes nos períodos bloqueados serão marcadas como
|
||||
<b>Remarcar</b> e os pacientes receberão aviso por e-mail/SMS para reagendamento.<br />
|
||||
<span class="opacity-70 text-xs">O bloqueio prevalece sobre qualquer compromisso já agendado.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Modo: Horário ════════════════════════════════════ -->
|
||||
<div v-if="mode === 'horario'" class="flex flex-col gap-3">
|
||||
<p class="text-sm text-[var(--text-color-secondary)]">
|
||||
Selecione os horários de <b>hoje</b> que deseja bloquear (baseados na sua jornada).
|
||||
Presencial e online serão bloqueados simultaneamente.
|
||||
</p>
|
||||
|
||||
<div v-if="timeSlots.length === 0" class="blq-empty">
|
||||
<i class="pi pi-info-circle" />
|
||||
Hoje não é um dia de trabalho configurado na agenda.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="(slot, idx) in timeSlots"
|
||||
:key="idx"
|
||||
class="blq-chip"
|
||||
:class="{ 'blq-chip--on': selectedSlotIndices.has(idx) }"
|
||||
type="button"
|
||||
@click="toggleSlot(idx)"
|
||||
>
|
||||
<i class="pi pi-clock text-xs" />
|
||||
{{ slot.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="selectedSlotIndices.size > 0" class="text-xs text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-lock mr-1" style="color:var(--red-500)" />
|
||||
{{ selectedSlotIndices.size }} horário(s) selecionado(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ══ Modo: Período ════════════════════════════════════ -->
|
||||
<div v-else-if="mode === 'periodo'" class="flex flex-col gap-4">
|
||||
<p class="text-sm text-[var(--text-color-secondary)]">
|
||||
Selecione o dia e os períodos que deseja bloquear.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label class="blq-label">Data *</label>
|
||||
<DatePicker
|
||||
v-model="periodoDate"
|
||||
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 class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="p in periodos"
|
||||
:key="p.label"
|
||||
class="blq-period-card"
|
||||
:class="{ 'blq-period-card--on': p.selected }"
|
||||
type="button"
|
||||
@click="p.selected = !p.selected"
|
||||
>
|
||||
<i :class="p.icon" class="text-xl mb-1" />
|
||||
<span class="font-semibold text-sm">{{ p.label }}</span>
|
||||
<span class="text-xs opacity-60">{{ p.sub }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Modo: Dia ════════════════════════════════════════ -->
|
||||
<div v-else-if="mode === 'dia'" class="flex flex-col gap-3">
|
||||
<p class="text-sm text-[var(--text-color-secondary)]">
|
||||
Clique nos dias que deseja bloquear. O dia inteiro ficará indisponível para agendamentos.
|
||||
</p>
|
||||
|
||||
<Calendar
|
||||
v-model="selectedDays"
|
||||
inline
|
||||
selectionMode="multiple"
|
||||
:minDate="new Date()"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<p v-if="selectedDays.length" class="text-xs text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-lock mr-1" style="color:var(--red-500)" />
|
||||
{{ selectedDays.length }} dia(s) selecionado(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ══ Modo: Feriados ═══════════════════════════════════ -->
|
||||
<div v-else-if="mode === 'feriados'" class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p class="text-sm text-[var(--text-color-secondary)] m-0">
|
||||
Próximos feriados (90 dias). Indique se vai trabalhar em cada um.
|
||||
</p>
|
||||
<Button
|
||||
label="+ Feriado municipal"
|
||||
icon="pi pi-map-marker"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="shrink-0 rounded-full"
|
||||
@click="fdlgOpen = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="upcomingFeriados.length === 0" class="blq-empty">
|
||||
<i class="pi pi-calendar" />
|
||||
Nenhum feriado nos próximos 90 dias.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2 max-h-[320px] overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="f in upcomingFeriados"
|
||||
:key="f.data"
|
||||
class="blq-feriado-row"
|
||||
:class="{ 'blq-feriado-row--blocked': feriadosDecisao[f.data] === false }"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm truncate">{{ f.nome }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] capitalize">
|
||||
{{ fmtDateLong(f.data) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0 flex-wrap justify-end">
|
||||
<span class="text-xs text-[var(--text-color-secondary)] whitespace-nowrap">Vai trabalhar?</span>
|
||||
<SelectButton
|
||||
:modelValue="feriadosDecisao[f.data] === true ? 'sim' : feriadosDecisao[f.data] === false ? 'nao' : null"
|
||||
:options="[{ label: 'Sim', value: 'sim' }, { label: 'Não', value: 'nao' }]"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="true"
|
||||
size="small"
|
||||
@update:modelValue="(v) => setFeriadoDecisao(f.data, v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Footer ─────────────────────────────────────────── -->
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="close" />
|
||||
<Button
|
||||
label="Confirmar Bloqueio"
|
||||
icon="pi pi-lock"
|
||||
severity="danger"
|
||||
:loading="saving"
|
||||
:disabled="!canConfirm"
|
||||
@click="confirmar"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Dialog feriado municipal -->
|
||||
<Dialog
|
||||
v-model:visible="fdlgOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Cadastrar feriado municipal"
|
||||
:style="{ width: '420px' }"
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-1">
|
||||
<div>
|
||||
<label class="blq-label">Nome *</label>
|
||||
<InputText
|
||||
v-model="fform.nome"
|
||||
class="w-full mt-1"
|
||||
placeholder="Ex.: Aniversário da cidade, Padroeiro…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="blq-label">Data *</label>
|
||||
<DatePicker
|
||||
v-model="fform.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="blq-label">Observação <span class="opacity-60">(opcional)</span></label>
|
||||
<Textarea v-model="fform.observacao" class="w-full mt-1" rows="2" autoResize />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="fdlgOpen = false" />
|
||||
<Button
|
||||
label="Cadastrar"
|
||||
icon="pi pi-check"
|
||||
:disabled="!fform.nome || !fform.data"
|
||||
:loading="fsaving"
|
||||
@click="salvarFeriadoMunicipal"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Aviso ─────────────────────────────────────────────── */
|
||||
.blq-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem;
|
||||
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);
|
||||
}
|
||||
.blq-warning__icon {
|
||||
color: var(--red-500, #ef4444);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Label ─────────────────────────────────────────────── */
|
||||
.blq-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* ── Empty ─────────────────────────────────────────────── */
|
||||
.blq-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1rem;
|
||||
border-radius: 0.875rem;
|
||||
border: 1px dashed var(--surface-border);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Chips de horário ──────────────────────────────────── */
|
||||
.blq-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.4rem 0.875rem;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.14s;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.blq-chip:hover {
|
||||
border-color: var(--red-300, #fca5a5);
|
||||
background: color-mix(in srgb, var(--red-400, #f87171) 8%, var(--surface-card));
|
||||
}
|
||||
.blq-chip--on {
|
||||
border-color: var(--red-500, #ef4444) !important;
|
||||
background: color-mix(in srgb, var(--red-500, #ef4444) 15%, var(--surface-card)) !important;
|
||||
color: var(--red-700, #b91c1c);
|
||||
}
|
||||
|
||||
/* ── Cards de período ──────────────────────────────────── */
|
||||
.blq-period-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
padding: 1.25rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
border: 1.5px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
cursor: pointer;
|
||||
transition: all 0.14s;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.blq-period-card:hover {
|
||||
border-color: var(--red-300, #fca5a5);
|
||||
background: color-mix(in srgb, var(--red-400, #f87171) 8%, var(--surface-card));
|
||||
}
|
||||
.blq-period-card--on {
|
||||
border-color: var(--red-500, #ef4444) !important;
|
||||
background: color-mix(in srgb, var(--red-500, #ef4444) 15%, var(--surface-card)) !important;
|
||||
color: var(--red-700, #b91c1c);
|
||||
}
|
||||
|
||||
/* ── Feriados ──────────────────────────────────────────── */
|
||||
.blq-feriado-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 0.875rem;
|
||||
border: 1.5px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
transition: all 0.14s;
|
||||
}
|
||||
.blq-feriado-row--blocked {
|
||||
border-color: var(--red-500, #ef4444);
|
||||
background: color-mix(in srgb, var(--red-500, #ef4444) 10%, var(--surface-card));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,442 @@
|
||||
<!-- src/features/agenda/components/ProximosFeriadosCard.vue -->
|
||||
<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'
|
||||
|
||||
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: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['bloqueado'])
|
||||
|
||||
const router = useRouter()
|
||||
const tenantStore = useTenantStore()
|
||||
const toast = useToast()
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
onMounted(boot)
|
||||
|
||||
// ── Feriados do mês atual ────────────────────────────────────
|
||||
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]
|
||||
|
||||
// ── Dias de trabalho (dow) ────────────────────────────────────
|
||||
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)
|
||||
}
|
||||
|
||||
// ── Bloqueios já existentes para o mês ───────────────────────
|
||||
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 }
|
||||
}
|
||||
|
||||
watch(_ownerId, v => { if (v) loadBloqueiosMes() })
|
||||
onMounted(() => { if (_ownerId.value) loadBloqueiosMes() })
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialog cadastro municipal ─────────────────────────────────
|
||||
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)
|
||||
|
||||
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')}`
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
</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 justify-center py-3">
|
||||
<i class="pi pi-spinner pi-spin opacity-40" />
|
||||
</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 / já bloqueado -->
|
||||
<template v-if="isDiaUtil(f.data)">
|
||||
<!-- Já 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>
|
||||
|
||||
<!-- ── 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" />
|
||||
Já 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;
|
||||
}
|
||||
.pfc-lock--idle {
|
||||
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);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.pfc-lock--done {
|
||||
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 */
|
||||
}
|
||||
.pfc-confirm__icon {
|
||||
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;
|
||||
}
|
||||
.pfc-expand-enter-from,
|
||||
.pfc-expand-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user