86311ef305
Sprints 04-29 + 04-30 acumuladas. - MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/ Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed. - MelissaEmbed: wrapper generico que injeta layout-variant=melissa e remove cromos pra reaproveitar Pages tradicionais. - 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes, Conversas, Embed, Grupos, Medicos, Recorrencias, Tags. - Dialog blueprint atualizado: bg-gray-100 (hardcoded light) -> bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em 9 arquivos. Anti-pattern documentado. - PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name), toggle vertical/abas com persist localStorage, sticky margin-top. - Surface picker no popover do MelissaLayout (8 swatches). - useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos. - Migration: status agenda remarcado/confirmado. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1715 lines
80 KiB
Vue
1715 lines
80 KiB
Vue
<!--
|
||
|--------------------------------------------------------------------------
|
||
| 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 Seg–Sex; 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 Seg–Sex; 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:00–18: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 (1–5) 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">
|
||
<!-- Seg–Sex -->
|
||
<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>
|
||
Há 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" />
|
||
Há {{ 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>
|