Files
agenciapsilmno/src/layout/melissa/MelissaAgendaConfig.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

2299 lines
86 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.
<script setup>
/*
* MelissaAgendaConfig — Pagina nativa Melissa pra "Configuracoes da Agenda".
*
* Substitui o embed cfg-agenda que vivia dentro do MelissaConfiguracoes.
* Layout 2-col:
* - COL 1 (sidebar) — Card "Status do setup" (3 mini stats: Jornada,
* Ritmo, Online com cor verde/amber/red) + Card "Atalhos" (3
* atalhos pra ancoras) + Card "Como funciona" (FAQ)
* - COL 2 (main) — 3 cards stackados: Jornada (fuso + dias +
* horarios igual/diferente + pausas) + Ritmo (presets + custom)
* + Online (toggle + slots por dia com periodos)
*
* Logica espelhada do ConfiguracoesAgendaPage.vue (tabelas
* agenda_configuracoes + agenda_regras_semanais + agenda_online_slots).
*
* SKIP: FullCalendar preview (visita a /melissa/agenda real).
*/
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import PausasChipsEditor from '@/components/agenda/PausasChipsEditor.vue';
// DatePicker/Select/Skeleton/Tag/ToggleSwitch: auto via PrimeVueResolver
const emit = defineEmits(['close']);
const toast = useToast();
const tenantStore = useTenantStore();
// ── Breakpoints + drawer ───────────────────────────────────
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Toggle entre cards (default) e lista de configs (alterna inline na sidebar)
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
// ── Estado ─────────────────────────────────────────────────
const loading = ref(true);
const hydrating = ref(true);
const ownerId = ref(null);
const savingCard = ref(null);
const cfg = ref({
owner_id: null,
agenda_view_mode: 'work_hours',
agenda_custom_start: null,
agenda_custom_end: null,
session_duration_min: 50,
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' }
];
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' }
});
const jornadaPorDiaSnapshot = ref(null);
const pausasGlobais = ref([]);
const pausasPorDia = ref({ 1: [], 2: [], 3: [], 4: [], 5: [], 6: [], 0: [] });
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() };
}
const showAdvancedRitmo = ref(false);
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 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;
}
// ── Resumos ────────────────────────────────────────────────
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 `${total} slot${total !== 1 ? 's' : ''} em ${dias} dia${dias !== 1 ? 's' : ''}`;
});
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 horários igual ────────────────────────────────────
watch(
[selectedDays, jornadaStart, jornadaEnd],
() => {
if (!isValidHHMM(jornadaStart.value) || !isValidHHMM(jornadaEnd.value)) return;
if (jornadaIgualTodos.value !== false) {
selectedDays.value
.filter((d) => d.value >= 1 && d.value <= 5)
.forEach((d) => {
jornadaPorDia.value[d.value] = { inicio: jornadaStart.value, fim: jornadaEnd.value };
});
}
},
{ immediate: true }
);
function getPausasForDay(dayValue) {
if (jornadaIgualTodos.value !== false) return pausasGlobais.value;
return pausasPorDia.value[dayValue] || [];
}
function switchToIgual() {
jornadaPorDiaSnapshot.value = {};
selectedDays.value.forEach((d) => {
if (jornadaPorDia.value[d.value]) {
jornadaPorDiaSnapshot.value[d.value] = { ...jornadaPorDia.value[d.value] };
}
});
if (isValidHHMM(jornadaStart.value) && isValidHHMM(jornadaEnd.value)) {
selectedDays.value
.filter((d) => d.value >= 1 && d.value <= 5)
.forEach((d) => {
jornadaPorDia.value[d.value] = { inicio: jornadaStart.value, fim: jornadaEnd.value };
});
[6, 0].forEach((v) => {
if (workDays.value[v] && !jornadaPorDia.value[v]?.inicio) {
jornadaPorDia.value[v] = { inicio: jornadaStart.value, fim: jornadaEnd.value };
}
});
}
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)) {
jornadaPorDia.value[d.value] = { ...snap };
} else if (!isWeekend) {
jornadaPorDia.value[d.value] = { inicio: '08:00', fim: '18:00' };
}
});
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;
const weekdayActives = actives.filter((r) => r.dia_semana >= 1 && r.dia_semana <= 5);
const ref0 = weekdayActives.length ? weekdayActives[0] : actives[0];
const allSame =
weekdayActives.length >= 2
? weekdayActives.every(
(r) =>
String(r.hora_inicio || '').slice(0, 5) === String(ref0.hora_inicio || '').slice(0, 5) &&
String(r.hora_fim || '').slice(0, 5) === String(ref0.hora_fim || '').slice(0, 5)
)
: true;
jornadaIgualTodos.value = allSame;
jornadaStart.value = String(ref0.hora_inicio || '').slice(0, 5) || '08:00';
jornadaEnd.value = String(ref0.hora_fim || '').slice(0, 5) || '18:00';
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 tenantDb().from('agenda_configuracoes')
.select('owner_id')
.eq('owner_id', uid)
.maybeSingle();
if (existing) return;
const tenantId = await getActiveTenantId(uid);
const { error } = await tenantDb().from('agenda_configuracoes')
.insert({ owner_id: uid, session_duration_min: 50, 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 tenantDb().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 tenantDb().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 tenantDb().from('agenda_online_slots')
.select('weekday,time,enabled')
.eq('owner_id', uid);
if (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();
if (cfg.value.jornada_igual_todos != null) {
jornadaIgualTodos.value = cfg.value.jornada_igual_todos;
}
hydratePausasFromCfg();
await loadOnlineSlots();
const first = selectedDays.value[0];
previewDay.value = first?.value ?? 1;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e?.message, life: 4000 });
} finally {
loading.value = false;
hydrating.value = false;
}
}
// ── 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);
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 { error: cfgErr } = await tenantDb().from('agenda_configuracoes')
.upsert(
{
owner_id: uid,
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' }
);
if (cfgErr) throw cfgErr;
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,
dia_semana: d.value,
hora_inicio: normalizeTime(t.inicio),
hora_fim: normalizeTime(t.fim),
modalidade: 'ambos',
ativo: true
};
});
const { error: delErr } = await tenantDb().from('agenda_regras_semanais').delete().eq('owner_id', uid);
if (delErr) throw delErr;
if (rows.length) {
const { error: insErr } = await tenantDb().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) {
await tenantDb().from('agenda_online_slots').delete().eq('owner_id', uid).in('weekday', orphanDays);
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', life: 2500 });
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 entre 10 e 240 min.', life: 3500 });
return;
}
if (gap < 0 || gap > 60) {
toast.add({ severity: 'warn', summary: 'Ritmo', detail: 'Intervalo entre 0 e 60 min.', life: 3500 });
return;
}
savingCard.value = 'ritmo';
try {
const uid = ownerId.value || (await getOwnerId());
ownerId.value = uid;
const { error } = await tenantDb().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', life: 1800 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha.', 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);
const { error: cfgErr } = await tenantDb().from('agenda_configuracoes')
.upsert({ owner_id: uid, online_ativo: cfg.value.online_ativo }, { onConflict: 'owner_id' });
if (cfgErr) throw cfgErr;
const { error: delErr } = await tenantDb().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,
weekday: Number(d.value),
time: normalizeTime(hhmm),
enabled: true
});
}
}
}
if (rows.length) {
const { error: insErr } = await tenantDb().from('agenda_online_slots').insert(rows);
if (insErr) throw insErr;
}
}
toast.add({ severity: 'success', summary: 'Online salvo', life: 1800 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha.', life: 3500 });
} finally {
savingCard.value = null;
}
}
// ── Live regras (UI) ───────────────────────────────────────
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);
const 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);
}
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());
}
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);
}
// ── Presets de duração ─────────────────────────────────────
const durationPresets = [
{ label: '30 min', dur: 30, gap: 0 },
{ label: '45 min', dur: 45, gap: 15 },
{ label: '50 min', dur: 50, gap: 10 },
{ label: '60 min', dur: 60, gap: 0 },
{ label: '90 min', dur: 90, gap: 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;
}
// DatePicker bridges
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(); }
});
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; }
});
function setJornadaPorDiaInicio(day, v) {
const h = dateToHHMM(v);
if (h) jornadaPorDia.value[day] = { ...jornadaPorDia.value[day], inicio: h };
}
function setJornadaPorDiaFim(day, v) {
const h = dateToHHMM(v);
if (h) jornadaPorDia.value[day] = { ...jornadaPorDia.value[day], fim: h };
}
// ── Status indicators (sidebar) ────────────────────────────
const statusItems = computed(() => [
{
key: 'jornada',
label: 'Jornada',
icon: 'pi pi-calendar',
ok: jornadaOk.value,
resumo: resumoJornada.value
},
{
key: 'ritmo',
label: 'Ritmo',
icon: 'pi pi-stopwatch',
ok: cfg.value.session_duration_min > 0,
resumo: resumoRitmo.value
},
{
key: 'online',
label: 'Online',
icon: 'pi pi-globe',
ok: cfg.value.online_ativo,
resumo: resumoOnline.value
}
]);
function scrollToCard(key) {
if (isMobile.value) drawerOpen.value = false;
nextTick(() => {
const el = document.getElementById('mac-sec-' + key);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
// ── Lifecycle ──────────────────────────────────────────────
onMounted(async () => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
catch { _mqMobile.addListener(_onMqMobileChange); }
}
await tenantStore.ensureLoaded();
await boot();
});
onBeforeUnmount(() => {
if (_mqMobile) {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
});
</script>
<template>
<Transition name="mac-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mac-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div id="mac-mobile-drawer-target" class="mac-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="mac-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mac-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mac-page">
<header class="mac-page__head">
<button
class="mac-menu-btn mac-menu-btn--mobile-only"
v-tooltip.bottom="'Status & Atalhos'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu</span>
</button>
<div class="mac-page__title">
<i class="pi pi-calendar mac-page__title-icon" />
<span>Configurações da Agenda</span>
<Tag
v-if="cfg.setup_clinica_concluido"
value="Setup OK"
severity="success"
/>
<Tag
v-else
value="Setup pendente"
severity="warn"
/>
</div>
<div class="mac-page__actions">
<button class="mac-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
<i class="pi pi-times" />
</button>
</div>
</header>
<div class="mac-subheader">
<i class="pi pi-info-circle mac-subheader__icon" />
<span class="mac-subheader__text">
Configure a jornada (dias e horários), o ritmo das sessões e o
agendamento online. Cada card abaixo tem seu próprio botão de salvar.
</span>
</div>
<div class="mac-body">
<Teleport to="#mac-mobile-drawer-target" :disabled="!isMobile">
<aside class="mac-side">
<button class="mac-cfg-btn" :class="{ 'is-open': cfgOpen }" @click="toggleCfg">
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mac-cfg-btn__chev" />
</button>
<div v-if="cfgOpen" class="mac-side__scroll mac-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mac-side__scroll">
<!-- Card: Status do setup -->
<div class="mac-w mac-w--side">
<div class="mac-w__head">
<div class="mac-w__icon"><i class="pi pi-check-circle" /></div>
<div class="mac-w__title">
<div class="mac-w__title-text">Status do setup</div>
<div class="mac-w__sub">Estado de cada seção</div>
</div>
</div>
<div class="mac-w__body">
<button
v-for="s in statusItems"
:key="s.key"
type="button"
class="mac-status-item"
@click="scrollToCard(s.key)"
>
<div
class="mac-status-item__icon"
:class="{ 'is-ok': s.ok, 'is-pending': !s.ok }"
>
<i :class="s.icon" />
</div>
<div class="mac-status-item__text">
<div class="mac-status-item__label">
{{ s.label }}
<span class="mac-status-item__tag" :class="{ 'is-ok': s.ok }">
{{ s.ok ? '✓' : '!' }}
</span>
</div>
<div class="mac-status-item__resumo">{{ s.resumo }}</div>
</div>
<i class="pi pi-chevron-right mac-status-item__chev" />
</button>
</div>
</div>
<!-- Card: Como funciona -->
<div class="mac-w mac-w--side">
<div class="mac-w__head">
<div class="mac-w__icon"><i class="pi pi-question-circle" /></div>
<div class="mac-w__title">
<div class="mac-w__title-text">Como funciona</div>
<div class="mac-w__sub">Ordem sugerida</div>
</div>
</div>
<div class="mac-w__body">
<ul class="mac-faq">
<li class="mac-faq__item">
<span class="mac-faq__bullet" style="background: #3b82f6">1</span>
<div>
<strong>Jornada</strong> escolha os dias e os horários de trabalho. Pode ser igual ou diferente por dia.
</div>
</li>
<li class="mac-faq__item">
<span class="mac-faq__bullet" style="background: #f59e0b">2</span>
<div>
<strong>Ritmo</strong> duração de cada sessão e intervalo entre elas. Define a grade de slots.
</div>
</li>
<li class="mac-faq__item">
<span class="mac-faq__bullet" style="background: #10b981">3</span>
<div>
<strong>Online</strong> ative o agendamento online e escolha quais slots ficam disponíveis pros pacientes.
</div>
</li>
</ul>
</div>
</div>
</div>
</aside>
</Teleport>
<div class="mac-main">
<template v-if="loading">
<div class="mac-w" v-for="n in 3" :key="`sk-${n}`">
<div class="mac-w__body">
<Skeleton width="40%" height="20px" class="mb-3" />
<Skeleton v-for="m in 4" :key="`sk-${n}-${m}`" width="100%" height="36px" class="mb-2" />
</div>
</div>
</template>
<template v-else>
<!-- Jornada -->
<div id="mac-sec-jornada" class="mac-w">
<div class="mac-w__head">
<div class="mac-w__icon mac-w__icon--blue"><i class="pi pi-calendar" /></div>
<div class="mac-w__title">
<div class="mac-w__title-text">Jornada</div>
<div class="mac-w__sub">Dias e horários de trabalho</div>
</div>
<Tag
v-if="jornadaOk"
value="Configurado"
severity="success"
/>
<Tag
v-else
value="Pendente"
severity="warn"
/>
</div>
<div class="mac-w__body">
<!-- Fuso -->
<div class="mac-field">
<label class="mac-label">Fuso horário</label>
<Select
v-model="cfg.timezone"
:options="timezones"
optionLabel="label"
optionValue="value"
class="w-full"
placeholder="Selecione o fuso..."
/>
</div>
<!-- Dias -->
<div class="mac-field">
<label class="mac-label">Quais dias você trabalha?</label>
<div class="mac-days">
<button
v-for="d in diasSemana"
:key="d.value"
type="button"
class="mac-day"
:class="{ 'is-active': workDays[d.value] }"
@click="
workDays[d.value] = !workDays[d.value];
previewDay = selectedDays[0]?.value ?? previewDay;
"
>{{ d.short }}</button>
</div>
</div>
<!-- Horários -->
<div v-if="selectedDays.length > 0" class="mac-field">
<label class="mac-label">Qual é o seu horário?</label>
<div class="mac-toggle-mode">
<button
type="button"
class="mac-mode-btn"
:class="{ 'is-active': jornadaIgualTodos !== false }"
@click="switchToIgual"
>Igual para todos</button>
<button
type="button"
class="mac-mode-btn"
:class="{ 'is-active': jornadaIgualTodos === false }"
@click="switchToDiferente"
>Diferente por dia</button>
</div>
<!-- Modo igual -->
<div v-if="jornadaIgualTodos !== false" class="mac-equals">
<div v-if="selectedWeekdays.length > 0" class="mac-equal-row">
<div class="mac-equal-row__chips">
<span
v-for="d in selectedWeekdays"
:key="d.value"
class="mac-day is-active mac-day--sm"
>{{ d.short }}</span>
</div>
<div class="mac-time-row">
<span class="mac-time-row__lbl">Das</span>
<DatePicker
v-model="jornadaStartDate"
showIcon
fluid
iconDisplay="input"
timeOnly
hourFormat="24"
:stepMinute="15"
:manualInput="false"
class="mac-time"
>
<template #inputicon="sp">
<i class="pi pi-clock" @click="sp.clickCallback" />
</template>
</DatePicker>
<span class="mac-time-row__lbl">até</span>
<DatePicker
v-model="jornadaEndDate"
showIcon
fluid
iconDisplay="input"
timeOnly
hourFormat="24"
:stepMinute="15"
:manualInput="false"
class="mac-time"
>
<template #inputicon="sp">
<i class="pi pi-clock" @click="sp.clickCallback" />
</template>
</DatePicker>
</div>
</div>
<div v-if="workDays[6]" class="mac-equal-row">
<div class="mac-equal-row__chips">
<span class="mac-day is-active mac-day--sm">Sáb</span>
</div>
<div class="mac-time-row">
<span class="mac-time-row__lbl">Das</span>
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[6]?.inicio)"
@update:modelValue="(v) => setJornadaPorDiaInicio(6, v)"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24"
:stepMinute="15" :manualInput="false" class="mac-time"
>
<template #inputicon="sp">
<i class="pi pi-clock" @click="sp.clickCallback" />
</template>
</DatePicker>
<span class="mac-time-row__lbl">até</span>
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[6]?.fim)"
@update:modelValue="(v) => setJornadaPorDiaFim(6, v)"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24"
:stepMinute="15" :manualInput="false" class="mac-time"
>
<template #inputicon="sp">
<i class="pi pi-clock" @click="sp.clickCallback" />
</template>
</DatePicker>
</div>
</div>
<div v-if="workDays[0]" class="mac-equal-row">
<div class="mac-equal-row__chips">
<span class="mac-day is-active mac-day--sm">Dom</span>
</div>
<div class="mac-time-row">
<span class="mac-time-row__lbl">Das</span>
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[0]?.inicio)"
@update:modelValue="(v) => setJornadaPorDiaInicio(0, v)"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24"
:stepMinute="15" :manualInput="false" class="mac-time"
>
<template #inputicon="sp">
<i class="pi pi-clock" @click="sp.clickCallback" />
</template>
</DatePicker>
<span class="mac-time-row__lbl">até</span>
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[0]?.fim)"
@update:modelValue="(v) => setJornadaPorDiaFim(0, v)"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24"
:stepMinute="15" :manualInput="false" class="mac-time"
>
<template #inputicon="sp">
<i class="pi pi-clock" @click="sp.clickCallback" />
</template>
</DatePicker>
</div>
</div>
</div>
<!-- Modo diferente -->
<div v-else class="mac-diff">
<div
v-for="d in selectedDays"
:key="d.value"
class="mac-diff-row"
>
<span class="mac-diff-row__day">{{ d.short }}</span>
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[d.value]?.inicio)"
@update:modelValue="(v) => { setJornadaPorDiaInicio(d.value, v); previewDay = d.value; }"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24"
:stepMinute="15" :manualInput="false" class="mac-time"
>
<template #inputicon="sp">
<i class="pi pi-clock" @click="sp.clickCallback" />
</template>
</DatePicker>
<span class="mac-time-row__lbl">até</span>
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[d.value]?.fim)"
@update:modelValue="(v) => { setJornadaPorDiaFim(d.value, v); previewDay = d.value; }"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24"
:stepMinute="15" :manualInput="false" class="mac-time"
>
<template #inputicon="sp">
<i class="pi pi-clock" @click="sp.clickCallback" />
</template>
</DatePicker>
</div>
</div>
</div>
<!-- Pausas -->
<div v-if="selectedDays.length > 0" class="mac-field">
<label class="mac-label">Pausas <span class="mac-label__opt">(opcional)</span></label>
<span class="mac-hint">Almoço, jantar, café ficam fora dos slots disponíveis.</span>
<div v-if="jornadaIgualTodos !== false">
<PausasChipsEditor v-model="pausasGlobais" />
</div>
<div v-else class="mac-pausas-by-day">
<div v-for="d in selectedDays" :key="d.value">
<div class="mac-pausa-day-label">{{ d.label }}</div>
<PausasChipsEditor v-model="pausasPorDia[d.value]" />
</div>
</div>
</div>
<div class="mac-card-actions">
<button
class="mac-btn mac-btn--primary"
:disabled="!jornadaOk || savingCard === 'jornada'"
@click="saveJornada"
>
<i :class="savingCard === 'jornada' ? 'pi pi-spin pi-spinner' : 'pi pi-check'" />
<span>Salvar jornada</span>
</button>
</div>
</div>
</div>
<!-- Ritmo -->
<div id="mac-sec-ritmo" class="mac-w">
<div class="mac-w__head">
<div class="mac-w__icon mac-w__icon--amber"><i class="pi pi-stopwatch" /></div>
<div class="mac-w__title">
<div class="mac-w__title-text">Ritmo das sessões</div>
<div class="mac-w__sub">Duração e intervalo</div>
</div>
</div>
<div class="mac-w__body">
<div class="mac-field">
<label class="mac-label">Duração padrão</label>
<div class="mac-presets">
<button
v-for="p in durationPresets"
:key="p.label"
type="button"
class="mac-preset"
:class="{ 'is-active': isActivePreset(p) }"
@click="applyDurationPreset(p)"
>
<span class="mac-preset__dur">{{ p.label }}</span>
<span class="mac-preset__gap">
{{ p.gap > 0 ? `· ${p.gap}min intervalo` : '· sem pausa' }}
</span>
</button>
<button
type="button"
class="mac-preset"
:class="{ 'is-active': !durationPresets.some((p) => isActivePreset(p)) }"
@click="showAdvancedRitmo = true"
>
<span class="mac-preset__dur">Personalizar</span>
</button>
</div>
</div>
<div v-if="showAdvancedRitmo || !durationPresets.some((p) => isActivePreset(p))" class="mac-custom">
<div class="mac-label">Personalizado</div>
<div class="mac-grid-2">
<div class="mac-field">
<label class="mac-label-sm">Duração</label>
<DatePicker
v-model="durationDate"
showIcon
fluid
iconDisplay="input"
timeOnly
hourFormat="24"
:stepMinute="5"
:manualInput="false"
>
<template #inputicon="sp">
<i class="pi pi-clock" @click="sp.clickCallback" />
</template>
</DatePicker>
</div>
<div class="mac-field">
<label class="mac-label-sm">Intervalo</label>
<DatePicker
v-model="breakDate"
showIcon
fluid
iconDisplay="input"
timeOnly
hourFormat="24"
:stepMinute="5"
:manualInput="false"
>
<template #inputicon="sp">
<i class="pi pi-clock" @click="sp.clickCallback" />
</template>
</DatePicker>
</div>
</div>
</div>
<div class="mac-card-actions">
<button
class="mac-btn mac-btn--primary"
:disabled="savingCard === 'ritmo'"
@click="saveRitmo"
>
<i :class="savingCard === 'ritmo' ? 'pi pi-spin pi-spinner' : 'pi pi-check'" />
<span>Salvar ritmo</span>
</button>
</div>
</div>
</div>
<!-- Online -->
<div id="mac-sec-online" class="mac-w">
<div class="mac-w__head">
<div class="mac-w__icon mac-w__icon--green"><i class="pi pi-globe" /></div>
<div class="mac-w__title">
<div class="mac-w__title-text">Agendamento Online</div>
<div class="mac-w__sub">Slots disponíveis pros pacientes</div>
</div>
<Tag
v-if="cfg.online_ativo"
value="Ativo"
severity="success"
/>
<Tag
v-else
value="Desativado"
severity="secondary"
/>
</div>
<div class="mac-w__body">
<!-- Aviso slots órfãos -->
<div v-if="orphanSlotDays.length" class="mac-warn">
<i class="pi pi-exclamation-triangle" />
<span>
slots configurados pra <b>{{ orphanSlotDays.join(', ') }}</b>,
mas esses dias saíram da sua jornada. Serão removidos ao salvar.
</span>
</div>
<div class="mac-toggle-row">
<div class="mac-toggle-row__text">
<div class="mac-toggle-row__label">Permitir agendamento online</div>
<div class="mac-toggle-row__hint">Você escolhe quais horários ficam disponíveis.</div>
</div>
<ToggleSwitch v-model="cfg.online_ativo" />
</div>
<!-- Slots por dia -->
<div v-if="cfg.online_ativo && selectedDays.length" class="mac-slots-area">
<label class="mac-label">Escolha os horários disponíveis</label>
<!-- Tabs de dias -->
<div class="mac-day-tabs">
<button
v-for="d in selectedDays"
:key="d.value"
type="button"
class="mac-day-tab"
:class="{
'is-active': previewDay === d.value,
'has-slots': previewDay !== d.value && (onlineSlotsByDay[d.value]?.size || 0) > 0
}"
@click="previewDay = d.value"
>{{ d.short }}</button>
</div>
<!-- Slots do dia selecionado -->
<div v-if="previewDay != null" class="mac-slots-day">
<div v-if="(slotsByDay[previewDay] || []).length === 0" class="mac-empty-slots">
Nenhum slot disponível pra este dia. Configure a jornada primeiro.
</div>
<div v-else>
<!-- Periodos quick actions -->
<div class="mac-periodos">
<button
v-for="p in periodos"
:key="p.label"
v-show="hasSlotsInPeriodo(previewDay, p)"
type="button"
class="mac-periodo-btn"
:class="{ 'is-on': isPeriodoAtivo(previewDay, p) }"
@click="togglePeriodo(previewDay, p)"
>
<i :class="['pi', p.icon]" />
<span>{{ p.label }}</span>
</button>
<span class="mac-periodos__sep" />
<button
type="button"
class="mac-periodo-btn"
@click="enableAllDay(previewDay)"
>
<i class="pi pi-check-circle" />
<span>Todos</span>
</button>
<button
type="button"
class="mac-periodo-btn"
@click="clearDay(previewDay)"
>
<i class="pi pi-times-circle" />
<span>Limpar</span>
</button>
</div>
<!-- Slot chips -->
<div class="mac-slot-chips">
<button
v-for="s in slotsByDay[previewDay] || []"
:key="s.hhmm"
type="button"
class="mac-slot"
:class="{ 'is-on': isSlotEnabled(previewDay, s.hhmm) }"
@click="toggleSlot(previewDay, s.hhmm)"
>{{ s.hhmm }}</button>
</div>
<div class="mac-slots-info">
<i class="pi pi-info-circle" />
<span>
{{ onlineSlotsByDay[previewDay]?.size || 0 }} slot{{
(onlineSlotsByDay[previewDay]?.size || 0) !== 1 ? 's' : ''
}} disponíveis toda
{{ diasSemana.find((d) => d.value === previewDay)?.label?.toLowerCase() }}.
</span>
</div>
</div>
</div>
</div>
<div v-else-if="!selectedDays.length" class="mac-empty-jornada">
<i class="pi pi-info-circle" />
<span>Configure sua jornada de trabalho primeiro.</span>
</div>
<div class="mac-card-actions">
<button
class="mac-btn mac-btn--primary"
:disabled="savingCard === 'online'"
@click="saveOnline"
>
<i :class="savingCard === 'online' ? 'pi pi-spin pi-spinner' : 'pi pi-check'" />
<span>Salvar online</span>
</button>
</div>
</div>
</div>
</template>
</div>
</div>
</section>
</template>
<style scoped>
/* ═══════ Page chrome ═══════ */
.mac-page {
position: absolute;
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) var(--m-config-aside-left, 6px);
z-index: 40;
display: flex;
flex-direction: column;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: var(--m-text);
animation: mac-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mac-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mac-page__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
gap: 10px;
}
.mac-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 700;
color: var(--m-text);
flex-wrap: wrap;
}
.mac-page__title-icon { color: var(--p-primary-color); font-size: 1.05rem; }
.mac-page__actions { display: flex; align-items: center; gap: 6px; }
.mac-close {
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: transparent;
border: 1px solid var(--m-border);
color: var(--m-text-muted);
border-radius: 8px;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}
.mac-close:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
.mac-menu-btn {
display: none;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
flex-shrink: 0;
}
.mac-menu-btn:hover { background: var(--m-bg-soft-hover); }
.mac-subheader {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 18px;
background: var(--m-bg-soft);
border-bottom: 1px solid var(--m-border);
color: var(--m-text-muted);
font-size: 0.78rem;
flex-shrink: 0;
}
.mac-subheader__icon {
color: var(--p-primary-color);
font-size: 0.85rem;
margin-top: 2px;
flex-shrink: 0;
}
/* ═══════ Body 2-col ═══════ */
.mac-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.mac-side {
width: 320px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mac-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mac-side__scroll::-webkit-scrollbar { width: 5px; }
.mac-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Botao "Configuracoes" no topo da .mac-side. Click alterna entre
cards (default) e lista de configs (estado is-open: vira "Voltar"). */
.mac-cfg-btn {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mac-cfg-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mac-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mac-cfg-btn > i:first-child {
color: var(--p-primary-color);
font-size: 0.92rem;
}
.mac-cfg-btn > span { flex: 1; }
.mac-cfg-btn__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
}
.mac-side__scroll--cfg {
padding: 8px;
gap: 0;
}
.mac-main {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mac-main::-webkit-scrollbar { width: 5px; }
.mac-main::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Desktop (>=1024): main coluna unica. Sem cap de altura nos cards —
cada um cresce com seu conteudo (Jornada vira maior em "Diferente
por dia", Online expande com slots, etc). Sem scroll interno: o
.mac-main / .mac-side__scroll fazem o scroll externo da pagina.
flex-shrink: 0 nos cards pra eles nao serem comprimidos quando o
total ultrapassa a altura do .mac-main — o scroll externo engata
no lugar disso. */
.mac-main > .mac-w,
.mac-side > .mac-side__scroll > .mac-w--side {
flex-shrink: 0;
}
/* ═══════ Card-base ═══════ */
.mac-w {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
}
.mac-w--side {
background: var(--m-bg-medium);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
}
.mac-w__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
}
.mac-w__icon {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 9px;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-color) 15%, transparent);
color: var(--p-primary-color);
}
.mac-w__icon > i { font-size: 0.95rem; }
.mac-w__icon--blue { background: color-mix(in srgb, #3b82f6 15%, transparent); color: #3b82f6; }
.mac-w__icon--amber { background: color-mix(in srgb, #f59e0b 15%, transparent); color: #f59e0b; }
.mac-w__icon--green { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
.mac-w__title { flex: 1; min-width: 0; }
.mac-w__title-text {
font-size: 0.92rem;
font-weight: 700;
color: var(--m-text);
line-height: 1.2;
}
.mac-w__sub {
font-size: 0.74rem;
color: var(--m-text-muted);
margin-top: 2px;
line-height: 1.3;
}
.mac-w__body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
/* ═══════ Sidebar: Status items ═══════ */
.mac-status-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
cursor: pointer;
font-family: inherit;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease;
}
.mac-status-item:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mac-status-item__icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 8px;
flex-shrink: 0;
}
.mac-status-item__icon.is-ok {
background: rgba(16, 185, 129, 0.14);
color: rgb(16, 185, 129);
}
.mac-status-item__icon.is-pending {
background: rgba(245, 158, 11, 0.14);
color: rgb(245, 158, 11);
}
.mac-status-item__text { flex: 1; min-width: 0; }
.mac-status-item__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
font-weight: 700;
color: var(--m-text);
}
.mac-status-item__tag {
width: 16px;
height: 16px;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(245, 158, 11, 0.20);
color: rgb(245, 158, 11);
font-size: 0.66rem;
font-weight: 800;
}
.mac-status-item__tag.is-ok {
background: rgba(16, 185, 129, 0.20);
color: rgb(16, 185, 129);
}
.mac-status-item__resumo {
font-size: 0.7rem;
color: var(--m-text-muted);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mac-status-item__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
flex-shrink: 0;
opacity: 0.5;
}
/* ═══════ Sidebar: FAQ ═══════ */
.mac-faq {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.mac-faq__item {
display: flex;
gap: 10px;
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.4;
}
.mac-faq__item > div { flex: 1; min-width: 0; }
.mac-faq__item strong { color: var(--m-text); font-weight: 700; }
.mac-faq__bullet {
width: 22px;
height: 22px;
border-radius: 50%;
margin-top: 0;
flex-shrink: 0;
display: grid;
place-items: center;
color: white;
font-weight: 700;
font-size: 0.72rem;
}
/* ═══════ Buttons ═══════ */
.mac-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
font-family: inherit;
transition: background-color 120ms ease, opacity 120ms ease;
}
.mac-btn:hover { background: var(--m-bg-soft-hover); }
.mac-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.mac-btn--primary {
background: var(--p-primary-color);
border-color: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
}
.mac-btn--primary:hover {
background: color-mix(in srgb, var(--p-primary-color) 88%, black);
}
.mac-card-actions {
display: flex;
justify-content: flex-end;
padding-top: 4px;
border-top: 1px solid var(--m-border);
margin-top: 4px;
}
/* ═══════ Form ═══════ */
.mac-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.mac-label {
font-size: 0.82rem;
font-weight: 700;
color: var(--m-text);
}
.mac-label__opt {
font-weight: 400;
color: var(--m-text-muted);
opacity: 0.7;
}
.mac-label-sm {
font-size: 0.74rem;
color: var(--m-text-muted);
font-weight: 600;
}
.mac-hint {
font-size: 0.72rem;
color: var(--m-text-muted);
line-height: 1.3;
}
.mac-grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
/* ═══════ Days chips ═══════ */
.mac-days {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mac-day {
padding: 8px 14px;
border-radius: 999px;
border: 1.5px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.82rem;
font-weight: 700;
transition: all 120ms ease;
}
.mac-day:hover { border-color: var(--m-border-strong); }
.mac-day.is-active {
background: var(--p-primary-color);
border-color: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
}
.mac-day--sm {
padding: 4px 10px;
font-size: 0.74rem;
cursor: default;
}
/* ═══════ Toggle igual/diferente ═══════ */
.mac-toggle-mode {
display: flex;
gap: 4px;
padding: 4px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
width: fit-content;
}
.mac-mode-btn {
padding: 6px 14px;
border-radius: 7px;
border: none;
background: transparent;
color: var(--m-text-muted);
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: all 120ms ease;
}
.mac-mode-btn:hover { color: var(--m-text); }
.mac-mode-btn.is-active {
background: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
}
/* ═══════ Equal mode rows ═══════ */
.mac-equals {
display: flex;
flex-direction: column;
gap: 8px;
}
.mac-equal-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 9px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
flex-wrap: wrap;
}
.mac-equal-row__chips {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.mac-time-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mac-time-row__lbl {
font-size: 0.78rem;
color: var(--m-text-muted);
}
.mac-time { width: 110px; }
/* ═══════ Different mode rows ═══════ */
.mac-diff {
display: flex;
flex-direction: column;
gap: 6px;
}
.mac-diff-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
flex-wrap: wrap;
}
.mac-diff-row__day {
width: 36px;
font-size: 0.82rem;
font-weight: 700;
color: var(--m-text);
flex-shrink: 0;
}
/* ═══════ Pausas ═══════ */
.mac-pausas-by-day {
display: flex;
flex-direction: column;
gap: 10px;
}
.mac-pausa-day-label {
font-size: 0.74rem;
font-weight: 700;
color: var(--m-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 4px;
}
/* ═══════ Presets ritmo ═══════ */
.mac-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mac-preset {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 14px;
border-radius: 999px;
border: 1.5px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: all 120ms ease;
}
.mac-preset:hover { border-color: var(--m-border-strong); }
.mac-preset.is-active {
background: var(--p-primary-color);
border-color: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
}
.mac-preset__dur { font-weight: 700; }
.mac-preset__gap {
font-size: 0.7rem;
opacity: 0.7;
font-weight: 500;
}
.mac-custom {
padding: 12px;
border-radius: 9px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
display: flex;
flex-direction: column;
gap: 10px;
}
/* ═══════ Toggle row (online) ═══════ */
.mac-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-radius: 9px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
}
.mac-toggle-row__text { flex: 1; min-width: 0; }
.mac-toggle-row__label {
font-size: 0.85rem;
font-weight: 700;
color: var(--m-text);
}
.mac-toggle-row__hint {
font-size: 0.72rem;
color: var(--m-text-muted);
margin-top: 2px;
line-height: 1.3;
}
.mac-warn {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: 9px;
background: rgba(245, 158, 11, 0.10);
border: 1px solid rgba(245, 158, 11, 0.30);
color: rgb(180, 83, 9);
font-size: 0.78rem;
line-height: 1.4;
}
.mac-warn > i { font-size: 0.92rem; margin-top: 2px; flex-shrink: 0; }
/* ═══════ Slots ═══════ */
.mac-slots-area {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--m-border);
}
.mac-day-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mac-day-tab {
padding: 6px 12px;
border-radius: 7px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: all 120ms ease;
}
.mac-day-tab:hover { border-color: var(--m-border-strong); }
.mac-day-tab.is-active {
background: var(--p-primary-color);
border-color: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
}
.mac-day-tab.has-slots {
border-color: color-mix(in srgb, var(--p-primary-color) 50%, transparent);
}
.mac-day-tab.has-slots::after {
content: '·';
margin-left: 3px;
color: var(--p-primary-color);
font-weight: 800;
}
.mac-slots-day {
padding: 12px;
border-radius: 9px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
display: flex;
flex-direction: column;
gap: 12px;
}
.mac-empty-slots {
color: var(--m-text-muted);
font-size: 0.82rem;
text-align: center;
padding: 12px;
}
.mac-periodos {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.mac-periodos__sep {
width: 1px;
height: 20px;
background: var(--m-border);
margin: 0 4px;
}
.mac-periodo-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border-radius: 6px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.74rem;
font-weight: 600;
transition: all 120ms ease;
}
.mac-periodo-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mac-periodo-btn.is-on {
background: color-mix(in srgb, var(--p-primary-color) 15%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 50%, transparent);
color: var(--p-primary-color);
}
.mac-periodo-btn > i { font-size: 0.7rem; }
.mac-slot-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mac-slot {
padding: 5px 10px;
border-radius: 999px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text-muted);
cursor: pointer;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.72rem;
font-weight: 600;
transition: all 120ms ease;
}
.mac-slot:hover { border-color: var(--m-border-strong); }
.mac-slot.is-on {
background: var(--p-primary-color);
border-color: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
}
.mac-slots-info {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 0.72rem;
color: var(--m-text-muted);
}
.mac-slots-info > i {
font-size: 0.78rem;
margin-top: 2px;
color: var(--p-primary-color);
}
.mac-empty-jornada {
display: flex;
align-items: center;
gap: 8px;
padding: 14px;
border-radius: 9px;
background: var(--m-bg-medium);
border: 1px dashed var(--m-border);
color: var(--m-text-muted);
font-size: 0.82rem;
}
.mac-empty-jornada > i {
color: var(--p-primary-color);
}
/* ═══════ Mobile drawer ═══════ */
.mac-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh;
height: 100dvh;
width: min(360px, 88vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
}
.mac-mobile-drawer.is-open { transform: translateX(0); }
.mac-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mac-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mac-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* No mobile a .mac-side e teleportada pra dentro do drawer scroll. */
.mac-mobile-drawer__scroll .mac-side {
width: 100%;
border-right: none;
}
.mac-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.mac-drawer-fade-enter-active,
.mac-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mac-drawer-fade-enter-from,
.mac-drawer-fade-leave-to { opacity: 0; }
/* ═══════ Mobile (<1024px) ═══════ */
@media (max-width: 1023px) {
.mac-body { flex-direction: column; padding: 0; }
.mac-body > .mac-side { display: none; }
.mac-main { width: 100%; padding: 8px; }
.mac-main .mac-w {
height: auto;
flex: 0 0 auto;
align-self: stretch;
}
.mac-grid-2 { grid-template-columns: 1fr; }
.mac-page__title > span:first-of-type { display: none; }
.mac-page__title-icon { display: none; }
.mac-menu-btn--mobile-only { display: inline-flex; }
.mac-time-row { width: 100%; }
.mac-time { flex: 1; min-width: 0; }
}
</style>