Agenda, Agendador, Configurações
This commit is contained in:
@@ -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