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
@@ -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 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>