Files
agenciapsilmno/src/layout/configuracoes/ConfiguracoesAgendaPage.vue
T
Leonardo 86311ef305 Melissa: hub Configuracoes + Embed + 9 Pages novas + dialog blueprint dark
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>
2026-05-04 11:41:19 -03:00

1715 lines
80 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/layout/configuracoes/ConfiguracoesAgendaPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import DatePicker from 'primevue/datepicker';
import { useToast } from 'primevue/usetoast';
import PausasChipsEditor from '@/components/agenda/PausasChipsEditor.vue';
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
import timeGridPlugin from '@fullcalendar/timegrid';
import FullCalendar from '@fullcalendar/vue3';
const toast = useToast();
const tenantStore = useTenantStore();
// ── Estado global ──────────────────────────────────────────────
const loading = ref(true);
const hydrating = ref(true);
const ownerId = ref(null);
// Card aberto: 'jornada' | 'ritmo' | 'online' | null
const expandedCard = ref(null);
// Qual card está salvando
const savingCard = ref(null);
// ── cfg (agenda_configuracoes) ──────────────────────────────────
const cfg = ref({
owner_id: null,
agenda_view_mode: 'work_hours',
agenda_custom_start: null,
agenda_custom_end: null,
session_duration_min: 40,
session_break_min: 10,
pausas_semanais: [],
online_ativo: false,
setup_clinica_concluido: false,
setup_clinica_concluido_em: null,
jornada_igual_todos: true,
timezone: 'America/Sao_Paulo',
is_conveniado: false
});
const timezones = [
{ label: 'Brasília (BRT)', value: 'America/Sao_Paulo' },
{ label: 'Manaus (AMT)', value: 'America/Manaus' },
{ label: 'Fortaleza (BRT)', value: 'America/Fortaleza' },
{ label: 'Belém (BRT)', value: 'America/Belem' },
{ label: 'Rio Branco (ACT)', value: 'America/Rio_Branco' },
{ label: 'Noronha (FNT)', value: 'America/Noronha' },
{ label: 'Lisboa (WET)', value: 'Europe/Lisbon' }
];
// ── Jornada ────────────────────────────────────────────────────
const regras = ref([]);
const workDays = ref({ 1: false, 2: false, 3: false, 4: false, 5: false, 6: false, 0: false });
const jornadaStart = ref('08:00');
const jornadaEnd = ref('18:00');
const jornadaIgualTodos = ref(true);
const jornadaPorDia = ref({
1: { inicio: '08:00', fim: '18:00' },
2: { inicio: '08:00', fim: '18:00' },
3: { inicio: '08:00', fim: '18:00' },
4: { inicio: '08:00', fim: '18:00' },
5: { inicio: '08:00', fim: '18:00' },
6: { inicio: '08:00', fim: '18:00' },
0: { inicio: '08:00', fim: '18:00' }
});
// Snapshot dos horários por dia do modo "diferente" — preservado ao alternar modos
const jornadaPorDiaSnapshot = ref(null);
// ── Pausas ──────────────────────────────────────────────────────
const pausasGlobais = ref([]);
const pausasPorDia = ref({ 1: [], 2: [], 3: [], 4: [], 5: [], 6: [], 0: [] });
// ── Online ──────────────────────────────────────────────────────
const onlineSlotsByDay = ref({ 1: new Set(), 2: new Set(), 3: new Set(), 4: new Set(), 5: new Set(), 6: new Set(), 0: new Set() });
function resetOnlineSlots() {
onlineSlotsByDay.value = { 1: new Set(), 2: new Set(), 3: new Set(), 4: new Set(), 5: new Set(), 6: new Set(), 0: new Set() };
}
// ── Ritmo preset ────────────────────────────────────────────────
const showAdvancedRitmo = ref(false);
// ── Preview day ─────────────────────────────────────────────────
const previewDay = ref(null);
// ══ CONSTANTES / HELPERS ══════════════════════════════════════
const diasSemana = [
{ label: 'Segunda', short: 'Seg', value: 1 },
{ label: 'Terça', short: 'Ter', value: 2 },
{ label: 'Quarta', short: 'Qua', value: 3 },
{ label: 'Quinta', short: 'Qui', value: 4 },
{ label: 'Sexta', short: 'Sex', value: 5 },
{ label: 'Sábado', short: 'Sáb', value: 6 },
{ label: 'Domingo', short: 'Dom', value: 0 }
];
const selectedDays = computed(() => diasSemana.filter((d) => !!workDays.value[d.value]));
const selectedWeekdays = computed(() => diasSemana.filter((d) => d.value >= 1 && d.value <= 5 && !!workDays.value[d.value]));
function hhmmToMin(hhmm) {
const [h, m] = String(hhmm).split(':').map(Number);
return h * 60 + m;
}
function minToHHMM(min) {
const h = Math.floor(min / 60) % 24;
const m = min % 60;
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
}
function isValidHHMM(v) {
if (!v) return false;
return /^\d{2}:\d{2}$/.test(String(v).trim());
}
function normalizeTime(v) {
if (v == null || v === '') return null;
const s = String(v).trim();
if (/^\d{2}:\d{2}$/.test(s)) return s + ':00';
if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s;
return s;
}
function newId() {
return Math.random().toString(16).slice(2) + Date.now().toString(16);
}
function overlapsInDay(pausas) {
const list = (pausas || []).map((p) => ({ ...p, s: hhmmToMin(p.inicio), e: hhmmToMin(p.fim) })).sort((a, b) => a.s - b.s);
for (let i = 1; i < list.length; i++) if (list[i].s < list[i - 1].e) return true;
return false;
}
function dateToHHMM(date) {
if (!date) return null;
const d = date instanceof Date ? date : new Date(date);
if (Number.isNaN(d.getTime())) return null;
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}
function hhmmToDate(hhmm) {
if (!isValidHHMM(hhmm)) return null;
const [h, m] = String(hhmm).split(':').map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return d;
}
function dateForDayOfWeek(dayValue) {
const now = new Date();
const delta = (dayValue - now.getDay() + 7) % 7;
const d = new Date(now);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + delta);
return d;
}
function floorTo30(hhmm) {
const [h, m] = String(hhmm || '00:00')
.slice(0, 5)
.split(':')
.map(Number);
return String(h).padStart(2, '0') + ':' + (m < 30 ? '00' : '30');
}
function ceilTo30(hhmm) {
const [h, m] = String(hhmm || '00:00')
.slice(0, 5)
.split(':')
.map(Number);
if (m === 0 || m === 30) return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
if (m < 30) return String(h).padStart(2, '0') + ':30';
return String(h + 1).padStart(2, '0') + ':00';
}
function toLocalIsoAt(dateBase, hhmm) {
const [h, m] = String(hhmm).split(':').map(Number);
const d = new Date(dateBase);
d.setHours(h, m, 0, 0);
const p = (v) => String(v).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:00`;
}
// ══ RESUMOS DOS CARDS ═════════════════════════════════════════
const resumoJornada = computed(() => {
const days = selectedDays.value;
if (!days.length) return 'Nenhum dia configurado';
const labels = days.map((d) => d.short).join('·');
if (jornadaIgualTodos.value !== false) {
return `${labels} · ${jornadaStart.value}${jornadaEnd.value}`;
}
return `${labels} · horários variados`;
});
const jornadaOk = computed(() => selectedDays.value.length > 0 && isValidHHMM(jornadaStart.value) && isValidHHMM(jornadaEnd.value) && jornadaEnd.value > jornadaStart.value);
const resumoRitmo = computed(() => {
const d = cfg.value.session_duration_min || 50;
const i = cfg.value.session_break_min || 0;
return `${d} min sessão · ${i > 0 ? `${i} min intervalo` : 'sem intervalo'}`;
});
const resumoOnline = computed(() => {
if (!cfg.value.online_ativo) return 'Desativado';
let total = 0;
let dias = 0;
for (const k of Object.keys(onlineSlotsByDay.value)) {
const size = onlineSlotsByDay.value[k]?.size || 0;
total += size;
if (size > 0) dias++;
}
return `Ativo · ${total} slot${total !== 1 ? 's' : ''} configurado${total !== 1 ? 's' : ''} em ${dias} dia${dias !== 1 ? 's' : ''}`;
});
// Dias que têm slots no banco mas não estão mais na jornada (órfãos)
const orphanSlotDays = computed(() => {
const active = new Set(selectedDays.value.map((d) => d.value));
return diasSemana.filter((d) => !active.has(d.value) && (onlineSlotsByDay.value[d.value]?.size || 0) > 0).map((d) => d.short);
});
// ══ SYNC / HYDRATE ════════════════════════════════════════════
watch(
[selectedDays, jornadaStart, jornadaEnd],
() => {
if (!isValidHHMM(jornadaStart.value) || !isValidHHMM(jornadaEnd.value)) return;
if (jornadaIgualTodos.value !== false) {
// Sync apenas SegSex; Sáb e Dom têm horário próprio
selectedDays.value
.filter((d) => d.value >= 1 && d.value <= 5)
.forEach((d) => {
jornadaPorDia.value[d.value] = { inicio: jornadaStart.value, fim: jornadaEnd.value };
});
}
},
{ immediate: true }
);
// retorna as pausas ao vivo para um dia (sem depender de cfg.value.pausas_semanais)
function getPausasForDay(dayValue) {
if (jornadaIgualTodos.value !== false) return pausasGlobais.value;
return pausasPorDia.value[dayValue] || [];
}
// ── Toggle igual/diferente ─────────────────────────────────────
function switchToIgual() {
// Salva o estado atual de "diferente" no snapshot antes de sair
jornadaPorDiaSnapshot.value = {};
selectedDays.value.forEach((d) => {
if (jornadaPorDia.value[d.value]) jornadaPorDiaSnapshot.value[d.value] = { ...jornadaPorDia.value[d.value] };
});
// jornadaStart/jornadaEnd ficam intocados — eram os valores do modo "igual"
if (isValidHHMM(jornadaStart.value) && isValidHHMM(jornadaEnd.value)) {
// Sync apenas SegSex; Sáb e Dom mantêm horário próprio
selectedDays.value
.filter((d) => d.value >= 1 && d.value <= 5)
.forEach((d) => {
jornadaPorDia.value[d.value] = { inicio: jornadaStart.value, fim: jornadaEnd.value };
});
// Inicializa Sáb/Dom com horário global se ainda não tiver valor
[6, 0].forEach((v) => {
if (workDays.value[v] && !jornadaPorDia.value[v]?.inicio) jornadaPorDia.value[v] = { inicio: jornadaStart.value, fim: jornadaEnd.value };
});
}
// Copia pausas globais para todos os dias e usa apenas pausasGlobais
selectedDays.value.forEach((d) => {
pausasPorDia.value[d.value] = [];
});
jornadaIgualTodos.value = true;
}
function switchToDiferente() {
selectedDays.value.forEach((d) => {
const isWeekend = d.value === 6 || d.value === 0;
const snap = jornadaPorDiaSnapshot.value?.[d.value];
if (snap && isValidHHMM(snap.inicio) && isValidHHMM(snap.fim)) {
// Restaura do snapshot (vale para todos os dias)
jornadaPorDia.value[d.value] = { ...snap };
} else if (!isWeekend) {
// Dia de semana sem snapshot: inicia com 08:0018:00
jornadaPorDia.value[d.value] = { inicio: '08:00', fim: '18:00' };
}
// Sáb/Dom sem snapshot: mantém o valor atual (já configurado no modo "igual")
});
selectedDays.value.forEach((d) => {
pausasPorDia.value[d.value] = pausasGlobais.value.map((p) => ({ ...p, id: newId() }));
});
jornadaIgualTodos.value = false;
}
function hydratePausasFromCfg() {
const byDay = { 1: [], 2: [], 3: [], 4: [], 5: [], 6: [], 0: [] };
for (const p of cfg.value.pausas_semanais || []) {
const d = p.dia_semana;
if (byDay[d]) byDay[d].push({ ...p, id: p.id || newId() });
}
pausasPorDia.value = byDay;
const first = selectedDays.value[0]?.value;
if (jornadaIgualTodos.value !== false) {
pausasGlobais.value = first != null ? (byDay[first] || []).map((p) => ({ ...p })) : [];
}
}
function hydrateWizardFromRegras(dbRegras) {
const map = { 1: false, 2: false, 3: false, 4: false, 5: false, 6: false, 0: false };
const byDay = { ...jornadaPorDia.value };
const actives = (dbRegras || []).filter((r) => r.ativo);
if (!actives.length) {
workDays.value = map;
jornadaIgualTodos.value = true;
regras.value = [];
return;
}
actives.forEach((r) => {
map[r.dia_semana] = true;
byDay[r.dia_semana] = {
inicio: String(r.hora_inicio || '').slice(0, 5),
fim: String(r.hora_fim || '').slice(0, 5)
};
});
workDays.value = map;
jornadaPorDia.value = byDay;
// Usa apenas dias de semana (15) para determinar allSame e o horário padrão.
// Sáb (6) e Dom (0) têm horários próprios e não devem afetar o modo "igual para todos".
const weekdayActives = actives.filter((r) => r.dia_semana >= 1 && r.dia_semana <= 5);
const ref = weekdayActives.length ? weekdayActives[0] : actives[0];
const allSame =
weekdayActives.length >= 2 ? weekdayActives.every((r) => String(r.hora_inicio || '').slice(0, 5) === String(ref.hora_inicio || '').slice(0, 5) && String(r.hora_fim || '').slice(0, 5) === String(ref.hora_fim || '').slice(0, 5)) : true;
jornadaIgualTodos.value = allSame;
jornadaStart.value = String(ref.hora_inicio || '').slice(0, 5) || '08:00';
jornadaEnd.value = String(ref.hora_fim || '').slice(0, 5) || '18:00';
// Se carregou em modo "diferente", popula o snapshot para preservar ao alternar modos
if (!allSame) {
jornadaPorDiaSnapshot.value = {};
Object.keys(byDay).forEach((k) => {
jornadaPorDiaSnapshot.value[k] = { ...byDay[k] };
});
}
regras.value = actives.map((r) => ({
...r,
hora_inicio: String(r.hora_inicio || '').slice(0, 5),
hora_fim: String(r.hora_fim || '').slice(0, 5)
}));
}
// ══ AUTH / TENANT ═════════════════════════════════════════════
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
async function getActiveTenantId(uid) {
const fromStore = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id || null;
if (fromStore) return fromStore;
const { data } = await supabase.from('tenant_members').select('tenant_id').eq('user_id', uid).eq('status', 'active').limit(1).maybeSingle();
return data?.tenant_id || null;
}
async function seedConfigIfMissing(uid) {
const { data: existing } = await supabase.from('agenda_configuracoes').select('owner_id').eq('owner_id', uid).maybeSingle();
if (existing) return; // já existe, não toca
const tenantId = await getActiveTenantId(uid);
const { error } = await supabase.from('agenda_configuracoes').insert({ owner_id: uid, tenant_id: tenantId, session_duration_min: 40, session_break_min: 10 });
if (error) throw error;
}
// ══ LOAD ══════════════════════════════════════════════════════
async function loadConfig() {
const uid = await getOwnerId();
ownerId.value = uid;
cfg.value.owner_id = uid;
await seedConfigIfMissing(uid);
const { data, error } = await supabase.from('agenda_configuracoes').select('*').eq('owner_id', uid).order('created_at', { ascending: false }).limit(1).maybeSingle();
if (error) throw error;
if (data) {
cfg.value = { ...cfg.value, ...data };
if (!Array.isArray(cfg.value.pausas_semanais)) cfg.value.pausas_semanais = [];
}
}
async function loadRegras() {
const uid = ownerId.value || (await getOwnerId());
ownerId.value = uid;
const { data, error } = await supabase.from('agenda_regras_semanais').select('*').eq('owner_id', uid).order('dia_semana').order('hora_inicio');
if (error) throw error;
const rows = (data || []).map((r) => ({
...r,
hora_inicio: String(r.hora_inicio || '').slice(0, 5),
hora_fim: String(r.hora_fim || '').slice(0, 5),
ativo: !!r.ativo
}));
hydrateWizardFromRegras(rows);
}
async function loadOnlineSlots() {
const uid = ownerId.value || (await getOwnerId());
ownerId.value = uid;
resetOnlineSlots();
const { data, error } = await supabase.from('agenda_online_slots').select('weekday,time,enabled').eq('owner_id', uid);
if (error) {
console.warn('[CFG] loadOnlineSlots:', error);
return;
}
for (const row of data || []) {
if (!row?.enabled) continue;
const d = Number(row.weekday);
const t = String(row.time || '').slice(0, 5);
if ([0, 1, 2, 3, 4, 5, 6].includes(d) && isValidHHMM(t)) onlineSlotsByDay.value[d].add(t);
}
}
async function boot() {
hydrating.value = true;
loading.value = true;
try {
await loadConfig();
await loadRegras();
// sobrescreve a inferência de allSame com a preferência salva no BD
if (cfg.value.jornada_igual_todos != null) {
jornadaIgualTodos.value = cfg.value.jornada_igual_todos;
}
hydratePausasFromCfg();
await loadOnlineSlots();
// abre o primeiro card se setup não foi concluído
if (!cfg.value.setup_clinica_concluido) expandedCard.value = 'jornada';
// define preview para primeiro dia ativo
const first = selectedDays.value[0];
previewDay.value = first?.value ?? 1;
} catch (e) {
console.error(e);
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e?.message, life: 4000 });
} finally {
loading.value = false;
hydrating.value = false;
}
}
onMounted(boot);
// ══ SAVE POR CARD ════════════════════════════════════════════
async function saveJornada() {
if (!jornadaOk.value) {
toast.add({ severity: 'warn', summary: 'Jornada', detail: 'Selecione ao menos um dia e informe horários válidos.', life: 3500 });
return;
}
savingCard.value = 'jornada';
try {
const uid = ownerId.value || (await getOwnerId());
ownerId.value = uid;
const tenantId = await getActiveTenantId(uid);
// 1. salvar pausas + cfg — lê diretamente dos refs para evitar race condition
const pausasToSave = [];
if (jornadaIgualTodos.value !== false) {
for (const d of selectedDays.value) for (const p of pausasGlobais.value) pausasToSave.push({ ...p, dia_semana: d.value });
} else {
for (const d of selectedDays.value) for (const p of pausasPorDia.value[d.value] || []) pausasToSave.push({ ...p, dia_semana: d.value });
}
cfg.value.pausas_semanais = pausasToSave;
const igualTodos = jornadaIgualTodos.value !== false;
const { data: cfgSaved, error: cfgErr } = await supabase
.from('agenda_configuracoes')
.upsert(
{
owner_id: uid,
tenant_id: tenantId,
pausas_semanais: pausasToSave,
jornada_igual_todos: igualTodos,
timezone: cfg.value.timezone || 'America/Sao_Paulo',
setup_clinica_concluido: true,
setup_clinica_concluido_em: cfg.value.setup_clinica_concluido_em || new Date().toISOString()
},
{ onConflict: 'owner_id' }
)
.select('pausas_semanais, jornada_igual_todos');
if (cfgErr) throw cfgErr;
// 2. regras semanais
const rows = selectedDays.value.map((d) => {
const isWeekend = d.value === 6 || d.value === 0;
const t = jornadaIgualTodos.value === false || isWeekend ? jornadaPorDia.value[d.value] || { inicio: jornadaStart.value, fim: jornadaEnd.value } : { inicio: jornadaStart.value, fim: jornadaEnd.value };
return { owner_id: uid, tenant_id: tenantId, dia_semana: d.value, hora_inicio: normalizeTime(t.inicio), hora_fim: normalizeTime(t.fim), modalidade: 'ambos', ativo: true };
});
const { error: delErr } = await supabase.from('agenda_regras_semanais').delete().eq('owner_id', uid);
if (delErr) throw delErr;
if (rows.length) {
const { error: insErr } = await supabase.from('agenda_regras_semanais').insert(rows);
if (insErr) throw insErr;
}
// Limpar slots online de dias removidos da jornada
const activeDays = new Set(selectedDays.value.map((d) => d.value));
const orphanDays = [0, 1, 2, 3, 4, 5, 6].filter((d) => !activeDays.has(d));
if (orphanDays.length) {
const { error: orphanErr } = await supabase.from('agenda_online_slots').delete().eq('owner_id', uid).in('weekday', orphanDays);
if (orphanErr) console.warn('[CFG] limpeza órfãos:', orphanErr);
else for (const d of orphanDays) _setDay(d, new Set());
}
cfg.value.setup_clinica_concluido = true;
cfg.value.jornada_igual_todos = igualTodos;
toast.add({ severity: 'success', summary: 'Jornada salva', detail: 'Horários de trabalho atualizados.', life: 3500 });
// Notifica consumidores (ex: MelissaLayout/timeline) pra refetch
// do agenda_regras_semanais sem precisar reload da página.
window.dispatchEvent(new CustomEvent('agenda:settings-saved', { detail: { source: 'jornada' } }));
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar jornada.', life: 3500 });
} finally {
savingCard.value = null;
}
}
async function saveRitmo() {
const dur = Number(cfg.value.session_duration_min || 0);
const gap = Number(cfg.value.session_break_min || 0);
if (dur < 10 || dur > 240) {
toast.add({ severity: 'warn', summary: 'Ritmo', detail: 'Duração deve ser entre 10 e 240 min.', life: 3500 });
return;
}
if (gap < 0 || gap > 60) {
toast.add({ severity: 'warn', summary: 'Ritmo', detail: 'Intervalo deve ser entre 0 e 60 min.', life: 3500 });
return;
}
savingCard.value = 'ritmo';
try {
const uid = ownerId.value || (await getOwnerId());
ownerId.value = uid;
const { error } = await supabase.from('agenda_configuracoes').upsert(
{
owner_id: uid,
session_duration_min: dur,
session_break_min: gap
},
{ onConflict: 'owner_id' }
);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Ritmo salvo', detail: 'Configurações de sessão atualizadas.', life: 1800 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar ritmo.', life: 3500 });
} finally {
savingCard.value = null;
}
}
async function saveOnline() {
savingCard.value = 'online';
try {
const uid = ownerId.value || (await getOwnerId());
ownerId.value = uid;
const tenantId = await getActiveTenantId(uid);
// salvar flag online_ativo
const { error: cfgErr } = await supabase.from('agenda_configuracoes').upsert({ owner_id: uid, online_ativo: cfg.value.online_ativo }, { onConflict: 'owner_id' });
if (cfgErr) throw cfgErr;
// salvar slots
const { error: delErr } = await supabase.from('agenda_online_slots').delete().eq('owner_id', uid);
if (delErr) throw delErr;
if (cfg.value.online_ativo) {
const rows = [];
for (const d of selectedDays.value) {
for (const hhmm of onlineSlotsByDay.value[d.value] || new Set()) {
if (isValidHHMM(hhmm)) rows.push({ owner_id: uid, tenant_id: tenantId, weekday: Number(d.value), time: normalizeTime(hhmm), enabled: true });
}
}
if (rows.length) {
const { error: insErr } = await supabase.from('agenda_online_slots').insert(rows);
if (insErr) throw insErr;
}
}
toast.add({ severity: 'success', summary: 'Online salvo', detail: 'Configurações de agendamento online atualizadas.', life: 1800 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 3500 });
} finally {
savingCard.value = null;
}
}
// ══ LIVE REGRAS (reflete estado da UI sem precisar salvar) ════
const liveRegras = computed(() =>
selectedDays.value.map((d) => {
const t = jornadaPorDia.value[d.value] || { inicio: jornadaStart.value, fim: jornadaEnd.value };
return { dia_semana: d.value, hora_inicio: t.inicio, hora_fim: t.fim, ativo: true };
})
);
// ══ SLOTS ONLINE helpers ══════════════════════════════════════
function generateSlotsForDay(dayValue) {
const windows = liveRegras.value
.filter((r) => r.ativo && r.dia_semana === dayValue)
.map((r) => ({ start: String(r.hora_inicio || '').slice(0, 5), end: String(r.hora_fim || '').slice(0, 5) }))
.sort((a, b) => a.start.localeCompare(b.start));
if (!windows.length) return [];
const breaks = getPausasForDay(dayValue).map((p) => ({ start: String(p.inicio || '').slice(0, 5), end: String(p.fim || '').slice(0, 5) }));
const duration = Number(cfg.value.session_duration_min || 50);
const gap = Number(cfg.value.session_break_min || 10);
const cycle = Math.max(1, duration + gap);
const out = [];
for (const w of windows) {
const wStart = hhmmToMin(w.start);
const wEnd = hhmmToMin(w.end);
let t = wStart;
while (t < wEnd) {
const aEnd = t + duration;
if (aEnd > wEnd) break;
const conflict = breaks.find((b) => {
const bS = hhmmToMin(b.start),
bE = hhmmToMin(b.end);
return !(aEnd <= bS || t >= bE);
});
if (conflict) {
t = hhmmToMin(conflict.end);
continue;
}
out.push({ hhmm: minToHHMM(t), endHHMM: minToHHMM(aEnd) });
t += cycle;
}
}
const seen = new Set();
return out
.filter((s) => {
if (seen.has(s.hhmm)) return false;
seen.add(s.hhmm);
return true;
})
.sort((a, b) => a.hhmm.localeCompare(b.hhmm));
}
const slotsByDay = computed(() => {
const out = {};
for (const d of selectedDays.value) out[d.value] = generateSlotsForDay(d.value);
return out;
});
function isSlotEnabled(dayValue, hhmm) {
return !!onlineSlotsByDay.value?.[dayValue]?.has?.(hhmm);
}
// ⚠️ Sempre espalha o objeto pai para Vue detectar a mudança (Sets não são reativos internamente)
function _setDay(dayValue, newSet) {
onlineSlotsByDay.value = { ...onlineSlotsByDay.value, [dayValue]: newSet };
}
function toggleSlot(dayValue, hhmm) {
const s = new Set(onlineSlotsByDay.value?.[dayValue] || []);
if (s.has(hhmm)) s.delete(hhmm);
else s.add(hhmm);
_setDay(dayValue, s);
}
function enableAllDay(dayValue) {
const set = new Set();
for (const s of slotsByDay.value?.[dayValue] || []) set.add(s.hhmm);
_setDay(dayValue, set);
}
function clearDay(dayValue) {
_setDay(dayValue, new Set());
}
// Períodos: seleciona slots do dia que caem dentro do intervalo de horário
const periodos = [
{ label: 'Manhã', icon: 'pi-sun', from: '05:00', to: '12:00' },
{ label: 'Tarde', icon: 'pi-cloud', from: '12:00', to: '18:00' },
{ label: 'Noite', icon: 'pi-moon', from: '18:00', to: '24:00' }
];
function isPeriodoAtivo(dayValue, periodo) {
const slots = (slotsByDay.value?.[dayValue] || []).filter((s) => s.hhmm >= periodo.from && s.hhmm < periodo.to);
if (!slots.length) return false;
return slots.every((s) => isSlotEnabled(dayValue, s.hhmm));
}
function togglePeriodo(dayValue, periodo) {
const slots = (slotsByDay.value?.[dayValue] || []).filter((s) => s.hhmm >= periodo.from && s.hhmm < periodo.to);
if (!slots.length) return;
const s = new Set(onlineSlotsByDay.value?.[dayValue] || []);
const allOn = slots.every((sl) => s.has(sl.hhmm));
if (allOn) {
for (const sl of slots) s.delete(sl.hhmm);
} else {
for (const sl of slots) s.add(sl.hhmm);
}
_setDay(dayValue, s);
}
function hasSlotsInPeriodo(dayValue, periodo) {
return (slotsByDay.value?.[dayValue] || []).some((s) => s.hhmm >= periodo.from && s.hhmm < periodo.to);
}
// ══ PREVIEW (FullCalendar) ════════════════════════════════════
const fcRef = ref(null);
const previewFcEvents = computed(() => {
const day = previewDay.value;
if (day == null) return [];
const base = dateForDayOfWeek(day);
const events = [];
// janelas de trabalho (background verde)
for (const r of liveRegras.value.filter((r) => r.ativo && r.dia_semana === day)) {
events.push({
id: `work_${r.dia_semana}`,
start: toLocalIsoAt(base, r.hora_inicio),
end: toLocalIsoAt(base, r.hora_fim),
display: 'background',
color: 'var(--green-100)'
});
}
// pausas (background vermelho)
for (const p of getPausasForDay(day)) {
events.push({
id: `pause_${p.id || newId()}`,
title: p.label || 'Pausa',
start: toLocalIsoAt(base, p.inicio),
end: toLocalIsoAt(base, p.fim),
display: 'background',
color: 'var(--red-200)'
});
}
// slots gerados (visualização)
for (const s of generateSlotsForDay(day)) {
const isOnline = isSlotEnabled(day, s.hhmm);
events.push({
id: `slot_${s.hhmm}`,
title: isOnline ? 'Sessão, também disponível online 🌐' : 'Sessão',
start: toLocalIsoAt(base, s.hhmm),
end: toLocalIsoAt(base, s.endHHMM),
backgroundColor: 'var(--primary-color)',
borderColor: 'transparent',
textColor: '#fff'
});
}
return events;
});
const previewBounds = computed(() => {
const day = previewDay.value;
const active = liveRegras.value.filter((r) => r.ativo && (day == null || r.dia_semana === day));
if (!active.length) return { start: '06:00', end: '22:00' };
const start = active.reduce((acc, r) => (r.hora_inicio < acc ? r.hora_inicio : acc), active[0].hora_inicio);
const end = active.reduce((acc, r) => (r.hora_fim > acc ? r.hora_fim : acc), active[0].hora_fim);
return { start: floorTo30(String(start).slice(0, 5)), end: ceilTo30(String(end).slice(0, 5)) };
});
function previewSlotLabelContent(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">:30</span>' };
}
const previewFcOptions = computed(() => {
const day = previewDay.value;
const base = day != null ? dateForDayOfWeek(day) : new Date();
return {
plugins: [timeGridPlugin],
locale: ptBrLocale,
initialView: 'timeGridDay',
initialDate: base,
headerToolbar: false,
allDaySlot: false,
slotMinTime: previewBounds.value.start + ':00',
slotMaxTime: previewBounds.value.end + ':00',
slotDuration: '00:15:00',
snapDuration: '00:15:00',
slotLabelInterval: '00:30',
slotLabelContent: previewSlotLabelContent,
expandRows: true,
height: 'auto',
editable: false,
selectable: false,
events: previewFcEvents.value
};
});
// reage quando o dia muda
watch(previewDay, async () => {
await nextTick();
fcRef.value?.getApi?.()?.refetchEvents?.();
});
watch(
previewFcEvents,
async () => {
await nextTick();
fcRef.value?.getApi?.()?.refetchEvents?.();
},
{ deep: true }
);
// presets de duração
const durationPresets = [
{ label: '30 min', dur: 30, gap: 0, off: 0 },
{ label: '45 min', dur: 45, gap: 15, off: 0 },
{ label: '50 min', dur: 50, gap: 10, off: 0 },
{ label: '60 min', dur: 60, gap: 0, off: 0 },
{ label: '90 min', dur: 90, gap: 0, off: 0 }
];
function applyDurationPreset(preset) {
cfg.value.session_duration_min = preset.dur;
cfg.value.session_break_min = preset.gap;
showAdvancedRitmo.value = false;
}
function isActivePreset(preset) {
return cfg.value.session_duration_min === preset.dur && cfg.value.session_break_min === preset.gap;
}
// computed para DatePicker bridge (ritmo: duração e intervalo em minutos)
const durationDate = computed({
get: () => hhmmToDate(minToHHMM(Number(cfg.value.session_duration_min || 50))),
set: (v) => {
if (v instanceof Date) cfg.value.session_duration_min = v.getHours() * 60 + v.getMinutes();
}
});
const breakDate = computed({
get: () => hhmmToDate(minToHHMM(Number(cfg.value.session_break_min || 0))),
set: (v) => {
if (v instanceof Date) cfg.value.session_break_min = v.getHours() * 60 + v.getMinutes();
}
});
// computed para DatePicker bridge (jornada global)
const jornadaStartDate = computed({
get: () => hhmmToDate(jornadaStart.value),
set: (v) => {
const h = dateToHHMM(v);
if (h) jornadaStart.value = h;
}
});
const jornadaEndDate = computed({
get: () => hhmmToDate(jornadaEnd.value),
set: (v) => {
const h = dateToHHMM(v);
if (h) jornadaEnd.value = h;
}
});
</script>
<template>
<!-- SKELETON -->
<div v-if="loading" class="flex flex-col xl:flex-row gap-4">
<!-- Coluna esquerda skeleton -->
<div class="flex flex-col gap-3 xl:w-[58%]">
<!-- Subheader skeleton -->
<div class="flex items-center gap-3 px-1 py-2">
<Skeleton shape="circle" size="2rem" />
<div class="flex flex-col gap-1.5">
<Skeleton width="9rem" height="0.85rem" />
<Skeleton width="14rem" height="0.75rem" />
</div>
</div>
<!-- Card skeleton (×3) frases dentro do primeiro -->
<div v-for="i in 3" :key="i" class="rounded-[8px] border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<div class="flex items-center gap-3 px-4 py-4">
<Skeleton shape="circle" size="2.2rem" />
<div class="flex-1 flex flex-col gap-1.5">
<Skeleton :width="i === 1 ? '11rem' : i === 2 ? '9rem' : '13rem'" height="0.85rem" />
<Skeleton :width="i === 1 ? '18rem' : i === 2 ? '12rem' : '10rem'" height="0.7rem" />
</div>
<Skeleton width="6rem" height="1.4rem" border-radius="999px" />
<Skeleton shape="circle" size="1rem" />
</div>
<AppLoadingPhrases v-if="i === 1" action="Carregando configurações da agenda..." containerClass="py-8" />
</div>
</div>
<!-- Coluna direita: skeleton puro -->
<div class="xl:w-[42%] xl:self-start">
<div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
<!-- Header skeleton -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
<Skeleton width="8rem" height="0.85rem" />
<div class="flex gap-1">
<Skeleton v-for="j in 5" :key="j" width="2.2rem" height="1.6rem" border-radius="999px" />
</div>
</div>
<!-- Legenda skeleton -->
<div class="flex gap-3 px-4 py-2 border-b border-[var(--surface-border)]">
<Skeleton v-for="k in 3" :key="k" width="4.5rem" height="0.7rem" />
</div>
<!-- Calendário skeleton -->
<div class="flex flex-col gap-2 p-4">
<Skeleton v-for="l in 10" :key="l" :width="`${70 + (l % 3) * 10}%`" height="2.2rem" />
</div>
</div>
</div>
</div>
<Transition name="fade-up" appear>
<div v-if="!loading" class="flex flex-col xl:flex-row gap-4">
<!-- COLUNA ESQUERDA: CARDS -->
<div class="anim-child [--delay:0ms] flex flex-col gap-3 xl:w-[58%]">
<!-- CARD 1: JORNADA -->
<div class="cfg-card" :class="{ 'cfg-card--open': expandedCard === 'jornada' }">
<!-- Cabeçalho clicável -->
<button class="cfg-card__header" @click="expandedCard = expandedCard === 'jornada' ? null : 'jornada'">
<div class="cfg-card__icon-wrap w-10 h-10 rounded-md">
<i class="pi pi-calendar text-lg" />
</div>
<div class="flex-1 min-w-0 text-left">
<div class="cfg-card__title">Horários de trabalho</div>
<div class="cfg-card__summary">{{ resumoJornada }}</div>
</div>
<div class="flex items-center gap-2">
<span v-if="jornadaOk" class="cfg-badge cfg-badge--ok"><i class="pi pi-check" /> Configurado</span>
<span v-else class="cfg-badge cfg-badge--warn"><i class="pi pi-exclamation-triangle" /> Pendente</span>
<i class="pi text-[var(--text-color-secondary)] transition-transform duration-200" :class="expandedCard === 'jornada' ? 'pi-chevron-up' : 'pi-chevron-down'" />
</div>
</button>
<!-- Corpo expansível -->
<div v-show="expandedCard === 'jornada'" class="cfg-card__body">
<div class="border-t border-[var(--surface-border)] pt-4">
<!-- Fuso horário -->
<div class="mb-5">
<div class="cfg-label mb-2">Fuso horário</div>
<Select v-model="cfg.timezone" :options="timezones" optionLabel="label" optionValue="value" class="w-full max-w-xs" placeholder="Selecione o fuso..." />
</div>
<!-- Dias da semana -->
<div class="mb-5">
<div class="cfg-label mb-2">Quais dias você trabalha?</div>
<div class="flex flex-wrap gap-2">
<button
v-for="d in diasSemana"
:key="d.value"
class="day-chip"
:class="workDays[d.value] ? 'day-chip--active' : ''"
@click="
workDays[d.value] = !workDays[d.value];
previewDay = selectedDays[0]?.value ?? previewDay;
"
>
{{ d.short }}
</button>
</div>
</div>
<!-- Horário -->
<div v-if="selectedDays.length > 0" class="mb-5">
<div class="cfg-label mb-3">Qual é o seu horário?</div>
<!-- Toggle igual/diferente por dia -->
<div class="flex gap-2 mb-4">
<button class="toggle-opt" :class="jornadaIgualTodos !== false ? 'toggle-opt--active' : ''" @click="switchToIgual">Igual para todos os dias</button>
<button class="toggle-opt" :class="jornadaIgualTodos === false ? 'toggle-opt--active' : ''" @click="switchToDiferente">Diferente por dia</button>
</div>
<!-- Igual para todos -->
<div v-if="jornadaIgualTodos !== false" class="flex flex-col gap-3">
<!-- SegSex -->
<div v-if="selectedWeekdays.length > 0" class="cfg-equal-group">
<div class="cfg-equal-chips">
<span v-for="d in selectedWeekdays" :key="d.value" class="day-chip day-chip--active">{{ d.short }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-color-secondary)]">Das</span>
<div class="w-32">
<DatePicker v-model="jornadaStartDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps"><i class="pi pi-clock" @click="slotProps.clickCallback" /></template>
</DatePicker>
</div>
<span class="text-sm text-[var(--text-color-secondary)]">até</span>
<div class="w-32">
<DatePicker v-model="jornadaEndDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps"><i class="pi pi-clock" @click="slotProps.clickCallback" /></template>
</DatePicker>
</div>
</div>
</div>
<!-- Sábado -->
<div v-if="workDays[6]" class="cfg-equal-group">
<div class="cfg-equal-chips">
<span class="day-chip day-chip--active">Sáb</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-color-secondary)]">Das</span>
<div class="w-32">
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[6]?.inicio)"
@update:modelValue="
(v) => {
const h = dateToHHMM(v);
if (h) jornadaPorDia[6] = { ...jornadaPorDia[6], inicio: h };
}
"
showIcon
fluid
iconDisplay="input"
timeOnly
hourFormat="24"
:stepMinute="15"
:manualInput="false"
>
<template #inputicon="slotProps"><i class="pi pi-clock" @click="slotProps.clickCallback" /></template>
</DatePicker>
</div>
<span class="text-sm text-[var(--text-color-secondary)]">até</span>
<div class="w-32">
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[6]?.fim)"
@update:modelValue="
(v) => {
const h = dateToHHMM(v);
if (h) jornadaPorDia[6] = { ...jornadaPorDia[6], fim: h };
}
"
showIcon
fluid
iconDisplay="input"
timeOnly
hourFormat="24"
:stepMinute="15"
:manualInput="false"
>
<template #inputicon="slotProps"><i class="pi pi-clock" @click="slotProps.clickCallback" /></template>
</DatePicker>
</div>
</div>
</div>
<!-- Domingo -->
<div v-if="workDays[0]" class="cfg-equal-group">
<div class="cfg-equal-chips">
<span class="day-chip day-chip--active">Dom</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-color-secondary)]">Das</span>
<div class="w-32">
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[0]?.inicio)"
@update:modelValue="
(v) => {
const h = dateToHHMM(v);
if (h) jornadaPorDia[0] = { ...jornadaPorDia[0], inicio: h };
}
"
showIcon
fluid
iconDisplay="input"
timeOnly
hourFormat="24"
:stepMinute="15"
:manualInput="false"
>
<template #inputicon="slotProps"><i class="pi pi-clock" @click="slotProps.clickCallback" /></template>
</DatePicker>
</div>
<span class="text-sm text-[var(--text-color-secondary)]">até</span>
<div class="w-32">
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[0]?.fim)"
@update:modelValue="
(v) => {
const h = dateToHHMM(v);
if (h) jornadaPorDia[0] = { ...jornadaPorDia[0], fim: h };
}
"
showIcon
fluid
iconDisplay="input"
timeOnly
hourFormat="24"
:stepMinute="15"
:manualInput="false"
>
<template #inputicon="slotProps"><i class="pi pi-clock" @click="slotProps.clickCallback" /></template>
</DatePicker>
</div>
</div>
</div>
</div>
<!-- Diferente por dia -->
<div v-else class="flex flex-col gap-2">
<div v-for="d in selectedDays" :key="d.value" class="flex items-center gap-3 p-2 rounded-[6px] bg-[var(--surface-ground)]">
<span class="w-10 text-sm font-medium">{{ d.short }}</span>
<div class="w-32">
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[d.value]?.inicio)"
@update:modelValue="
(v) => {
const h = dateToHHMM(v);
if (h) {
jornadaPorDia[d.value] = { ...jornadaPorDia[d.value], inicio: h };
previewDay = d.value;
}
}
"
showIcon
fluid
iconDisplay="input"
timeOnly
hourFormat="24"
:stepMinute="15"
:manualInput="false"
>
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
</DatePicker>
</div>
<span class="text-sm text-[var(--text-color-secondary)]">até</span>
<div class="w-32">
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[d.value]?.fim)"
@update:modelValue="
(v) => {
const h = dateToHHMM(v);
if (h) {
jornadaPorDia[d.value] = { ...jornadaPorDia[d.value], fim: h };
previewDay = d.value;
}
}
"
showIcon
fluid
iconDisplay="input"
timeOnly
hourFormat="24"
:stepMinute="15"
:manualInput="false"
>
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
</DatePicker>
</div>
</div>
</div>
</div>
<!-- Pausas -->
<div v-if="selectedDays.length > 0" class="mb-5">
<div class="cfg-label mb-2">Pausas (opcional)</div>
<div class="text-sm text-[var(--text-color-secondary)] mb-3">Ex.: almoço, jantar, etc.</div>
<div v-if="jornadaIgualTodos !== false">
<PausasChipsEditor v-model="pausasGlobais" />
</div>
<div v-else class="flex flex-col gap-3">
<div v-for="d in selectedDays" :key="d.value">
<div class="text-xs font-semibold mb-1 text-[var(--text-color-secondary)]">{{ d.label }}</div>
<PausasChipsEditor v-model="pausasPorDia[d.value]" />
</div>
</div>
</div>
<!-- Ação -->
<div class="flex justify-end">
<Button label="Salvar jornada" icon="pi pi-check" :loading="savingCard === 'jornada'" :disabled="!jornadaOk || !!savingCard" class="rounded-full" @click="saveJornada" />
</div>
</div>
</div>
</div>
<!-- CARD 2: RITMO -->
<div class="cfg-card" :class="{ 'cfg-card--open': expandedCard === 'ritmo' }">
<button class="cfg-card__header" @click="expandedCard = expandedCard === 'ritmo' ? null : 'ritmo'">
<div class="cfg-card__icon-wrap w-10 h-10 rounded-md">
<i class="pi pi-stopwatch text-lg" />
</div>
<div class="flex-1 min-w-0 text-left">
<div class="cfg-card__title">Ritmo das sessões</div>
<div class="cfg-card__summary">{{ resumoRitmo }}</div>
</div>
<div class="flex items-center gap-2">
<span class="cfg-badge cfg-badge--ok"><i class="pi pi-check" /> Configurado</span>
<i class="pi text-[var(--text-color-secondary)] transition-transform duration-200" :class="expandedCard === 'ritmo' ? 'pi-chevron-up' : 'pi-chevron-down'" />
</div>
</button>
<div v-show="expandedCard === 'ritmo'" class="cfg-card__body">
<div class="border-t border-[var(--surface-border)] pt-4">
<!-- Presets -->
<div class="mb-5">
<div class="cfg-label mb-3">Duração padrão das sessões</div>
<div class="flex flex-wrap gap-2">
<button v-for="p in durationPresets" :key="p.label" class="preset-chip" :class="isActivePreset(p) ? 'preset-chip--active' : ''" @click="applyDurationPreset(p)">
{{ p.label }}
<span v-if="p.gap > 0" class="text-xs opacity-70 ml-1">· {{ p.gap }}min intervalo</span>
<span v-else class="text-xs opacity-70 ml-1">· sem pausa</span>
</button>
<button class="preset-chip" :class="!durationPresets.some((p) => isActivePreset(p)) ? 'preset-chip--active' : ''" @click="showAdvancedRitmo = true">Personalizar</button>
</div>
</div>
<!-- Campos manuais (personalizado) -->
<div v-if="showAdvancedRitmo || !durationPresets.some((p) => isActivePreset(p))" class="mb-5 p-4 rounded-[6px] bg-[var(--surface-ground)]">
<div class="cfg-label mb-3">Personalizado</div>
<div class="flex flex-row gap-6">
<div class="flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Duração</label>
<DatePicker v-model="durationDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="5" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
</DatePicker>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Intervalo</label>
<DatePicker v-model="breakDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="5" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
</DatePicker>
</div>
</div>
</div>
<div class="flex justify-end">
<Button label="Salvar ritmo" icon="pi pi-check" :loading="savingCard === 'ritmo'" :disabled="!!savingCard" class="rounded-full" @click="saveRitmo" />
</div>
</div>
</div>
</div>
<!-- CARD 3: ONLINE -->
<div class="cfg-card" :class="{ 'cfg-card--open': expandedCard === 'online' }">
<button class="cfg-card__header" @click="expandedCard = expandedCard === 'online' ? null : 'online'">
<div class="cfg-card__icon-wrap w-10 h-10 rounded-md">
<i class="pi pi-globe text-lg" />
</div>
<div class="flex-1 min-w-0 text-left">
<div class="cfg-card__title">Agendamento online</div>
<div class="cfg-card__summary">{{ resumoOnline }}</div>
</div>
<div class="flex items-center gap-2">
<span v-if="cfg.online_ativo" class="cfg-badge cfg-badge--ok"><i class="pi pi-check" /> Configurado</span>
<span class="cfg-badge" :class="cfg.online_ativo ? 'cfg-badge--ok' : 'cfg-badge--off'">
{{ cfg.online_ativo ? 'Ativo' : 'Desativado' }}
</span>
<i class="pi text-[var(--text-color-secondary)] transition-transform duration-200" :class="expandedCard === 'online' ? 'pi-chevron-up' : 'pi-chevron-down'" />
</div>
</button>
<div v-show="expandedCard === 'online'" class="cfg-card__body">
<div class="border-t border-[var(--surface-border)] pt-4">
<!-- Aviso slots órfãos -->
<div v-if="orphanSlotDays.length" class="flex items-start gap-2 mb-4 p-3 rounded-[6px] bg-[var(--yellow-50)] border border-[var(--yellow-200)] text-sm text-[var(--yellow-800)]">
<i class="pi pi-exclamation-triangle mt-0.5 shrink-0" />
<span>
slots configurados para <b>{{ orphanSlotDays.join(', ') }}</b
>, mas esses dias não estão mais na sua jornada. Eles serão removidos automaticamente ao salvar a jornada.
</span>
</div>
<!-- Toggle ativo -->
<div class="flex items-center justify-between mb-5 p-4 rounded-[6px] bg-[var(--surface-ground)]">
<div>
<div class="font-medium">Permitir que pacientes agendem online</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Você escolhe quais horários ficam disponíveis.</div>
</div>
<button class="toggle-switch" :class="cfg.online_ativo ? 'toggle-switch--on' : ''" @click="cfg.online_ativo = !cfg.online_ativo">
<span class="toggle-switch__thumb" />
</button>
</div>
<!-- Slots por dia -->
<div v-if="cfg.online_ativo && selectedDays.length">
<div class="cfg-label mb-3">Escolha os horários disponíveis</div>
<div class="online-tab-panel" :class="previewDay != null ? 'online-tab-panel--active' : ''">
<!-- Seletor de dia (tabs) -->
<div class="flex gap-2 flex-wrap p-3 pb-2">
<button
v-for="d in selectedDays"
:key="d.value"
class="online-day-tab"
:class="{
'online-day-tab--active': previewDay === d.value,
'online-day-tab--has-slots': previewDay !== d.value && (onlineSlotsByDay[d.value]?.size || 0) > 0
}"
@click="previewDay = d.value"
>
{{ d.short }}
</button>
</div>
<!-- Conteúdo do dia selecionado -->
<template v-if="previewDay != null">
<!-- Área cinza: ações rápidas + slots -->
<div class="mx-3 mb-2 p-3 rounded-[6px] bg-[var(--surface-ground)]">
<div v-if="(slotsByDay[previewDay] || []).length === 0" class="text-sm text-[var(--text-color-secondary)] py-1">Nenhum slot disponível para este dia. Configure a jornada primeiro.</div>
<div v-else>
<!-- Ações rápidas -->
<div class="flex flex-wrap gap-2 mb-3">
<button
v-for="p in periodos"
:key="p.label"
v-show="hasSlotsInPeriodo(previewDay, p)"
class="periodo-btn"
:class="isPeriodoAtivo(previewDay, p) ? 'periodo-btn--on' : ''"
@click="togglePeriodo(previewDay, p)"
>
<i :class="['pi', p.icon, 'text-xs']" />
{{ p.label }}
</button>
<span class="w-px bg-[var(--surface-border)] self-stretch mx-1" />
<button class="periodo-btn" @click="enableAllDay(previewDay)">Todos</button>
<button class="periodo-btn" @click="clearDay(previewDay)">Limpar</button>
</div>
<!-- Slots individuais -->
<div class="flex flex-wrap gap-2">
<button v-for="s in slotsByDay[previewDay] || []" :key="s.hhmm" class="slot-chip" :class="isSlotEnabled(previewDay, s.hhmm) ? 'slot-chip--on' : ''" @click="toggleSlot(previewDay, s.hhmm)">
{{ s.hhmm }}
</button>
</div>
</div>
</div>
<!-- Resumo fora da área cinza -->
<div class="px-3 pb-3 text-xs text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle mr-1" />
{{ onlineSlotsByDay[previewDay]?.size || 0 }} slot{{ (onlineSlotsByDay[previewDay]?.size || 0) !== 1 ? 's' : '' }} disponíveis para atendimento online toda
{{ diasSemana.find((d) => d.value === previewDay)?.label?.toLowerCase() }}.
</div>
</template>
</div>
</div>
<div v-else-if="!selectedDays.length" class="text-sm text-[var(--text-color-secondary)] py-2">Configure sua jornada de trabalho primeiro.</div>
<div class="flex justify-end mt-4">
<Button label="Salvar" icon="pi pi-check" :loading="savingCard === 'online'" :disabled="!!savingCard" class="rounded-full" @click="saveOnline" />
</div>
</div>
</div>
</div>
<!-- Bloco pós-carregamento -->
<LoadedPhraseBlock />
</div>
<!-- COLUNA DIREITA: PREVIEW -->
<div class="anim-child [--delay:120ms] xl:w-[42%] xl:top-4 xl:self-start">
<div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm agenda-altura">
<!-- Header do preview -->
<div class="sticky top-0 z-10 bg-[var(--surface-card)]">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
<div class="font-semibold text-sm">Preview da agenda</div>
<div class="flex gap-1">
<button v-for="d in selectedDays" :key="d.value" class="day-chip day-chip--sm" :class="jornadaIgualTodos !== false || previewDay === d.value ? 'day-chip--active' : ''" @click="previewDay = d.value">
{{ d.short }}
</button>
<span v-if="!selectedDays.length" class="text-xs text-[var(--text-color-secondary)]"> Nenhum dia selecionado </span>
</div>
</div>
<!-- Legenda -->
<div class="flex gap-3 px-4 py-2 border-b border-[var(--surface-border)] text-xs text-[var(--text-color-secondary)]">
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-[var(--green-100)] inline-block border border-green-300"></span> Jornada</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-[var(--red-200)] inline-block border border-red-300"></span> Pausa</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-[var(--primary-color)] inline-block"></span> Sessão</span>
<span v-if="cfg.online_ativo" class="flex items-center gap-1">🌐 Online</span>
</div>
</div>
<!-- FullCalendar -->
<div v-if="previewDay != null && !loading" class="p-2">
<FullCalendar ref="fcRef" :key="`preview-${previewDay}-${previewBounds.start}-${previewBounds.end}`" :options="previewFcOptions" />
</div>
<div v-else class="flex items-center justify-center py-16 text-[var(--text-color-secondary)] text-sm">Selecione um dia de trabalho para ver o preview.</div>
</div>
</div>
</div>
</Transition>
</template>
<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;
}
</style>
<style scoped>
/* ── Cards ─────────────────────────────────────────────────── */
.cfg-card {
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
transition: box-shadow 0.2s ease;
}
.cfg-card--open {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
.cfg-card__header {
display: flex;
align-items: center;
gap: 0.875rem;
width: 100%;
padding: 1rem 1.25rem;
background: transparent;
border: none;
cursor: pointer;
transition: background 0.15s;
}
.cfg-card__header:hover {
background: var(--surface-hover);
}
.cfg-card__title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-color);
line-height: 1.2;
}
.cfg-card__summary {
font-size: 0.8125rem;
color: var(--text-color-secondary);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 28ch;
}
.cfg-card__body {
padding: 0 1.25rem 1.25rem;
}
/* ── Badges ─────────────────────────────────────────────────── */
.cfg-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
font-weight: 600;
padding: 0.2rem 0.55rem;
border-radius: 99px;
white-space: nowrap;
}
.cfg-badge--ok {
background: var(--green-100);
color: var(--green-700);
}
.cfg-badge--warn {
background: var(--yellow-100);
color: var(--yellow-700);
}
.cfg-badge--off {
background: var(--surface-200);
color: var(--text-color-secondary);
}
/* ── Labels ─────────────────────────────────────────────────── */
.cfg-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ── Day chips ──────────────────────────────────────────────── */
.day-chip {
padding: 0.35rem 0.75rem;
border-radius: 99px;
border: 1.5px solid var(--surface-border);
background: var(--surface-ground);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
color: var(--text-color);
}
.day-chip:hover {
border-color: var(--primary-color);
}
.day-chip--active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
.day-chip--sm {
padding: 0.2rem 0.55rem;
font-size: 0.75rem;
}
.cfg-equal-group {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.cfg-equal-chips {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
min-width: 200px;
}
/* ── Toggle opções ──────────────────────────────────────────── */
.toggle-opt {
flex: 1;
padding: 0.5rem 1rem;
border-radius: 0.75rem;
border: 1.5px solid var(--surface-border);
background: var(--surface-ground);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
color: var(--text-color-secondary);
}
.toggle-opt:hover {
border-color: var(--primary-color);
color: var(--text-color);
}
.toggle-opt--active {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
font-weight: 600;
}
/* ── Preset chips ───────────────────────────────────────────── */
.preset-chip {
padding: 0.45rem 1rem;
border-radius: 99px;
border: 1.5px solid var(--surface-border);
background: var(--surface-ground);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
color: var(--text-color);
}
.preset-chip:hover {
border-color: var(--primary-color);
}
.preset-chip--active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
/* ── Período buttons ────────────────────────────────────────── */
.periodo-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.75rem;
border-radius: 99px;
border: 1.5px solid var(--surface-border);
background: var(--surface-ground);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
color: var(--text-color-secondary);
}
.periodo-btn:hover {
border-color: var(--primary-color);
color: var(--text-color);
}
.periodo-btn--on {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
font-weight: 600;
}
/* ── Slot chips ─────────────────────────────────────────────── */
.slot-chip {
padding: 0.3rem 0.7rem;
border-radius: 0.625rem;
border: 1.5px solid var(--surface-border);
background: var(--surface-ground);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
color: var(--text-color-secondary);
font-family: monospace;
}
.slot-chip:hover {
border-color: var(--primary-color);
}
.slot-chip--on {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
/* ── Online tab panel ───────────────────────────────────────── */
.online-tab-panel {
border-radius: 12px;
}
.online-day-tab {
padding: 0.3rem 0.7rem;
border-radius: 99px;
border: 1.5px solid var(--surface-border);
background: var(--surface-card);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
color: var(--text-color-secondary);
transition: all 0.15s;
}
.online-day-tab:hover {
border-color: var(--primary-color);
}
.online-day-tab--has-slots {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
font-weight: 600;
}
.online-day-tab--active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
font-weight: 700;
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* ── Toggle switch ──────────────────────────────────────────── */
.toggle-switch {
width: 2.75rem;
height: 1.5rem;
border-radius: 99px;
background: var(--surface-300);
border: none;
cursor: pointer;
position: relative;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle-switch--on {
background: var(--primary-color);
}
.toggle-switch__thumb {
position: absolute;
top: 3px;
left: 3px;
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.toggle-switch--on .toggle-switch__thumb {
transform: translateX(1.25rem);
}
</style>