86311ef305
Sprints 04-29 + 04-30 acumuladas. - MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/ Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed. - MelissaEmbed: wrapper generico que injeta layout-variant=melissa e remove cromos pra reaproveitar Pages tradicionais. - 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes, Conversas, Embed, Grupos, Medicos, Recorrencias, Tags. - Dialog blueprint atualizado: bg-gray-100 (hardcoded light) -> bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em 9 arquivos. Anti-pattern documentado. - PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name), toggle vertical/abas com persist localStorage, sticky margin-top. - Surface picker no popover do MelissaLayout (8 swatches). - useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos. - Migration: status agenda remarcado/confirmado. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
429 lines
18 KiB
Vue
429 lines
18 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/features/agenda/components/ProximosFeriadosCard.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<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 { useLayout } from '@/layout/composables/layout';
|
|
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 { layoutConfig } = useLayout();
|
|
|
|
// Quando o layout ativo é Melissa, "Ver todos os feriados" leva pra rota
|
|
// interna /melissa/bloqueios (abre MelissaConfiguracoes na seção embed
|
|
// de Bloqueios). Caso contrário usa a rota tradicional de configurações.
|
|
const verTodosFeriadosRoute = computed(() =>
|
|
layoutConfig.variant === 'melissa' ? '/melissa/bloqueios' : '/configuracoes/bloqueios'
|
|
);
|
|
|
|
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 lastDay = new Date(ano, mesAtual, 0).getDate();
|
|
const start = `${ano}-${String(mesAtual).padStart(2, '0')}-01`;
|
|
const end = `${ano}-${String(mesAtual).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
|
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 'remarcado'
|
|
await supabase.from('agenda_eventos').update({ status: 'remarcado' }).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="pfc-month-badge">{{ nomeMes }}</span>
|
|
</div>
|
|
|
|
<!-- Lista -->
|
|
<div class="px-4 py-3">
|
|
<div v-if="loading" class="flex flex-col gap-2 py-1">
|
|
<Skeleton v-for="n in 3" :key="n" height="2rem" class="rounded" />
|
|
</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">
|
|
<p class="text-xs font-semibold mb-0.5">
|
|
<i class="pi pi-exclamation-triangle pfc-confirm__icon" />
|
|
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 class="pfc-confirm__actions flex gap-1.5">
|
|
<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(verTodosFeriadosRoute)" />
|
|
</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 {
|
|
/* sem display:flex — texto flui em bloco; botões ganham margin-top
|
|
pra distanciar do parágrafo. */
|
|
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);
|
|
}
|
|
.pfc-confirm__icon {
|
|
color: var(--red-500, #ef4444);
|
|
margin-right: 0.375rem;
|
|
font-size: 0.8rem;
|
|
vertical-align: middle;
|
|
}
|
|
/* Espaço entre os botões "Não/Bloquear" e o texto acima. */
|
|
.pfc-confirm__actions {
|
|
margin-top: 10px;
|
|
}
|
|
|
|
/* ── Mês atual no header (badge primary) ─────────────────── */
|
|
.pfc-month-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 2px 10px;
|
|
border-radius: 999px;
|
|
background: var(--p-primary-color);
|
|
color: var(--p-primary-contrast-color, white);
|
|
border: 1px solid var(--p-primary-color);
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.02em;
|
|
text-transform: capitalize;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* ── 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>
|