Files
agenciapsilmno/src/features/agenda/components/BloqueioDialog.vue
T
Leonardo a7f6bcbe66 F3 schema-per-tenant: frontend usa tenantDb() pra tabelas tenant
- useTenantDb composable + lib/supabase/tenantClient (tenantDb/tenantSchemaName)
- tenantStore: getters activeTenantSlug/activeTenantSchema; my_tenants() RPC
  passa a devolver slug+nome (migration 07)
- codemod scripts/codemod-tenant-db.py: supabase.from('<84 tabelas + 6 views
  tenant>') -> tenantDb().from(...) em 139 arquivos (777 chamadas), remove
  .eq('tenant_id') das cadeias tenant (173)
- passada manual (4 agentes): remove tenant_id de payloads insert/upsert/update,
  selects, .or/.is de defaults; onConflict ajustado pros uniques sem tenant_id
  (singletons usam 'singleton'); realtime de tabelas tenant aponta pro schema
  do tenant ativo; repos dropam tenant_id defensivamente de payloads externos
- agendaSelects: tenant_id fora do AGENDA_EVENT_SELECT (quebraria PostgREST)
- zero embeds cross-schema (todos FK embeds sao tenant->tenant ou global->global)
- build de producao passa; 67 .js checados

Pendente (fora do escopo F3, sao cross-tenant/anon -> F4/F6):
- AgendadorPublicoPage (anon, resolve tenant por link_slug)
- Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page
  (gerenciam defaults do sistema / views cross-tenant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:44:59 -03:00

523 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/BloqueioDialog.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, watch } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
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,
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 tenantDb().from('agenda_bloqueios').insert(rows);
if (error) throw error;
// Marcar sessões existentes como "remarcado"
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 'remarcado'
for (const b of bloqueios) {
try {
let query = tenantDb().from('agenda_eventos').update({ status: 'remarcado' }).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({
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>