f17e9ee786
Fluxos anon identificam tenant por token/slug e nao resolvem o schema fisico. Decisao (opcao C): manter em public com RLS por token. Volta a global: patient_intake_requests, patient_invites, patient_invite_attempts, document_share_links, agendador_configuracoes, agendador_solicitacoes. - migration 20260613000001_f1b: remove as 6 do _tenant_template (template v2, 78 tabelas). Smoke: clone gera 78, zero tabelas anon no schema, drop limpo - frontend: 38 cadeias em 14 arquivos revertidas tenantDb().from() -> supabase.from() com tenant_id/owner_id restaurado (via comparacao com main) - edge: convert-abandoned-intakes restaurada do main (SELECT global) - save-intake-progress: ja usava public, sem mudanca - doc F0 atualizado: 78 tenant + 59 global Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3614 lines
172 KiB
Vue
3614 lines
172 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/features/agenda/pages/AgendaTerapeutaPage.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import { useRouter, useRoute } from 'vue-router';
|
|
import { useTenantStore } from '@/stores/tenantStore';
|
|
import { supabase } from '@/lib/supabase/client';
|
|
|
|
import { tenantDb } from '@/lib/supabase/tenantClient';
|
|
import { useToast } from 'primevue/usetoast';
|
|
import { useConfirm } from 'primevue/useconfirm';
|
|
|
|
import DatePicker from 'primevue/datepicker';
|
|
|
|
import FullCalendar from '@fullcalendar/vue3';
|
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
|
import listPlugin from '@fullcalendar/list';
|
|
import interactionPlugin from '@fullcalendar/interaction';
|
|
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
|
import { FC_TOUCH_DEFAULTS } from '@/features/agenda/utils/fcDefaults';
|
|
|
|
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
|
|
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue';
|
|
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue';
|
|
import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosCard.vue';
|
|
|
|
import { useSupportDebugStore } from '@/support/supportDebugStore';
|
|
import { logEvent, logError } from '@/support/supportLogger';
|
|
|
|
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
|
|
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents';
|
|
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
|
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
|
|
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
|
|
import { useFeriados } from '@/composables/useFeriados';
|
|
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
|
|
|
|
// Fase C (replicação Rail): adopta agendaBilling.service via composable
|
|
// reutilizável. Cobre status change com confirm dialog + multa + reverse +
|
|
// pacote saldo/upfront (C7-C13 de Melissa).
|
|
import { useAgendaStatusChange } from '@/features/agenda/composables/useAgendaStatusChange';
|
|
import { createPackageContract, materializeAndChargePerSession } from '@/features/agenda/services/agendaBilling.service';
|
|
import AgendaStatusChangeConfirmDialog from '@/features/agenda/components/AgendaStatusChangeConfirmDialog.vue';
|
|
|
|
import { mapAgendaEventosToCalendarEvents, buildWeeklyBreakBackgroundEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
|
|
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
|
|
// Data inicial via query param ?date=YYYY-MM-DD (vindo de "Ver na agenda")
|
|
const _queryDate = route.query.date ? new Date(route.query.date + 'T12:00:00') : null;
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
|
|
const tenantStore = useTenantStore();
|
|
const clinicTenantId = computed(() => tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id || null);
|
|
|
|
function miniGoToday() {
|
|
const d = new Date();
|
|
miniDate.value = d;
|
|
getApi()?.gotoDate?.(d);
|
|
}
|
|
|
|
// ✅ catálogo determinístico (tenant atual, já filtrado no composable)
|
|
const { rows: determinedCommitments, load: loadDeterminedCommitments, loading: dcLoading, error: dcError } = useDeterminedCommitments(clinicTenantId);
|
|
|
|
// ✅ agora no formato que o Dialog novo usa (name/description/native_key...)
|
|
const commitmentOptionsNormalized = computed(() => {
|
|
const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : [];
|
|
|
|
// prioridade pra "Sessão" primeiro (native_key = session)
|
|
const priority = new Map([
|
|
['session', 0],
|
|
['class', 1],
|
|
['study', 2],
|
|
['reading', 3],
|
|
['supervision', 4],
|
|
['content_creation', 5]
|
|
]);
|
|
|
|
return [...list]
|
|
.filter((i) => i?.id && i?.active !== false)
|
|
.sort((a, b) => {
|
|
const pa = priority.has(a.native_key) ? priority.get(a.native_key) : 99;
|
|
const pb = priority.has(b.native_key) ? priority.get(b.native_key) : 99;
|
|
if (pa !== pb) return pa - pb;
|
|
return String(a.name || '').localeCompare(String(b.name || ''), 'pt-BR');
|
|
})
|
|
.map((i) => ({
|
|
id: i.id,
|
|
tenant_id: i.tenant_id ?? null,
|
|
created_by: i.created_by ?? null,
|
|
name: String(i.name || '').trim() || 'Sem nome',
|
|
description: i.description || '',
|
|
native_key: i.native_key || null,
|
|
is_native: !!i.is_native,
|
|
is_locked: !!i.is_locked,
|
|
active: i.active !== false,
|
|
bg_color: i.bg_color || null,
|
|
text_color: i.text_color || null,
|
|
fields: Array.isArray(i.determined_commitment_fields) ? [...i.determined_commitment_fields].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) : []
|
|
}));
|
|
});
|
|
|
|
// -----------------------------
|
|
// settings + events
|
|
// -----------------------------
|
|
const { error: settingsError, settings, workRules, load: loadSettings } = useAgendaSettings();
|
|
const { error: eventsError, rows, loading: eventsLoading, loadMyRange, create, update, remove } = useAgendaEvents();
|
|
const eventsHasLoaded = ref(false);
|
|
watch(eventsLoading, (val) => {
|
|
if (!val) eventsHasLoaded.value = true;
|
|
});
|
|
|
|
// Fase C: orquestrador de status change (Melissa pattern). Cobre confirm
|
|
// dialog + multa + reverse + pacote saldo/upfront via agendaBilling.service.
|
|
const {
|
|
dialogOpen: statusDialogOpen,
|
|
dialogProps: statusDialogProps,
|
|
onDialogConfirm: onStatusDialogConfirm,
|
|
onDialogCancel: onStatusDialogCancel,
|
|
applyStatusChange
|
|
} = useAgendaStatusChange({ toast });
|
|
|
|
const { loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException } = useRecurrence();
|
|
|
|
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
|
|
|
|
const ownerId = computed(() => settings.value?.owner_id || '');
|
|
|
|
// -----------------------------
|
|
// Feriados
|
|
// -----------------------------
|
|
const { fcEvents: feriadoFcEvents, load: loadFeriados } = useFeriados();
|
|
|
|
// -----------------------------
|
|
// Bloqueios (background events cinza no FC)
|
|
// -----------------------------
|
|
const { bloqueios: bloqueioRows, load: loadBloqueios, buildEventsForRange: buildBloqueioEvents } = useAgendaBloqueios();
|
|
const bloqueioFcEvents = computed(() => {
|
|
const s = currentRange.value.start;
|
|
const e = currentRange.value.end;
|
|
if (!s || !e) return [];
|
|
return buildBloqueioEvents(s, e);
|
|
});
|
|
|
|
// Detecta se um range [start, end] cai dentro de algum bloqueio carregado.
|
|
// Cobre bloqueios dia-inteiro (sem hora) e bloqueios com janela horária.
|
|
// Recorrentes semanais cobertos via dia_semana.
|
|
function bloqueioCobrindo(start, end) {
|
|
const arr = bloqueioRows?.value || [];
|
|
if (!arr.length || !start) return null;
|
|
const dStart = start instanceof Date ? start : new Date(start);
|
|
const dEnd = end instanceof Date ? end : new Date(end || start);
|
|
const isoDay = `${dStart.getFullYear()}-${String(dStart.getMonth() + 1).padStart(2, '0')}-${String(dStart.getDate()).padStart(2, '0')}`;
|
|
const dow = dStart.getDay();
|
|
const hhmmStart = dStart.getHours() * 60 + dStart.getMinutes();
|
|
const hhmmEnd = dEnd.getHours() * 60 + dEnd.getMinutes();
|
|
|
|
const parseHM = (s) => {
|
|
if (!s) return null;
|
|
const [h, m] = String(s).split(':').map(Number);
|
|
return Number.isFinite(h) ? h * 60 + (m || 0) : null;
|
|
};
|
|
|
|
for (const b of arr) {
|
|
if (!b) continue;
|
|
// Recorrente semanal
|
|
if (b.recorrente && b.dia_semana != null) {
|
|
if (Number(b.dia_semana) !== dow) continue;
|
|
} else {
|
|
const di = b.data_inicio;
|
|
const df = b.data_fim || b.data_inicio;
|
|
if (!di) continue;
|
|
if (isoDay < di || isoDay > df) continue;
|
|
}
|
|
// Bloqueio sem hora = dia inteiro
|
|
const bhi = parseHM(b.hora_inicio);
|
|
const bhf = parseHM(b.hora_fim);
|
|
if (bhi == null || bhf == null) return b;
|
|
// Sobreposição de janela
|
|
if (hhmmStart < bhf && hhmmEnd > bhi) return b;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
|
if (tid) loadFeriados(tid);
|
|
});
|
|
|
|
// Carrega desativados assim que ownerId estiver disponível
|
|
watch(
|
|
ownerId,
|
|
(id) => {
|
|
if (id) loadDesativados();
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
// Range atual
|
|
const currentRange = ref({ start: null, end: null });
|
|
|
|
// -----------------------------
|
|
// Topbar state
|
|
// -----------------------------
|
|
const onlySessions = ref(false);
|
|
const calendarView = ref('day'); // day | week | month
|
|
const timeMode = ref('my'); // 24 | 12 | my
|
|
const search = ref('');
|
|
|
|
const searchModalOpen = ref(false);
|
|
const miniDate = ref(_queryDate || new Date());
|
|
const monthPickerVisible = ref(false);
|
|
const monthPickerDate = ref(_queryDate || new Date());
|
|
|
|
// -----------------------------
|
|
// Opções UI
|
|
// -----------------------------
|
|
const onlySessionsOptions = [
|
|
{ label: 'Apenas Sessões', value: true },
|
|
{ label: 'Tudo', value: false }
|
|
];
|
|
const viewOptions = [
|
|
{ label: 'Dia', value: 'day' },
|
|
{ label: 'Semana', value: 'week' },
|
|
{ label: 'Mês', value: 'month' },
|
|
{ label: 'Lista', value: 'list' }
|
|
];
|
|
const timeModeOptions = [
|
|
{ label: '24h', value: '24' },
|
|
{ label: '12h', value: '12' },
|
|
{ label: 'Meu Horário', value: 'my' }
|
|
];
|
|
|
|
// -----------------------------
|
|
// ✅ ENUM do banco: tipo_evento_agenda (somente sessao/bloqueio)
|
|
// -----------------------------
|
|
const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' });
|
|
const EVENTO_TIPOS_VALIDOS = new Set([EVENTO_TIPO.SESSAO, EVENTO_TIPO.BLOQUEIO]);
|
|
|
|
function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) {
|
|
const s = String(t || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!s) return fallback;
|
|
if (s.includes('sess')) return EVENTO_TIPO.SESSAO;
|
|
if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
|
|
return EVENTO_TIPOS_VALIDOS.has(s) ? s : fallback;
|
|
}
|
|
|
|
function deriveEventoTipoForNewEvent(payload) {
|
|
const vis = String(payload?.visibility_scope || '').toLowerCase();
|
|
const title = String(payload?.titulo || '').toLowerCase();
|
|
if (vis === 'busy_only' || title.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
|
|
return EVENTO_TIPO.SESSAO;
|
|
}
|
|
|
|
function deriveTituloDefaultByTipo(tipo) {
|
|
return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão';
|
|
}
|
|
|
|
// -----------------------------
|
|
// Dialog state
|
|
// -----------------------------
|
|
const dialogOpen = ref(false);
|
|
const dialogEventRow = ref(null);
|
|
const dialogStartISO = ref('');
|
|
const dialogEndISO = ref('');
|
|
|
|
// -----------------------------
|
|
// FullCalendar
|
|
// -----------------------------
|
|
// Hero sticky header
|
|
const headerSentinelRef = ref(null);
|
|
const headerEl = ref(null);
|
|
const headerStuck = ref(false);
|
|
const headerMenuRef = ref(null);
|
|
const agPanelOpen = ref(false);
|
|
const blockMenuRef = ref(null);
|
|
|
|
// Fecha o drawer mobile ao cruzar para desktop (≥ xl / 1280px)
|
|
const mqDesktop = typeof window !== 'undefined' ? window.matchMedia('(min-width: 1280px)') : null;
|
|
const onMqDesktopChange = (e) => {
|
|
if (e.matches) agPanelOpen.value = false;
|
|
};
|
|
|
|
// ── Prontuário ────────────────────────────────────────────────
|
|
const prontuarioOpen = ref(false);
|
|
const selectedPatient = ref(null);
|
|
|
|
function openProntuario(patientId, patientNome) {
|
|
if (!patientId) return;
|
|
selectedPatient.value = { id: patientId, nome_completo: patientNome || '' };
|
|
prontuarioOpen.value = true;
|
|
}
|
|
function closeProntuario() {
|
|
prontuarioOpen.value = false;
|
|
selectedPatient.value = null;
|
|
}
|
|
|
|
// ── Menu de contexto: Sessões Hoje ────────────────────────────
|
|
const todayEvMenuRef = ref(null);
|
|
const _todayEvAtivo = ref(null);
|
|
|
|
const todayEvMenuItems = computed(() => [
|
|
{
|
|
label: 'Opções',
|
|
items: [
|
|
{
|
|
label: 'Ver prontuário',
|
|
icon: 'pi pi-file-edit',
|
|
disabled: !(_todayEvAtivo.value?.patient_id || _todayEvAtivo.value?.paciente_id),
|
|
command: () => {
|
|
const id = _todayEvAtivo.value?.patient_id || _todayEvAtivo.value?.paciente_id;
|
|
const nome = _todayEvAtivo.value?.paciente_nome || _todayEvAtivo.value?.patient_name || '';
|
|
openProntuario(id, nome);
|
|
}
|
|
},
|
|
{
|
|
label: 'Abrir na agenda',
|
|
icon: 'pi pi-calendar',
|
|
command: () => {
|
|
if (_todayEvAtivo.value) onEventRowClick(_todayEvAtivo.value);
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]);
|
|
|
|
function openTodayEvMenu(event, ev) {
|
|
event.stopPropagation();
|
|
_todayEvAtivo.value = ev;
|
|
todayEvMenuRef.value?.toggle(event);
|
|
}
|
|
|
|
// Bloqueio dialog
|
|
const bloqueioDialogOpen = ref(false);
|
|
const bloqueioMode = ref('horario');
|
|
|
|
function openBloqueioDialog(mode) {
|
|
if (!ownerId.value) {
|
|
toast.add({ severity: 'warn', summary: 'Agenda', detail: 'Aguarde carregar as configurações da agenda.', life: 3000 });
|
|
return;
|
|
}
|
|
bloqueioMode.value = mode;
|
|
bloqueioDialogOpen.value = true;
|
|
}
|
|
|
|
const headerMenuItems = computed(() => [
|
|
{ label: 'Novo compromisso', icon: 'pi pi-plus', command: () => onCreateFromButton() },
|
|
{
|
|
label: 'Buscar',
|
|
icon: 'pi pi-search',
|
|
command: () => {
|
|
searchModalOpen.value = true;
|
|
}
|
|
},
|
|
{ separator: true },
|
|
{ label: 'Hoje', icon: 'pi pi-calendar', command: () => goToday() },
|
|
{ label: 'Anterior', icon: 'pi pi-chevron-left', command: () => goPrev() },
|
|
{ label: 'Próximo', icon: 'pi pi-chevron-right', command: () => goNext() },
|
|
{ separator: true },
|
|
{ label: 'Bloquear por Horário', icon: 'pi pi-clock', command: () => openBloqueioDialog('horario') },
|
|
{ label: 'Bloquear por Período', icon: 'pi pi-calendar-clock', command: () => openBloqueioDialog('periodo') },
|
|
{ label: 'Bloquear por Dia', icon: 'pi pi-calendar-times', command: () => openBloqueioDialog('dia') },
|
|
{ label: 'Bloqueio por Feriados', icon: 'pi pi-star', command: () => openBloqueioDialog('feriados') },
|
|
...(feriadosSemBloqueio.value.length
|
|
? [
|
|
{
|
|
label: `Feriados sem bloqueio (${feriadosSemBloqueio.value.length})`,
|
|
icon: 'pi pi-bell',
|
|
command: () => {
|
|
feriadosAlertaOpen.value = true;
|
|
}
|
|
}
|
|
]
|
|
: []),
|
|
{ separator: true },
|
|
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => refetch() },
|
|
{ label: 'Recorrências', icon: 'pi pi-sync', command: () => goRecorrencias() },
|
|
{ label: 'Configurações', icon: 'pi pi-cog', command: () => goSettings() }
|
|
]);
|
|
|
|
const fcRef = ref(null);
|
|
const currentDate = ref(new Date());
|
|
|
|
// Só remonta o FC ao trocar de view (day/week/month).
|
|
// timeMode NÃO entra no key: slotMinTime/Max/businessHours atualizam reativamente,
|
|
// e remontar ao mudar de modo fazia o calendário perder a data de navegação.
|
|
const fcKey = computed(() => calendarView.value);
|
|
// 'local' faz o FullCalendar usar o timezone do browser — sem precisar de plugin extra.
|
|
// Com um named timezone (ex: 'America/Sao_Paulo') sem o luxon/moment-timezone plugin,
|
|
// o FC exibe os eventos no horário UTC bruto em vez de converter.
|
|
const timezone = computed(() => 'local');
|
|
|
|
// Grade visual sempre em 15 min — independente da duração configurada das sessões.
|
|
// A duração da sessão (ex: 1h06) é usada apenas no dialog (onSelectTime/onCreateFromButton).
|
|
const slotDuration = '00:15:00'; // grade visual sempre 15min
|
|
|
|
const snapDuration = '00:15:00';
|
|
const slotLabelInterval = '00:30';
|
|
|
|
// ── Jornada por dia (agenda_regras_semanais) ───────────────
|
|
// fallback: usa agenda_configuracoes se não houver regras semanais
|
|
function settingsFallbackStart() {
|
|
const s = settings.value;
|
|
return (s?.usar_horario_admin_custom && s?.admin_inicio_visualizacao) || s?.agenda_custom_start || '08:00:00';
|
|
}
|
|
function settingsFallbackEnd() {
|
|
const s = settings.value;
|
|
return (s?.usar_horario_admin_custom && s?.admin_fim_visualizacao) || s?.agenda_custom_end || '18:00:00';
|
|
}
|
|
|
|
const businessHours = computed(() => {
|
|
if (timeMode.value === '24') return [];
|
|
const rules = workRules.value;
|
|
if (rules.length > 0) {
|
|
return rules.map((r) => ({
|
|
daysOfWeek: [r.dia_semana],
|
|
startTime: String(r.hora_inicio || '').slice(0, 5),
|
|
endTime: String(r.hora_fim || '').slice(0, 5)
|
|
}));
|
|
}
|
|
// fallback: todos os dias úteis com horário global
|
|
return [{ daysOfWeek: [1, 2, 3, 4, 5], startTime: settingsFallbackStart(), endTime: settingsFallbackEnd() }];
|
|
});
|
|
|
|
// Arredonda HH:MM:SS para baixo até a fronteira de 30 min (ex: 12:09 → 12:00, 12:39 → 12:30)
|
|
function floorTo30(hhmmss) {
|
|
const [h, m] = String(hhmmss || '00:00')
|
|
.slice(0, 5)
|
|
.split(':')
|
|
.map(Number);
|
|
const rm = m < 30 ? 0 : 30;
|
|
return `${String(h).padStart(2, '0')}:${String(rm).padStart(2, '0')}:00`;
|
|
}
|
|
// Arredonda HH:MM:SS para cima até a fronteira de 30 min (ex: 21:09 → 21:30, 21:30 → 21:30, 21:31 → 22:00)
|
|
function ceilTo30(hhmmss) {
|
|
const [h, m] = String(hhmmss || '00:00')
|
|
.slice(0, 5)
|
|
.split(':')
|
|
.map(Number);
|
|
if (m === 0 || m === 30) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
|
|
if (m < 30) return `${String(h).padStart(2, '0')}:30:00`;
|
|
return `${String(h + 1).padStart(2, '0')}:00:00`;
|
|
}
|
|
|
|
// Retorna { start, end } para o modo 'my' de acordo com a view e o dia atual.
|
|
// start/end são SEMPRE na fronteira de 30 min para que getMinutes() retorne 0 ou 30
|
|
// e os labels de hora cheia apareçam corretamente.
|
|
function myHoursRange() {
|
|
const rules = workRules.value;
|
|
if (rules.length === 0) {
|
|
return {
|
|
start: floorTo30(settingsFallbackStart()),
|
|
end: ceilTo30(settingsFallbackEnd())
|
|
};
|
|
}
|
|
if (calendarView.value === 'day') {
|
|
const dow = currentDate.value.getDay();
|
|
const rule = rules.find((r) => r.dia_semana === dow);
|
|
if (rule) return { start: floorTo30(rule.hora_inicio), end: ceilTo30(rule.hora_fim) };
|
|
return { start: '00:00:00', end: '24:00:00' };
|
|
}
|
|
// visão semana/mês: usa min/max de todos os dias com jornada
|
|
const starts = rules.map((r) => floorTo30(r.hora_inicio));
|
|
const ends = rules.map((r) => ceilTo30(r.hora_fim));
|
|
return {
|
|
start: starts.reduce((a, b) => (a < b ? a : b)),
|
|
end: ends.reduce((a, b) => (a > b ? a : b))
|
|
};
|
|
}
|
|
|
|
const slotMinTime = computed(() => {
|
|
if (timeMode.value === '24') return '00:00:00';
|
|
if (timeMode.value === '12') return '06:00:00';
|
|
return myHoursRange().start;
|
|
});
|
|
|
|
const slotMaxTime = computed(() => {
|
|
if (timeMode.value === '24') return '24:00:00';
|
|
if (timeMode.value === '12') return '18:00:00';
|
|
return myHoursRange().end;
|
|
});
|
|
|
|
const fcViewName = computed(() => {
|
|
if (calendarView.value === 'day') return 'timeGridDay';
|
|
if (calendarView.value === 'week') return 'timeGridWeek';
|
|
if (calendarView.value === 'list') return 'listWeek';
|
|
return 'dayGridMonth';
|
|
});
|
|
|
|
// Todos os rows: reais + ocorrências virtuais
|
|
const allRows = computed(() => [...(rows.value || []), ...(_occurrenceRows.value || [])]);
|
|
|
|
const calendarRows = computed(() => {
|
|
return allRows.value.filter((r) => {
|
|
// "Apenas Sessões" = eventos vinculados a paciente.
|
|
// Filtrar por tipo não funciona pois o banco usa 'sessao' para todos os compromissos
|
|
// que não são bloqueio — compromissos pessoais (Análise, Leitura, etc.) têm o mesmo tipo.
|
|
if (onlySessions.value && !(r.patient_id || r.paciente_id)) return false;
|
|
return true;
|
|
});
|
|
});
|
|
|
|
// ── Detecção de eventos fora da jornada ───────────────────
|
|
function timeStrToMin(t) {
|
|
const [h, m] = String(t || '00:00')
|
|
.split(':')
|
|
.map(Number);
|
|
return h * 60 + m;
|
|
}
|
|
|
|
const hasEventsOutsideWorkHours = computed(() => {
|
|
if (timeMode.value === '24') return false;
|
|
const rules = workRules.value;
|
|
if (!rules.length) return false;
|
|
|
|
return calendarRows.value.some((r) => {
|
|
if (!r.inicio_em) return false;
|
|
const d = new Date(r.inicio_em);
|
|
const dow = d.getDay();
|
|
const rule = rules.find((rr) => Number(rr.dia_semana) === dow);
|
|
// dia sem regra = fora da jornada
|
|
if (!rule) return true;
|
|
|
|
const ruleStart = timeStrToMin(String(rule.hora_inicio || '').slice(0, 5));
|
|
const ruleEnd = timeStrToMin(String(rule.hora_fim || '').slice(0, 5));
|
|
const evStart = d.getHours() * 60 + d.getMinutes();
|
|
if (evStart < ruleStart) return true;
|
|
if (r.fim_em) {
|
|
const e = new Date(r.fim_em);
|
|
if (e.getHours() * 60 + e.getMinutes() > ruleEnd) return true;
|
|
}
|
|
return false;
|
|
});
|
|
});
|
|
|
|
watch(
|
|
hasEventsOutsideWorkHours,
|
|
(hasOut) => {
|
|
if (hasOut && timeMode.value === 'my') timeMode.value = '24';
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
const searchTrim = computed(() => String(search.value || '').trim());
|
|
const searchScope = ref('month'); // 'range' | 'month'
|
|
const monthSearchRows = ref([]);
|
|
const monthSearchLoading = ref(false);
|
|
|
|
function _norm(s) {
|
|
return String(s || '')
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/\p{Diacritic}/gu, '')
|
|
.trim();
|
|
}
|
|
|
|
function _matchRow(r, q) {
|
|
const qn = _norm(q);
|
|
return _norm(r.titulo).includes(qn) || _norm(r.observacoes).includes(qn) || _norm(r.tipo).includes(qn) || _norm(r.paciente_nome || r.patient_name || r.nome_paciente).includes(qn);
|
|
}
|
|
|
|
const searchLoading = computed(() => monthSearchLoading.value);
|
|
|
|
const searchResults = computed(() => {
|
|
const q = searchTrim.value.toLowerCase();
|
|
if (!q) return [];
|
|
// scope 'range': usa calendarRows (reais) + occurrences com nome derivado
|
|
const source =
|
|
searchScope.value === 'month'
|
|
? monthSearchRows.value
|
|
: (calendarRows.value || []).map((r) => ({
|
|
...r,
|
|
paciente_nome: r.paciente_nome || r.patient_name || r.nome_paciente || '',
|
|
paciente_status: r.paciente_status || r.extendedProps?.paciente_status || ''
|
|
}));
|
|
return source.filter((r) => _matchRow(r, q));
|
|
});
|
|
|
|
async function loadMonthSearchRows() {
|
|
const uid = ownerId.value;
|
|
if (!uid) return;
|
|
const d = currentDate.value;
|
|
const startDate = new Date(d.getFullYear(), d.getMonth(), 1);
|
|
const endDate = new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59);
|
|
const startISO = startDate.toISOString();
|
|
const endISO = new Date(d.getFullYear(), d.getMonth() + 1, 1).toISOString();
|
|
monthSearchLoading.value = true;
|
|
try {
|
|
// 1. Eventos reais do banco — inclui recurrence_id/recurrence_date para
|
|
// mergeWithStoredSessions deduplicar sessões materializadas de séries.
|
|
const { data, error } = await tenantDb().from('agenda_eventos')
|
|
.select(
|
|
'id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo, status)'
|
|
)
|
|
.eq('owner_id', uid)
|
|
.is('mirror_of_event_id', null)
|
|
.gte('inicio_em', startISO)
|
|
.lt('inicio_em', endISO)
|
|
.order('inicio_em', { ascending: true });
|
|
if (error) throw error;
|
|
|
|
const realRows = (data || []).map((r) => ({ ...r, paciente_nome: r.patients?.nome_completo || '', paciente_status: r.patients?.status || '' }));
|
|
|
|
// 2. Ocorrências virtuais de recorrência (não existem em agenda_eventos).
|
|
// loadAndExpand retorna merged = reais + virtuais; filtramos só is_occurrence
|
|
// para não duplicar sessões reais já contidas em realRows.
|
|
const activeTenantId = tenantStore.activeTenantId || null;
|
|
const expanded = await loadAndExpand(uid, startDate, endDate, data || [], activeTenantId);
|
|
const virtualOccs = expanded.filter((r) => r.is_occurrence);
|
|
|
|
monthSearchRows.value = [...realRows, ...virtualOccs];
|
|
} catch {
|
|
monthSearchRows.value = [];
|
|
} finally {
|
|
monthSearchLoading.value = false;
|
|
}
|
|
}
|
|
|
|
watch([searchTrim, searchScope], ([q, scope]) => {
|
|
if (q && scope === 'month') loadMonthSearchRows();
|
|
else if (!q) monthSearchRows.value = [];
|
|
});
|
|
|
|
watch(currentDate, (newD, oldD) => {
|
|
if (searchTrim.value && searchScope.value === 'month' && (newD.getFullYear() !== oldD?.getFullYear() || newD.getMonth() !== oldD?.getMonth())) loadMonthSearchRows();
|
|
});
|
|
|
|
const calendarEvents = computed(() => {
|
|
// Mapa id → {bg_color, text_color} dos commitments já carregados na página.
|
|
// Usado para injetar cores nas ocorrências virtuais, que não têm o join
|
|
// determined_commitments (useRecurrence faz select('*') sem join).
|
|
const colorMap = new Map((commitmentOptionsNormalized.value || []).filter((c) => c.id).map((c) => [c.id, { bg_color: c.bg_color || null, text_color: c.text_color || null }]));
|
|
|
|
// Injeta determined_commitments em qualquer row que ainda não tenha — resolve
|
|
// tanto ocorrências virtuais quanto eventos reais com join nulo.
|
|
function withCommitmentColors(r) {
|
|
if (r.determined_commitments || !r.determined_commitment_id) return r;
|
|
const colors = colorMap.get(r.determined_commitment_id);
|
|
return colors ? { ...r, determined_commitments: colors } : r;
|
|
}
|
|
|
|
// separa reais e virtuais para aplicar mapAgendaEventosToCalendarEvents
|
|
// em cada grupo — as virtuais precisam do mesmo tratamento de cores
|
|
const realRows = calendarRows.value.filter((r) => !r.is_occurrence).map(withCommitmentColors);
|
|
const occRows = calendarRows.value.filter((r) => r.is_occurrence).map(withCommitmentColors);
|
|
|
|
const base = mapAgendaEventosToCalendarEvents(realRows);
|
|
const occEvents = mapAgendaEventosToCalendarEvents(occRows);
|
|
|
|
const breaks = settings.value && currentRange.value.start && currentRange.value.end ? buildWeeklyBreakBackgroundEvents(settings.value.pausas_semanais, currentRange.value.start, currentRange.value.end) : [];
|
|
|
|
return [...base, ...occEvents, ...breaks, ...feriadoFcEvents.value, ...bloqueioFcEvents.value];
|
|
});
|
|
|
|
const visibleTitle = computed(() => {
|
|
const d = currentDate.value;
|
|
const month = d.toLocaleDateString('pt-BR', { month: 'long' });
|
|
const year = d.getFullYear();
|
|
return `${capitalize(month)} ${year}`;
|
|
});
|
|
|
|
const subtitlePrefix = computed(() => (calendarView.value === 'month' ? '' : ' '));
|
|
const subtitleText = computed(() => {
|
|
if (calendarView.value === 'month') return visibleTitle.value;
|
|
const d = currentDate.value;
|
|
const weekday = d.toLocaleDateString('pt-BR', { weekday: 'long' });
|
|
const day = d.getDate();
|
|
const month = d.toLocaleDateString('pt-BR', { month: 'long' });
|
|
const base = `${capitalize(weekday)}, ${day} de ${capitalize(month)}`;
|
|
const today = new Date();
|
|
const isToday = d.getDate() === today.getDate() && d.getMonth() === today.getMonth() && d.getFullYear() === today.getFullYear();
|
|
return isToday ? `Hoje — ${base}` : base;
|
|
});
|
|
|
|
function fmtHora(hhmm) {
|
|
const [h, m] = String(hhmm || '00:00')
|
|
.slice(0, 5)
|
|
.split(':')
|
|
.map(Number);
|
|
return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`;
|
|
}
|
|
|
|
const jornadaHoje = computed(() => {
|
|
const rules = workRules.value;
|
|
if (!rules.length) return null;
|
|
// na visão semana/mês usa a data âncora (currentDate); em dia, é o dia exibido
|
|
const dow = currentDate.value.getDay();
|
|
const rule = rules.find((r) => Number(r.dia_semana) === dow);
|
|
if (!rule) return 'Folga';
|
|
|
|
const inicio = String(rule.hora_inicio || '').slice(0, 5);
|
|
const fim = String(rule.hora_fim || '').slice(0, 5);
|
|
const [h1, m1] = inicio.split(':').map(Number);
|
|
const [h2, m2] = fim.split(':').map(Number);
|
|
const totalMin = h2 * 60 + m2 - (h1 * 60 + m1);
|
|
const durH = Math.floor(totalMin / 60);
|
|
const durM = totalMin % 60;
|
|
const durStr = durM > 0 ? `${durH}h${String(durM).padStart(2, '0')}` : `${durH}h`;
|
|
|
|
return `das ${fmtHora(inicio)} às ${fmtHora(fim)} (${durStr} de trabalho)`;
|
|
});
|
|
|
|
// Funções estáveis fora do computed — referências fixas, o Vue adapter nunca as re-aplica.
|
|
function slotLabelContent(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>' };
|
|
}
|
|
|
|
// slotMinTime/Max são gerenciados EXCLUSIVAMENTE pelo watch abaixo via setOption().
|
|
// NÃO entram no fcOptions computed — isso evita que o Vue adapter dispare setOption()
|
|
// concorrentemente com o watch, causando múltiplos re-renders que corrompem os labels.
|
|
// Os valores iniciais são capturados aqui (antes do mount) para o primeiro render.
|
|
const _initSlotMin = slotMinTime.value;
|
|
const _initSlotMax = slotMaxTime.value;
|
|
|
|
// Eventos são gerenciados 100% de forma imperativa via _pushEventsToFC().
|
|
// NÃO incluímos 'events' no fcOptions — evita que o Vue FC adapter gerencie
|
|
// a fonte e conflite com o watch que usa getEventSources + addEventSource.
|
|
const fcOptions = computed(() => ({
|
|
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
|
|
locale: ptBrLocale,
|
|
...FC_TOUCH_DEFAULTS,
|
|
timeZone: timezone.value,
|
|
|
|
headerToolbar: false,
|
|
initialView: 'timeGridDay',
|
|
initialDate: _queryDate || new Date(),
|
|
|
|
nowIndicator: true,
|
|
editable: true,
|
|
selectable: true,
|
|
selectMirror: true,
|
|
|
|
slotMinTime: _initSlotMin,
|
|
slotMaxTime: _initSlotMax,
|
|
slotDuration,
|
|
snapDuration,
|
|
slotLabelInterval,
|
|
slotLabelContent,
|
|
expandRows: false,
|
|
height: 'auto',
|
|
dayMaxEvents: true,
|
|
weekends: true,
|
|
eventMinHeight: 14,
|
|
|
|
views: {
|
|
timeGridDay: {
|
|
dayHeaderFormat: { day: 'numeric', month: 'long', year: 'numeric' }
|
|
}
|
|
},
|
|
|
|
businessHours: businessHours.value,
|
|
|
|
datesSet: async (arg) => {
|
|
const api = getApi();
|
|
const cd = api?.getDate?.();
|
|
const fallback = arg?.start ? new Date(arg.start) : new Date();
|
|
const newDate = cd ? new Date(cd) : fallback;
|
|
|
|
// Só atualiza currentDate se o dia realmente mudou.
|
|
// Sem isso, setOption('slotMinTime') → datesSet → novo Date object → watch re-dispara → loop infinito.
|
|
if (currentDate.value.toDateString() !== newDate.toDateString()) {
|
|
currentDate.value = newDate;
|
|
miniDate.value = new Date(newDate);
|
|
}
|
|
|
|
const start = arg?.start;
|
|
const end = arg?.end;
|
|
if (start && end) {
|
|
const prevStart = currentRange.value.start?.toString();
|
|
const prevEnd = currentRange.value.end?.toString();
|
|
currentRange.value = { start, end };
|
|
// Recarrega sempre que o range mudar OU quando _occurrenceRows estiver vazio
|
|
if (start.toString() !== prevStart || end.toString() !== prevEnd || _occurrenceRows.value.length === 0) {
|
|
await _reloadRange();
|
|
}
|
|
if (eventsError.value) toast.add({ severity: 'warn', summary: 'Compromissos', detail: eventsError.value, life: 4500 });
|
|
}
|
|
},
|
|
|
|
select: (selection) => onSelectTime(selection),
|
|
eventClick: (info) => onEventClick(info),
|
|
|
|
eventDrop: (info) => persistMoveOrResize(info, 'Movido'),
|
|
eventResize: (info) => persistMoveOrResize(info, 'Redimensionado'),
|
|
|
|
eventContent: (arg) => {
|
|
const ext = arg.event.extendedProps || {};
|
|
const avatarUrl = ext.paciente_avatar || '';
|
|
const nome = ext.paciente_nome || '';
|
|
const obs = ext.observacoes || '';
|
|
const title = arg.event.title || '';
|
|
const pacienteStatus = ext.paciente_status || '';
|
|
|
|
const esc = (s) =>
|
|
String(s ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
|
|
const initials = (n) => {
|
|
const p = String(n).trim().split(/\s+/).filter(Boolean);
|
|
if (!p.length) return '?';
|
|
if (p.length === 1) return p[0].slice(0, 2).toUpperCase();
|
|
return (p[0][0] + p[p.length - 1][0]).toUpperCase();
|
|
};
|
|
|
|
const fmtHour = (d) => {
|
|
if (!d) return '';
|
|
const h = d.getHours();
|
|
const m = d.getMinutes();
|
|
return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`;
|
|
};
|
|
const range = arg.event.start && arg.event.end ? `${fmtHour(arg.event.start)}-${fmtHour(arg.event.end)}` : arg.timeText || '';
|
|
|
|
const avatarHtml = avatarUrl ? `<img src="${esc(avatarUrl)}" class="ev-avatar ev-avatar-img" />` : nome ? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>` : '';
|
|
|
|
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : '';
|
|
const titleLine = `<div class="ev-title"><span class="ev-name">${esc(title)}</span>${range ? ` <span class="ev-hour">(${esc(range)})</span>` : ''}</div>`;
|
|
const inativoBadge =
|
|
pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado'
|
|
? `<span class="ev-badge">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
|
|
: '';
|
|
|
|
return {
|
|
html: `<div class="ev-custom">
|
|
${avatarHtml}
|
|
<div class="ev-body">
|
|
${titleLine}
|
|
${inativoBadge}
|
|
${obsHtml}
|
|
</div>
|
|
</div>`
|
|
};
|
|
},
|
|
|
|
eventClassNames: (arg) => {
|
|
const tipo = normalizeEventoTipo(arg?.event?.extendedProps?.tipo || arg?.event?.extendedProps?.kind, EVENTO_TIPO.SESSAO);
|
|
const title = String(arg?.event?.title || '');
|
|
const obs = String(arg?.event?.extendedProps?.observacoes || '');
|
|
const nome = String(arg?.event?.extendedProps?.paciente_nome || '');
|
|
|
|
const qn = _norm(searchTrim.value);
|
|
const hit = qn && (_norm(title).includes(qn) || _norm(obs).includes(qn) || _norm(nome).includes(qn) || _norm(tipo).includes(qn));
|
|
|
|
const classes = [];
|
|
if (tipo === EVENTO_TIPO.SESSAO) classes.push('evt-session');
|
|
if (tipo === EVENTO_TIPO.BLOQUEIO) classes.push('evt-block');
|
|
// Quando o evento já tem cor do commitment, marca para que o CSS
|
|
// não sobrescreva com a cor primária padrão via !important
|
|
if (arg?.event?.backgroundColor) classes.push('evt-has-color');
|
|
if (qn && hit) classes.push('evt-hit');
|
|
if (qn && !hit) classes.push('evt-dim');
|
|
return classes;
|
|
},
|
|
|
|
eventDidMount: (info) => {
|
|
const bgColor = info.event.extendedProps?.commitment_bg_color;
|
|
if (bgColor) {
|
|
info.el.style.setProperty('background-color', bgColor, 'important');
|
|
info.el.style.setProperty('border-color', bgColor, 'important');
|
|
}
|
|
// Marca o elemento com o id do evento para scroll+pulse posterior
|
|
info.el.dataset.eventId = info.event.id;
|
|
}
|
|
}));
|
|
|
|
// ── Scroll + pulse no evento do FullCalendar ─────────────────
|
|
const foraJornadaBannerRef = ref(null);
|
|
|
|
async function scrollToAndPulseEvent(eventId) {
|
|
await nextTick();
|
|
// Aguarda o FC renderizar após possível mudança de view/data
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
|
|
// Pulsa o banner "fora da jornada" se estiver visível
|
|
const banner = foraJornadaBannerRef.value;
|
|
if (banner) {
|
|
banner.classList.add('notif-card--highlight');
|
|
setTimeout(() => banner.classList.remove('notif-card--highlight'), 2000);
|
|
}
|
|
|
|
const el = document.querySelector(`[data-event-id="${eventId}"]`);
|
|
if (!el) return;
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
el.classList.add('notif-card--highlight');
|
|
setTimeout(() => el.classList.remove('notif-card--highlight'), 2000);
|
|
}
|
|
|
|
// ── Resumo do dia (coluna direita) ────────────────────────────────────────────
|
|
const todayEvents = computed(() => {
|
|
const today = new Date();
|
|
return allRows.value
|
|
.filter((r) => {
|
|
if (!r.inicio_em) return false;
|
|
const d = new Date(r.inicio_em);
|
|
return d.getDate() === today.getDate() && d.getMonth() === today.getMonth() && d.getFullYear() === today.getFullYear();
|
|
})
|
|
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em));
|
|
});
|
|
|
|
const todayStats = computed(() => {
|
|
const evs = todayEvents.value;
|
|
const total = evs.length;
|
|
const realizado = evs.filter((e) => ['realizado', 'presente'].includes(e.status)).length;
|
|
const faltou = evs.filter((e) => ['faltou', 'falta'].includes(e.status)).length;
|
|
const agendado = evs.filter((e) => !e.status || e.status === 'agendado' || e.status === 'confirmado').length;
|
|
return [
|
|
{ label: 'Total', value: String(total), cls: '' },
|
|
{ label: 'Agendado', value: String(agendado), cls: '' },
|
|
{ label: 'Realizado', value: String(realizado), cls: realizado > 0 ? 'ag-stat--ok' : '' },
|
|
{ label: 'Faltou', value: String(faltou), cls: faltou > 0 ? 'ag-stat--warn' : '' }
|
|
];
|
|
});
|
|
|
|
function fmtHoraEvento(iso) {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
}
|
|
|
|
function fmtDuracao(startIso, endIso) {
|
|
if (!startIso || !endIso) return '';
|
|
const diff = Math.round((new Date(endIso) - new Date(startIso)) / 60000);
|
|
if (!diff) return '';
|
|
return diff >= 60 ? `${Math.floor(diff / 60)}h${diff % 60 ? String(diff % 60).padStart(2, '0') : ''}` : `${diff}min`;
|
|
}
|
|
|
|
function statusIcon(status) {
|
|
const map = {
|
|
confirmado: 'pi pi-check-circle text-green-500',
|
|
realizado: 'pi pi-check-circle text-green-500',
|
|
presente: 'pi pi-check-circle text-green-500',
|
|
cancelado: 'pi pi-times-circle text-red-400',
|
|
faltou: 'pi pi-times-circle text-red-400',
|
|
agendado: 'pi pi-clock text-[var(--text-color-secondary)]'
|
|
};
|
|
return map[status] || map.agendado;
|
|
}
|
|
|
|
function onEventRowClick(ev) {
|
|
if (ev.inicio_em) {
|
|
getApi()?.gotoDate?.(new Date(ev.inicio_em));
|
|
calendarView.value = 'day';
|
|
scrollToAndPulseEvent(ev.id);
|
|
}
|
|
}
|
|
|
|
// ── Links públicos (agendador + cadastro externo) ─────────────────────────────
|
|
const agendadorSlug = ref('');
|
|
const cadastroToken = ref('');
|
|
|
|
const agendadorLink = computed(() => (agendadorSlug.value ? `${window.location.origin}/agendar/${agendadorSlug.value}` : ''));
|
|
const cadastroExternoLink = computed(() => (cadastroToken.value ? `${window.location.origin}/cadastro/paciente?t=${encodeURIComponent(cadastroToken.value)}` : ''));
|
|
|
|
async function loadAgendadorSlug() {
|
|
const uid = ownerId.value;
|
|
if (!uid) return;
|
|
try {
|
|
const { data } = await supabase.from('agendador_configuracoes').select('link_slug').eq('owner_id', uid).eq('ativo', true).maybeSingle();
|
|
agendadorSlug.value = data?.link_slug || '';
|
|
} catch {
|
|
agendadorSlug.value = '';
|
|
}
|
|
}
|
|
|
|
async function loadCadastroToken() {
|
|
try {
|
|
const { data: authData } = await supabase.auth.getUser();
|
|
const uid = authData?.user?.id;
|
|
if (!uid) return;
|
|
const { data } = await supabase.from('patient_invites').select('token').eq('owner_id', uid).eq('active', true).order('created_at', { ascending: false }).limit(1);
|
|
cadastroToken.value = data?.[0]?.token || '';
|
|
} catch {
|
|
cadastroToken.value = '';
|
|
}
|
|
}
|
|
|
|
watch(
|
|
ownerId,
|
|
(v) => {
|
|
if (v) {
|
|
loadAgendadorSlug();
|
|
loadCadastroToken();
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
function copyLink(text) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
toast.add({ severity: 'success', summary: 'Copiado!', detail: 'Link copiado para a área de transferência', life: 2000 });
|
|
});
|
|
}
|
|
|
|
function getApi() {
|
|
return fcRef.value?.getApi?.() || null;
|
|
}
|
|
|
|
// ── Pacientes Desativados com sessões pendentes ────────────
|
|
const desativadoPatients = ref([]);
|
|
const desativadoDialogOpen = ref(false);
|
|
const desativadoSelected = ref(null);
|
|
const desativadoFocused = ref(null);
|
|
const desativadoFcRef = ref(null);
|
|
|
|
async function loadDesativados() {
|
|
if (!ownerId.value) return;
|
|
try {
|
|
const { data: pats, error: pErr } = await tenantDb().from('patients').select('id, nome_completo, status').eq('owner_id', ownerId.value).in('status', ['Inativo', 'Arquivado']);
|
|
|
|
if (pErr) {
|
|
console.warn('[loadDesativados] patients error:', pErr);
|
|
desativadoPatients.value = [];
|
|
return;
|
|
}
|
|
if (!pats?.length) {
|
|
desativadoPatients.value = [];
|
|
return;
|
|
}
|
|
|
|
const patIds = pats.map((p) => p.id);
|
|
const sessQ = tenantDb().from('agenda_eventos').select('id, patient_id, inicio_em, fim_em, status, titulo, modalidade, determined_commitment_id').in('patient_id', patIds).order('inicio_em', { ascending: true });
|
|
if (ownerId.value) sessQ.eq('owner_id', ownerId.value);
|
|
const { data: sessions, error: sErr } = await sessQ;
|
|
|
|
if (sErr) {
|
|
console.warn('[loadDesativados] sessions error:', sErr);
|
|
}
|
|
|
|
const byPat = new Map();
|
|
for (const s of sessions || []) {
|
|
if (!byPat.has(s.patient_id)) byPat.set(s.patient_id, []);
|
|
byPat.get(s.patient_id).push(s);
|
|
}
|
|
|
|
desativadoPatients.value = pats.filter((p) => byPat.has(p.id)).map((p) => ({ ...p, sessions: byPat.get(p.id) }));
|
|
|
|
if (desativadoPatients.value.length && !desativadoSelected.value) {
|
|
desativadoSelected.value = desativadoPatients.value[0];
|
|
}
|
|
} catch (e) {
|
|
console.warn('[loadDesativados] erro:', e);
|
|
}
|
|
}
|
|
|
|
const desativadoFcOptions = computed(() => {
|
|
const patient = desativadoSelected.value;
|
|
if (!patient) return {};
|
|
const events = (patient.sessions || []).map((s) => ({
|
|
id: s.id,
|
|
title: s.titulo || 'Sessão',
|
|
start: s.inicio_em,
|
|
end: s.fim_em,
|
|
backgroundColor: desativadoFocused.value?.id === s.id ? '#ea580c' : '#f97316',
|
|
borderColor: desativadoFocused.value?.id === s.id ? '#ea580c' : '#f97316',
|
|
textColor: '#fff',
|
|
extendedProps: { session: s }
|
|
}));
|
|
const firstDate = patient.sessions[0]?.inicio_em;
|
|
return {
|
|
plugins: [listPlugin, dayGridPlugin, interactionPlugin],
|
|
locale: ptBrLocale,
|
|
initialView: 'listMonth',
|
|
initialDate: firstDate || new Date().toISOString(),
|
|
events,
|
|
headerToolbar: { left: 'prev,next today', center: 'title', right: 'listMonth,dayGridMonth' },
|
|
height: '100%',
|
|
noEventsText: 'Nenhuma sessão encontrada.',
|
|
eventClick: (info) => {
|
|
const s = info.event.extendedProps.session;
|
|
openSessionInMainCalendar(s);
|
|
}
|
|
};
|
|
});
|
|
|
|
watch(desativadoSelected, () => {
|
|
desativadoFocused.value = null;
|
|
});
|
|
|
|
function focusDesativadoSession(session) {
|
|
desativadoFocused.value = session;
|
|
const api = desativadoFcRef.value?.getApi?.();
|
|
if (api) api.gotoDate(new Date(session.inicio_em));
|
|
}
|
|
|
|
function openSessionInMainCalendar(session) {
|
|
desativadoDialogOpen.value = false;
|
|
const date = new Date(session.inicio_em);
|
|
currentDate.value = date;
|
|
getApi()?.gotoDate?.(date);
|
|
// Muda para visão de dia para facilitar encontrar a sessão
|
|
const api = getApi();
|
|
if (api) api.changeView('timeGridDay', date);
|
|
}
|
|
|
|
// Helpers de formato para o painel de desativados
|
|
function fmtDesativadoDate(iso) {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
return d.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' });
|
|
}
|
|
function fmtDesativadoTime(iso) {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
function fmtDesativadoDur(start, end) {
|
|
if (!start || !end) return '';
|
|
const min = Math.round((new Date(end) - new Date(start)) / 60000);
|
|
return `${min}min`;
|
|
}
|
|
|
|
watch(calendarView, async () => {
|
|
await nextTick();
|
|
getApi()?.changeView?.(fcViewName.value);
|
|
});
|
|
|
|
// ✅ slotMinTime/Max são gerenciados SOMENTE aqui (não no fcOptions computed).
|
|
// Watch combinado: ambos setOption no mesmo tick → um único re-render do FC → labels preservados.
|
|
watch([slotMinTime, slotMaxTime], async ([minT, maxT]) => {
|
|
await nextTick();
|
|
const api = getApi();
|
|
if (!api) return;
|
|
api.setOption('slotMinTime', minT);
|
|
api.setOption('slotMaxTime', maxT);
|
|
});
|
|
|
|
watch(searchTrim, (v) => {
|
|
if (!v) searchModalOpen.value = false;
|
|
});
|
|
|
|
// Persiste a data atual na URL (?date=) para que o F5 mantenha o dia visualizado
|
|
watch(currentDate, (d) => {
|
|
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
if (route.query.date !== iso) {
|
|
router.replace({ query: { ...route.query, date: iso } });
|
|
}
|
|
});
|
|
|
|
// ✅ carrega commitments uma vez por tenant
|
|
watch(
|
|
clinicTenantId,
|
|
async (tid) => {
|
|
if (!tid) return;
|
|
await loadDeterminedCommitments();
|
|
if (dcError.value) toast.add({ severity: 'warn', summary: 'Compromissos', detail: dcError.value, life: 4500 });
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
// -----------------------------
|
|
// Topbar actions
|
|
// -----------------------------
|
|
function goToday() {
|
|
getApi()?.today?.();
|
|
}
|
|
function goPrev() {
|
|
getApi()?.prev?.();
|
|
}
|
|
function goNext() {
|
|
getApi()?.next?.();
|
|
}
|
|
|
|
function clearSearch() {
|
|
search.value = '';
|
|
}
|
|
function clearSearchAndClose() {
|
|
search.value = '';
|
|
searchModalOpen.value = false;
|
|
}
|
|
function openSearchModal() {
|
|
if (searchTrim.value) searchModalOpen.value = true;
|
|
}
|
|
|
|
function toggleMonthPicker() {
|
|
monthPickerDate.value = new Date(currentDate.value);
|
|
monthPickerVisible.value = true;
|
|
}
|
|
async function applyMonthPick() {
|
|
monthPickerVisible.value = false;
|
|
await nextTick();
|
|
getApi()?.gotoDate?.(monthPickerDate.value);
|
|
}
|
|
|
|
function onMiniPick(d) {
|
|
if (d) getApi()?.gotoDate?.(d);
|
|
}
|
|
function miniPrevMonth() {
|
|
miniDate.value = shiftMonth(miniDate.value, -1);
|
|
}
|
|
function miniNextMonth() {
|
|
miniDate.value = shiftMonth(miniDate.value, +1);
|
|
}
|
|
|
|
const workDowSet = computed(() => new Set(workRules.value.filter((r) => r.ativo).map((r) => Number(r.dia_semana))));
|
|
|
|
// ISO do dia atual sendo visualizado no FullCalendar
|
|
const currentDateISO = computed(() => {
|
|
const d = currentDate.value;
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
});
|
|
|
|
// ── Mini calendário: set de dias da semana atual ─────────────
|
|
const currentWeekIsoSet = computed(() => {
|
|
const now = new Date();
|
|
const monday = new Date(now);
|
|
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
|
|
monday.setHours(0, 0, 0, 0);
|
|
const set = new Set();
|
|
for (let i = 0; i < 7; i++) {
|
|
const d = new Date(monday);
|
|
d.setDate(monday.getDate() + i);
|
|
set.add(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
|
|
}
|
|
return set;
|
|
});
|
|
|
|
const todayISO = computed(() => {
|
|
const n = new Date();
|
|
return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`;
|
|
});
|
|
|
|
// ── Mini calendário: classes por dia ──────────────────────────
|
|
// Prioridade: bloqueado total > dia de trabalho > folga
|
|
function miniDayClass(date) {
|
|
const iso = `${date.year}-${String(date.month + 1).padStart(2, '0')}-${String(date.day).padStart(2, '0')}`;
|
|
const dow = new Date(date.year, date.month, date.day).getDay();
|
|
const classes = [];
|
|
if (currentWeekIsoSet.value.has(iso)) {
|
|
classes.push('mini-week-hl');
|
|
if (dow === 1) classes.push('mini-week-hl--start');
|
|
else if (dow === 0) classes.push('mini-week-hl--end');
|
|
else classes.push('mini-week-hl--mid');
|
|
}
|
|
if (iso === todayISO.value) classes.push('mini-day-today');
|
|
if (miniBlockedDaySet.value.has(iso)) classes.push('mini-day-blocked');
|
|
else classes.push(workDowSet.value.has(dow) ? 'mini-day-work' : 'mini-day-off');
|
|
return classes;
|
|
}
|
|
|
|
// ── Mini calendário: bolinhas de compromissos + set de dias bloqueados ──
|
|
const miniEventDaySet = ref(new Set());
|
|
const miniBlockedDaySet = ref(new Set()); // dias com bloqueio de dia inteiro
|
|
const miniBlockedLoaded = ref(false); // true após primeira carga dos bloqueios
|
|
|
|
async function loadMiniMonthEvents(refDate) {
|
|
if (!ownerId.value) return; // aguarda settings carregarem — evita 400 com owner_id vazio
|
|
const d = refDate instanceof Date ? refDate : new Date(refDate);
|
|
const year = d.getFullYear();
|
|
const month = d.getMonth();
|
|
const start = new Date(year, month, 1);
|
|
const end = new Date(year, month + 1, 0, 23, 59, 59);
|
|
|
|
try {
|
|
// 1. Eventos reais (agenda_eventos)
|
|
const { data: evData } = await tenantDb().from('agenda_eventos').select('inicio_em').eq('owner_id', ownerId.value).gte('inicio_em', start.toISOString()).lte('inicio_em', end.toISOString());
|
|
|
|
const evSet = new Set();
|
|
for (const r of evData || []) {
|
|
if (!r.inicio_em) continue;
|
|
const ev = new Date(r.inicio_em);
|
|
evSet.add(`${ev.getFullYear()}-${ev.getMonth()}-${ev.getDate()}`);
|
|
}
|
|
|
|
// 2. Ocorrências virtuais de recorrência (não existem no banco)
|
|
const occRows = await loadAndExpand(ownerId.value, start, end, rows.value, clinicTenantId.value);
|
|
for (const r of occRows || []) {
|
|
if (!r.inicio_em || !r.is_occurrence) continue;
|
|
const ev = new Date(r.inicio_em);
|
|
evSet.add(`${ev.getFullYear()}-${ev.getMonth()}-${ev.getDate()}`);
|
|
}
|
|
|
|
miniEventDaySet.value = evSet;
|
|
|
|
// 2. Bloqueios de dia inteiro (fundo vermelho forte)
|
|
// Usa ISO local (não UTC) para evitar off-by-one em fusos negativos como -03:00
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
const isoStart = `${year}-${pad(month + 1)}-01`;
|
|
const lastDay = new Date(year, month + 1, 0).getDate();
|
|
const isoEnd = `${year}-${pad(month + 1)}-${pad(lastDay)}`;
|
|
const { data: blkData } = await tenantDb().from('agenda_bloqueios')
|
|
.select('data_inicio')
|
|
.eq('owner_id', ownerId.value || '')
|
|
.is('hora_inicio', null)
|
|
.gte('data_inicio', isoStart)
|
|
.lte('data_inicio', isoEnd);
|
|
|
|
miniBlockedDaySet.value = new Set((blkData || []).map((r) => r.data_inicio));
|
|
miniBlockedLoaded.value = true;
|
|
} catch (e) {
|
|
logError('AgendaTerapeutaPage', 'miniBlocks erro', e);
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => {
|
|
const d = miniDate.value instanceof Date ? miniDate.value : new Date(miniDate.value);
|
|
return `${d.getFullYear()}-${d.getMonth()}`;
|
|
},
|
|
() => loadMiniMonthEvents(miniDate.value),
|
|
{ immediate: true }
|
|
);
|
|
watch(rows, () => loadMiniMonthEvents(miniDate.value));
|
|
// Fix persistência: recarrega quando ownerId fica disponível (settings são async)
|
|
watch(ownerId, (v) => {
|
|
if (v) loadMiniMonthEvents(miniDate.value);
|
|
});
|
|
|
|
function hasMiniEvent(date) {
|
|
return miniEventDaySet.value.has(`${date.year}-${date.month}-${date.day}`);
|
|
}
|
|
|
|
// ── Feriados próximos (30 dias) em dias úteis — inclui hoje e amanhã ──
|
|
const feriadosSemBloqueio = computed(() => {
|
|
if (!miniBlockedLoaded.value) return []; // aguarda query de bloqueios para evitar falso positivo
|
|
const hoje = new Date();
|
|
hoje.setHours(0, 0, 0, 0);
|
|
const limite = new Date(hoje);
|
|
limite.setDate(limite.getDate() + 30);
|
|
const bloqueados = miniBlockedDaySet.value;
|
|
|
|
return (feriadoFcEvents.value || [])
|
|
.filter((f) => {
|
|
const iso = f.startStr || String(f.start || '').slice(0, 10);
|
|
if (!iso) return false;
|
|
const [y, m, da] = iso.split('-').map(Number);
|
|
const dt = new Date(y, m - 1, da);
|
|
if (dt < hoje || dt > limite) return false;
|
|
if (!workDowSet.value.has(dt.getDay())) return false;
|
|
return !bloqueados.has(iso);
|
|
})
|
|
.map((f) => ({ data: f.startStr || String(f.start || '').slice(0, 10), nome: f.title || 'Feriado' }));
|
|
});
|
|
|
|
// ── Todos os feriados próximos em dias úteis (bloqueados + pendentes) ──
|
|
// Usado pelo dialog para mostrar tudo com opção de desbloquear
|
|
const feriadosTodosProximos = computed(() => {
|
|
if (!miniBlockedLoaded.value) return [];
|
|
const hoje = new Date();
|
|
hoje.setHours(0, 0, 0, 0);
|
|
const limite = new Date(hoje);
|
|
limite.setDate(limite.getDate() + 30);
|
|
const bloqueados = miniBlockedDaySet.value;
|
|
|
|
return (feriadoFcEvents.value || [])
|
|
.filter((f) => {
|
|
const iso = f.startStr || String(f.start || '').slice(0, 10);
|
|
if (!iso) return false;
|
|
const [y, m, da] = iso.split('-').map(Number);
|
|
const dt = new Date(y, m - 1, da);
|
|
if (dt < hoje || dt > limite) return false;
|
|
return workDowSet.value.has(dt.getDay());
|
|
})
|
|
.map((f) => {
|
|
const iso = f.startStr || String(f.start || '').slice(0, 10);
|
|
return { data: iso, nome: f.title || 'Feriado', bloqueado: bloqueados.has(iso) };
|
|
});
|
|
});
|
|
|
|
// ── Dialog de alerta de feriados ─────────────────────────────
|
|
const feriadosAlertaOpen = ref(false);
|
|
const feriadosAlertaBloqueados = ref(new Set());
|
|
const feriadosAlertaSalvando = ref(null);
|
|
|
|
function fmtFeriadoDateLong(iso) {
|
|
if (!iso) return '';
|
|
const [y, m, d] = iso.split('-').map(Number);
|
|
return new Date(y, m - 1, d).toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' });
|
|
}
|
|
|
|
async function bloquearFeriadoDoAlerta(feriado) {
|
|
if (!ownerId.value || !clinicTenantId.value) return;
|
|
feriadosAlertaSalvando.value = feriado.data;
|
|
try {
|
|
const { error } = await tenantDb().from('agenda_bloqueios').insert([
|
|
{
|
|
owner_id: ownerId.value,
|
|
tipo: 'bloqueio',
|
|
recorrente: false,
|
|
titulo: `Feriado: ${feriado.nome}`,
|
|
data_inicio: feriado.data,
|
|
data_fim: feriado.data,
|
|
hora_inicio: null,
|
|
hora_fim: null,
|
|
origem: 'agenda_feriado'
|
|
}
|
|
]);
|
|
if (error) throw error;
|
|
await tenantDb().from('agenda_eventos').update({ status: 'remarcado' }).eq('owner_id', ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
|
|
|
|
feriadosAlertaBloqueados.value = new Set([...feriadosAlertaBloqueados.value, feriado.data]);
|
|
miniBlockedDaySet.value = new Set([...miniBlockedDaySet.value, feriado.data]);
|
|
toast.add({ severity: 'success', summary: 'Bloqueado', detail: `${feriado.nome} bloqueado com sucesso.`, life: 3000 });
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
|
} finally {
|
|
feriadosAlertaSalvando.value = null;
|
|
}
|
|
}
|
|
|
|
async function bloquearTodosFeriadosAlerta() {
|
|
feriadosAlertaSalvando.value = 'all';
|
|
const pendentes = feriadosSemBloqueio.value.filter((f) => !feriadosAlertaBloqueados.value.has(f.data));
|
|
for (const f of pendentes) {
|
|
await bloquearFeriadoDoAlerta(f);
|
|
}
|
|
feriadosAlertaSalvando.value = null;
|
|
}
|
|
|
|
async function desbloquearFeriadoDoAlerta(feriado) {
|
|
if (!ownerId.value) return;
|
|
feriadosAlertaSalvando.value = `unblock_${feriado.data}`;
|
|
try {
|
|
const { error } = await tenantDb().from('agenda_bloqueios').delete().eq('owner_id', ownerId.value).eq('data_inicio', feriado.data).in('origem', ['agenda_feriado', 'agenda_dia']);
|
|
|
|
if (error) throw error;
|
|
|
|
// Remove do set de bloqueados (mini calendário fica sem fundo vermelho)
|
|
const novo = new Set(miniBlockedDaySet.value);
|
|
novo.delete(feriado.data);
|
|
miniBlockedDaySet.value = novo;
|
|
|
|
// Remove do set de "bloqueados nesta sessão"
|
|
const novoBloq = new Set(feriadosAlertaBloqueados.value);
|
|
novoBloq.delete(feriado.data);
|
|
feriadosAlertaBloqueados.value = novoBloq;
|
|
|
|
toast.add({
|
|
severity: 'info',
|
|
summary: 'Bloqueio removido',
|
|
detail: `O bloqueio de ${feriado.nome} foi removido. Sessões marcadas para reagendamento precisam ser reativadas manualmente.`,
|
|
life: 5000
|
|
});
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
|
} finally {
|
|
feriadosAlertaSalvando.value = null;
|
|
}
|
|
}
|
|
|
|
function gotoResult(row) {
|
|
const api = getApi();
|
|
if (api && row?.inicio_em) api.gotoDate(new Date(row.inicio_em));
|
|
dialogEventRow.value = row;
|
|
dialogStartISO.value = '';
|
|
dialogEndISO.value = '';
|
|
dialogOpen.value = true;
|
|
}
|
|
function gotoResultFromModal(row) {
|
|
searchModalOpen.value = false;
|
|
nextTick(() => gotoResult(row));
|
|
}
|
|
|
|
function fmtDateTime(iso) {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
|
|
}
|
|
function fmtDateOnly(iso) {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: '2-digit' });
|
|
}
|
|
function fmtTimeOnly(iso) {
|
|
if (!iso) return '';
|
|
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
function labelTipo(tipo) {
|
|
return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Bloqueio' : 'Sessão';
|
|
}
|
|
|
|
// -----------------------------
|
|
// Create / Select time
|
|
// -----------------------------
|
|
function onCreateFromButton() {
|
|
if (!ownerId.value) {
|
|
toast.add({ severity: 'warn', summary: 'Agenda', detail: 'Aguarde carregar as configurações da agenda.', life: 3000 });
|
|
return;
|
|
}
|
|
|
|
const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50;
|
|
const now = new Date();
|
|
// Usa o dia visualizado no calendário para que o bloqueio de dia seja detectado corretamente
|
|
const viewDate = currentDate.value instanceof Date ? currentDate.value : new Date(currentDate.value);
|
|
const base = new Date(viewDate.getFullYear(), viewDate.getMonth(), viewDate.getDate(), now.getHours(), now.getMinutes(), 0, 0);
|
|
|
|
dialogEventRow.value = {
|
|
owner_id: ownerId.value,
|
|
terapeuta_id: null,
|
|
paciente_id: null,
|
|
tipo: EVENTO_TIPO.SESSAO,
|
|
status: 'agendado',
|
|
titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO),
|
|
observacoes: null,
|
|
visibility_scope: 'public',
|
|
determined_commitment_id: null
|
|
};
|
|
|
|
dialogStartISO.value = base.toISOString();
|
|
dialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString();
|
|
dialogOpen.value = true;
|
|
}
|
|
|
|
function onSelectTime(selection) {
|
|
const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50;
|
|
const rawStart = selection.start instanceof Date ? selection.start : new Date(selection.start);
|
|
const rawEnd = selection.end instanceof Date ? selection.end : (selection.end ? new Date(selection.end) : new Date(rawStart.getTime() + durMin * 60000));
|
|
const startISO = rawStart.toISOString();
|
|
const endISO = new Date(rawStart.getTime() + durMin * 60000).toISOString();
|
|
|
|
// Aviso de bloqueio — não impede criação (regra: só agendador público
|
|
// veta), mas sinaliza pro terapeuta que ele tá agendando em cima de
|
|
// um bloqueio próprio.
|
|
const bloqHit = bloqueioCobrindo(rawStart, rawEnd);
|
|
if (bloqHit) {
|
|
toast.add({
|
|
severity: 'warn',
|
|
summary: 'Horário bloqueado',
|
|
detail: `Este horário está dentro do bloqueio "${bloqHit.titulo || 'Bloqueio'}". A sessão será criada mesmo assim.`,
|
|
life: 5000
|
|
});
|
|
}
|
|
|
|
dialogEventRow.value = {
|
|
owner_id: ownerId.value,
|
|
terapeuta_id: null,
|
|
paciente_id: null,
|
|
tipo: EVENTO_TIPO.SESSAO,
|
|
status: 'agendado',
|
|
titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO),
|
|
observacoes: null,
|
|
visibility_scope: 'public',
|
|
determined_commitment_id: null
|
|
};
|
|
|
|
dialogStartISO.value = startISO;
|
|
dialogEndISO.value = endISO;
|
|
dialogOpen.value = true;
|
|
}
|
|
|
|
function onEventClick(info) {
|
|
const ev = info?.event;
|
|
if (!ev) return;
|
|
const ep = ev.extendedProps || {};
|
|
|
|
// Bloqueios/pausas são background events — ignorar click pra não abrir
|
|
// dialog de compromisso em cima de bloqueio (visual cinza ≠ compromisso).
|
|
if (ep.kind === 'bloqueio' || ep.kind === 'break') return;
|
|
|
|
dialogEventRow.value = {
|
|
id: ep.isOccurrence ? null : ev.id || null,
|
|
owner_id: ep.owner_id,
|
|
terapeuta_id: ep.terapeuta_id ?? null,
|
|
paciente_id: ep.paciente_id ?? null,
|
|
paciente_nome: ep.paciente_nome ?? null,
|
|
paciente_avatar: ep.paciente_avatar ?? null,
|
|
paciente_status: ep.paciente_status ?? null,
|
|
tipo: normalizeEventoTipo(ep.tipo, EVENTO_TIPO.SESSAO),
|
|
status: ep.status,
|
|
titulo: ev.title,
|
|
observacoes: ep.observacoes ?? null,
|
|
visibility_scope: ep.visibility_scope ?? 'public',
|
|
inicio_em: ev.start?.toISOString?.() || ev.startStr,
|
|
fim_em: ev.end?.toISOString?.() || ev.endStr,
|
|
determined_commitment_id: ep.determined_commitment_id ?? null,
|
|
titulo_custom: ep.titulo_custom ?? null,
|
|
extra_fields: ep.extra_fields ?? null,
|
|
price: ep.price != null ? Number(ep.price) : null,
|
|
insurance_plan_id: ep.insurance_plan_id ?? null,
|
|
insurance_guide_number: ep.insurance_guide_number ?? null,
|
|
insurance_value: ep.insurance_value != null ? Number(ep.insurance_value) : null,
|
|
insurance_plan_service_id: ep.insurance_plan_service_id ?? null,
|
|
// ── recorrência (nova arquitetura) ──────────────────────────
|
|
recurrence_id: ep.recurrence_id ?? ep.recurrenceId ?? ep.serie_id ?? null,
|
|
original_date: ep.original_date ?? ep.originalDate ?? ep.recurrence_date ?? null,
|
|
is_occurrence: ep.isOccurrence ?? false,
|
|
exception_type: ep.exceptionType ?? null,
|
|
// ── fallback legado ─────────────────────────────────────────
|
|
serie_id: ep.serie_id ?? ep.recurrenceId ?? null,
|
|
serie_dia_semana: ep.serie_dia_semana ?? null,
|
|
serie_hora: ep.serie_hora ?? null
|
|
};
|
|
|
|
dialogStartISO.value = '';
|
|
dialogEndISO.value = '';
|
|
dialogOpen.value = true;
|
|
}
|
|
|
|
async function persistMoveOrResize(info, actionLabel) {
|
|
try {
|
|
const ev = info?.event;
|
|
if (!ev) return;
|
|
|
|
const id = ev.id;
|
|
|
|
// Ocorrência virtual de série (id no formato "rec::uuid::date") — não tem registro real
|
|
if (!id || !isUuid(id)) {
|
|
info?.revert?.();
|
|
toast.add({
|
|
severity: 'info',
|
|
summary: 'Sessão recorrente',
|
|
detail: 'Para mover uma sessão da série, abra-a e edite com "Somente esta sessão".',
|
|
life: 4500
|
|
});
|
|
return;
|
|
}
|
|
|
|
const startISO = ev.start ? ev.start.toISOString() : null;
|
|
const endISO = ev.end ? ev.end.toISOString() : null;
|
|
if (!startISO || !endISO) throw new Error('Compromisso sem start/end.');
|
|
|
|
const start = new Date(startISO);
|
|
const end = new Date(endISO);
|
|
const breakMin = settings.value?.session_break_min || 0;
|
|
|
|
// Usa allRows (inclui bloqueios) para não deixar o banco pegar conflitos que o JS ignorou
|
|
const conflict = allRows.value.find((r) => {
|
|
if (!r.inicio_em || r.id === id) return false;
|
|
const rS = new Date(r.inicio_em);
|
|
const rE = new Date(r.fim_em || r.inicio_em);
|
|
return start < new Date(rE.getTime() + breakMin * 60000) && end > rS;
|
|
});
|
|
|
|
if (conflict) {
|
|
info?.revert?.();
|
|
toast.add({ severity: 'warn', summary: 'Conflito', detail: 'Já existe um compromisso neste horário.', life: 4000 });
|
|
return;
|
|
}
|
|
|
|
await update(id, { inicio_em: startISO, fim_em: endISO });
|
|
toast.add({ severity: 'success', summary: actionLabel, detail: 'Alteração salva.', life: 1800 });
|
|
} catch (e) {
|
|
info?.revert?.();
|
|
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao salvar alteração.', life: 4500 });
|
|
}
|
|
}
|
|
|
|
async function refetch() {
|
|
toast.add({ severity: 'info', summary: 'Agenda', detail: 'Recarregando…', life: 1500 });
|
|
await _reloadRange();
|
|
}
|
|
|
|
function goSettings() {
|
|
router.push({ path: '/configuracoes/agenda' });
|
|
}
|
|
function goRecorrencias() {
|
|
router.push({ name: 'therapist-agenda-recorrencias' });
|
|
}
|
|
|
|
// -----------------------------
|
|
// Save/Delete
|
|
// -----------------------------
|
|
function isUuid(v) {
|
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''));
|
|
}
|
|
|
|
function pickDbFields(obj) {
|
|
const allowed = [
|
|
'tenant_id',
|
|
'owner_id',
|
|
'terapeuta_id',
|
|
'patient_id',
|
|
'tipo',
|
|
'status',
|
|
'titulo',
|
|
'observacoes',
|
|
'inicio_em',
|
|
'fim_em',
|
|
'visibility_scope',
|
|
'mirror_of_event_id',
|
|
'mirror_source',
|
|
'determined_commitment_id',
|
|
'titulo_custom',
|
|
'extra_fields',
|
|
// nova arquitetura
|
|
'recurrence_id',
|
|
'recurrence_date',
|
|
// financeiro
|
|
'price',
|
|
'insurance_plan_id',
|
|
'insurance_guide_number',
|
|
'insurance_value',
|
|
'insurance_plan_service_id'
|
|
];
|
|
const out = {};
|
|
for (const k of allowed) {
|
|
if (obj[k] !== undefined) out[k] = obj[k];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_virtual }) {
|
|
const current = dialogEventRow.value || {};
|
|
dialogEventRow.value = {
|
|
...current,
|
|
id: id || null,
|
|
inicio_em,
|
|
fim_em,
|
|
recurrence_date,
|
|
_is_virtual: is_virtual
|
|
};
|
|
}
|
|
|
|
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) {
|
|
try {
|
|
const row = dialogEventRow.value || {};
|
|
|
|
// 1) Materializar virtual se preciso (resolve `eventoId` real)
|
|
let eventoId = id;
|
|
if (!id) {
|
|
if (!is_virtual || !inicio_em) return;
|
|
const rid = row.recurrence_id ?? row.serie_id ?? null;
|
|
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
|
const { data: existing } = await tenantDb().from('agenda_eventos')
|
|
.select('id')
|
|
.eq('recurrence_id', rid)
|
|
.eq('recurrence_date', rDate)
|
|
.maybeSingle();
|
|
if (existing?.id) {
|
|
eventoId = existing.id;
|
|
// Status atualiza só depois do dialog/applyStatusChange decidir
|
|
} else {
|
|
// Materializa criando com status='agendado' — o status final
|
|
// é aplicado por applyStatusChange (que pode ramificar pelo
|
|
// dialog se houver decisão a tomar)
|
|
const created = await create({
|
|
owner_id: ownerId.value,
|
|
tenant_id: clinicTenantId.value,
|
|
recurrence_id: rid,
|
|
recurrence_date: rDate,
|
|
tipo: 'sessao',
|
|
status: 'agendado',
|
|
inicio_em,
|
|
fim_em,
|
|
visibility_scope: 'public',
|
|
titulo: row.titulo || 'Sessão',
|
|
patient_id: row.patient_id || row.paciente_id || null,
|
|
determined_commitment_id: row.determined_commitment_id || null,
|
|
price: row.price ?? null
|
|
});
|
|
eventoId = created?.id || null;
|
|
}
|
|
}
|
|
|
|
// 2) Atualiza status NO DB (applyStatusChange só cuida de billing — não
|
|
// do status do agenda_evento em si). Antes do dialog pra ctx.row
|
|
// refletir o novo status do evento.
|
|
if (eventoId) {
|
|
await update(eventoId, { status });
|
|
}
|
|
|
|
// 3) Aplica fluxo de billing (load context + dialog se preciso + apply)
|
|
const rowWithStatus = { ...row, id: eventoId, status, inicio_em, fim_em };
|
|
const { applied } = await applyStatusChange({ eventoId, row: rowWithStatus, novoStatus: status });
|
|
|
|
// 4) Se aplicou (e não cancelou via dialog), refetch pra UI refletir
|
|
if (applied) {
|
|
await loadMyRange?.();
|
|
}
|
|
} catch (e) {
|
|
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
|
|
}
|
|
}
|
|
|
|
// Opção C — oferece geração de billing_contract após criar série recorrente com serviços.
|
|
// Chamada APÓS fechar o dialog principal para não bloquear o fluxo principal.
|
|
async function _offerBillingContract(normalized, recorrencia, tenantId) {
|
|
const n = recorrencia.qtdSessoes;
|
|
const items = recorrencia.commitmentItems || [];
|
|
const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0);
|
|
const pacoteFechado = recorrencia.serieValorMode === 'dividir';
|
|
const packagePrice = pacoteFechado ? totalPorSessao : totalPorSessao * n;
|
|
const perSessao = pacoteFechado ? totalPorSessao / n : totalPorSessao;
|
|
const fmtB = (v) => Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
|
return new Promise((resolve) => {
|
|
confirm.require({
|
|
header: 'Gerar contrato de cobrança?',
|
|
message: `${n} sessões — ${fmtB(perSessao)} por sessão. Total da série: ${fmtB(packagePrice)}.`,
|
|
icon: 'pi pi-file',
|
|
acceptLabel: 'Sim, gerar contrato',
|
|
rejectLabel: 'Agora não',
|
|
accept: async () => {
|
|
try {
|
|
const { error } = await tenantDb().from('billing_contracts').insert({
|
|
owner_id: normalized.owner_id,
|
|
patient_id: normalized.paciente_id,
|
|
type: 'package',
|
|
total_sessions: n,
|
|
sessions_used: 0,
|
|
package_price: packagePrice,
|
|
status: 'active'
|
|
});
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Contrato gerado', detail: `Pacote de ${n} sessões: ${fmtB(packagePrice)}.`, life: 3000 });
|
|
} catch (e) {
|
|
toast.add({ severity: 'warn', summary: 'Erro ao gerar contrato', detail: e?.message, life: 4000 });
|
|
}
|
|
resolve();
|
|
},
|
|
reject: () => resolve()
|
|
});
|
|
});
|
|
}
|
|
|
|
async function onDialogSave(arg) {
|
|
let normalized = null;
|
|
|
|
try {
|
|
const isWrapped = !!arg && typeof arg === 'object' && Object.prototype.hasOwnProperty.call(arg, 'payload');
|
|
const payload = isWrapped ? arg.payload : arg;
|
|
const recorrencia = arg?.recorrencia ?? null;
|
|
const editMode = arg?.editMode ?? null;
|
|
// recurrence_id vem do emit do dialog — para edições de série
|
|
// serie_id é o campo legado que o dialog ainda usa; recurrence_id é o novo
|
|
const recurrenceId = arg?.recurrence_id ?? arg?.serie_id ?? null;
|
|
const originalDate = arg?.original_date ?? dialogEventRow.value?.original_date ?? null;
|
|
|
|
// Usa SEMPRE o id emitido pelo dialog.
|
|
// NUNCA usar dialogEventRow.value?.id como fallback — ele pode conter
|
|
// o id de um evento anterior aberto antes desta criação.
|
|
const id = isWrapped ? (arg.id ?? null) : (arg?.id ?? null);
|
|
|
|
normalized = { ...(payload || {}) };
|
|
|
|
if (!normalized.owner_id && ownerId.value) normalized.owner_id = ownerId.value;
|
|
|
|
const clinicId = clinicTenantId.value;
|
|
if (!clinicId) throw new Error('tenant_id da clínica não encontrado no tenantStore.');
|
|
normalized.tenant_id = clinicId;
|
|
|
|
if (!normalized.visibility_scope) normalized.visibility_scope = 'public';
|
|
|
|
normalized.tipo = normalizeEventoTipo(normalized.tipo || deriveEventoTipoForNewEvent(normalized), EVENTO_TIPO.SESSAO);
|
|
|
|
if (!normalized.status) normalized.status = 'agendado';
|
|
if (!String(normalized.titulo || '').trim()) normalized.titulo = deriveTituloDefaultByTipo(normalized.tipo);
|
|
|
|
if (!normalized.paciente_id || !isUuid(normalized.paciente_id)) normalized.paciente_id = null;
|
|
|
|
if (normalized.tipo === EVENTO_TIPO.BLOQUEIO) {
|
|
normalized.paciente_id = null;
|
|
normalized.determined_commitment_id = null;
|
|
if (!normalized.visibility_scope) normalized.visibility_scope = 'busy_only';
|
|
}
|
|
|
|
if (normalized.determined_commitment_id && !isUuid(normalized.determined_commitment_id)) {
|
|
normalized.determined_commitment_id = null;
|
|
}
|
|
|
|
// ── CASO C / C2: criação RECORRENTE (novo ou evento existente) ─────────
|
|
// Só cria nova regra se NÃO há série existente — se houver recurrenceId, cai para F/G/E
|
|
if (recorrencia?.tipo === 'recorrente' && !recurrenceId) {
|
|
const startDate = new Date(normalized.inicio_em);
|
|
const tipoFreq = recorrencia.tipoFreq ?? 'semanal';
|
|
const dow = recorrencia.diaSemana ?? startDate.getDay();
|
|
const firstRecISO = startDate.toISOString().slice(0, 10);
|
|
|
|
// Determina type/interval/weekdays conforme frequência
|
|
let ruleType = 'weekly';
|
|
let interval = 1;
|
|
let weekdays = [dow];
|
|
if (tipoFreq === 'quinzenal') {
|
|
ruleType = 'weekly';
|
|
interval = 2;
|
|
} else if (tipoFreq === 'diasEspecificos') {
|
|
ruleType = 'custom_weekdays';
|
|
weekdays = recorrencia.diasSemana?.length ? recorrencia.diasSemana : [dow];
|
|
}
|
|
|
|
const rule = {
|
|
tenant_id: clinicId,
|
|
owner_id: normalized.owner_id,
|
|
therapist_id: normalized.terapeuta_id ?? null,
|
|
patient_id: normalized.paciente_id ?? null,
|
|
determined_commitment_id: normalized.determined_commitment_id ?? null,
|
|
type: ruleType,
|
|
interval,
|
|
weekdays,
|
|
start_time: recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 8),
|
|
end_time: _addMinutesToTime(recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 5), recorrencia.duracaoMin ?? 50),
|
|
duration_min: recorrencia.duracaoMin ?? 50,
|
|
timezone: settings.value?.timezone || 'America/Sao_Paulo',
|
|
start_date: firstRecISO,
|
|
end_date: recorrencia.dataFim ? new Date(recorrencia.dataFim).toISOString().slice(0, 10) : null,
|
|
max_occurrences: recorrencia.qtdSessoes ?? null,
|
|
open_ended: !recorrencia.dataFim && !recorrencia.qtdSessoes,
|
|
modalidade: normalized.modalidade ?? 'presencial',
|
|
titulo_custom: normalized.titulo_custom ?? null,
|
|
observacoes: normalized.observacoes ?? null,
|
|
extra_fields: normalized.extra_fields ?? null,
|
|
price: normalized.price ?? null,
|
|
insurance_plan_id: normalized.insurance_plan_id ?? null,
|
|
insurance_guide_number: normalized.insurance_guide_number ?? null,
|
|
insurance_value: normalized.insurance_value ?? null,
|
|
insurance_plan_service_id: normalized.insurance_plan_service_id ?? null,
|
|
status: 'ativo'
|
|
};
|
|
const createdRule = await createRule(rule);
|
|
|
|
// Se editando evento existente, vincula à nova regra
|
|
if (id && createdRule?.id) {
|
|
await update(id, { recurrence_id: createdRule.id, recurrence_date: firstRecISO });
|
|
}
|
|
|
|
// Inserir exceptions automáticas para datas em conflito
|
|
if (recorrencia?.conflitos?.length && createdRule?.id) {
|
|
const exceptions = recorrencia.conflitos.map((c) => ({
|
|
recurrence_id: createdRule.id,
|
|
original_date: c.date,
|
|
type: c.conflict.type === 'feriado' ? 'holiday_block' : c.conflict.type === 'bloqueado' ? 'cancel_session' : c.conflict.type === 'folga' ? 'cancel_session' : 'cancel_session',
|
|
reason: c.conflict.label
|
|
}));
|
|
const { error: exErr } = await tenantDb().from('recurrence_exceptions').insert(exceptions);
|
|
if (exErr) logError('AgendaTerapeutaPage', 'onDialogSave: erro ao inserir exceptions', exErr);
|
|
}
|
|
|
|
// Opção C — salvar template de serviços da regra
|
|
if (createdRule?.id && recorrencia.commitmentItems?.length) {
|
|
await saveRuleItems(createdRule.id, recorrencia.commitmentItems);
|
|
}
|
|
|
|
const detail = recorrencia.qtdSessoes ? `${recorrencia.qtdSessoes} sessões criadas` : 'Série recorrente criada';
|
|
toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
|
|
// Opção C — oferecer billing_contract após fechar o dialog (só com serviços + nº definido + paciente)
|
|
if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) {
|
|
await _offerBillingContract(normalized, recorrencia, clinicId);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// ── CASO D: edição "somente_este" de ocorrência de série ───────────────
|
|
if (recurrenceId && editMode === 'somente_este') {
|
|
let eventId = id ?? null;
|
|
|
|
if (id) {
|
|
// Evento já materializado: atualiza campos + mantém exceção sincronizada
|
|
await update(id, pickDbFields(normalized));
|
|
if (originalDate) {
|
|
await upsertException({
|
|
recurrence_id: recurrenceId,
|
|
tenant_id: clinicId,
|
|
original_date: originalDate,
|
|
type: 'reschedule_session',
|
|
new_date: normalized.inicio_em?.slice(0, 10),
|
|
new_start_time: normalized.inicio_em ? new Date(normalized.inicio_em).toTimeString().slice(0, 8) : null,
|
|
new_end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : null,
|
|
modalidade: normalized.modalidade ?? null,
|
|
titulo_custom: normalized.titulo_custom ?? null,
|
|
observacoes: normalized.observacoes ?? null,
|
|
extra_fields: normalized.extra_fields ?? null
|
|
});
|
|
}
|
|
} else if (originalDate) {
|
|
// Ocorrência ainda virtual: cria exceção + materializa para salvar commitment_services
|
|
await upsertException({
|
|
recurrence_id: recurrenceId,
|
|
tenant_id: clinicId,
|
|
original_date: originalDate,
|
|
type: 'reschedule_session',
|
|
new_date: normalized.inicio_em?.slice(0, 10),
|
|
new_start_time: normalized.inicio_em ? new Date(normalized.inicio_em).toTimeString().slice(0, 8) : null,
|
|
new_end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : null,
|
|
modalidade: normalized.modalidade ?? null,
|
|
titulo_custom: normalized.titulo_custom ?? null,
|
|
observacoes: normalized.observacoes ?? null,
|
|
extra_fields: normalized.extra_fields ?? null
|
|
});
|
|
if (arg.onSaved) {
|
|
const { data: existing } = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', originalDate).maybeSingle();
|
|
if (existing?.id) {
|
|
eventId = existing.id;
|
|
} else {
|
|
const mat = await create({
|
|
owner_id: normalized.owner_id,
|
|
tenant_id: clinicId,
|
|
recurrence_id: recurrenceId,
|
|
recurrence_date: originalDate,
|
|
tipo: normalized.tipo,
|
|
status: normalized.status,
|
|
inicio_em: normalized.inicio_em,
|
|
fim_em: normalized.fim_em,
|
|
titulo: normalized.titulo,
|
|
patient_id: normalized.patient_id,
|
|
determined_commitment_id: normalized.determined_commitment_id,
|
|
modalidade: normalized.modalidade ?? 'presencial',
|
|
observacoes: normalized.observacoes ?? null,
|
|
extra_fields: normalized.extra_fields ?? null,
|
|
price: normalized.price ?? null,
|
|
insurance_plan_id: normalized.insurance_plan_id ?? null,
|
|
insurance_guide_number: normalized.insurance_guide_number ?? null,
|
|
insurance_value: normalized.insurance_value ?? null,
|
|
insurance_plan_service_id: normalized.insurance_plan_service_id ?? null
|
|
});
|
|
eventId = mat.id;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Opção C — salvar serviços e marcar esta ocorrência como customizada
|
|
if (eventId) await arg.onSaved?.(eventId, { markCustomized: true });
|
|
|
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Sessão atualizada individualmente.', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// ── CASO E: edição "este e os seguintes" ───────────────────────────────
|
|
if (recurrenceId && editMode === 'este_e_seguintes' && originalDate) {
|
|
const newRuleId = await splitRuleAt(recurrenceId, originalDate);
|
|
const startDate = new Date(normalized.inicio_em);
|
|
await updateRule(newRuleId, {
|
|
weekdays: [startDate.getDay()],
|
|
start_time: startDate.toTimeString().slice(0, 8),
|
|
end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : undefined,
|
|
duration_min: recorrencia?.duracaoMin ?? 50,
|
|
modalidade: normalized.modalidade ?? 'presencial',
|
|
titulo_custom: normalized.titulo_custom ?? null,
|
|
observacoes: normalized.observacoes ?? null,
|
|
extra_fields: normalized.extra_fields ?? null,
|
|
price: normalized.price ?? null,
|
|
insurance_plan_id: normalized.insurance_plan_id ?? null,
|
|
insurance_guide_number: normalized.insurance_guide_number ?? null,
|
|
insurance_value: normalized.insurance_value ?? null,
|
|
insurance_plan_service_id: normalized.insurance_plan_service_id ?? null
|
|
});
|
|
|
|
// Opção C — atualizar template e propagar para a nova sub-série
|
|
const serviceItemsE = arg.serviceItems;
|
|
if (newRuleId && serviceItemsE?.length) {
|
|
await saveRuleItems(newRuleId, serviceItemsE);
|
|
await propagateToSerie(newRuleId, serviceItemsE, { fromDate: originalDate });
|
|
}
|
|
if (id) await arg.onSaved?.(id);
|
|
|
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Esta sessão e as seguintes foram atualizadas.', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// ── CASO F: edição "todos" ─────────────────────────────────────────────
|
|
if (recurrenceId && editMode === 'todos') {
|
|
const startDate = new Date(normalized.inicio_em);
|
|
await updateRule(recurrenceId, {
|
|
weekdays: [startDate.getDay()],
|
|
start_time: startDate.toTimeString().slice(0, 8),
|
|
end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : undefined,
|
|
duration_min: recorrencia?.duracaoMin ?? 50,
|
|
modalidade: normalized.modalidade ?? 'presencial',
|
|
titulo_custom: normalized.titulo_custom ?? null,
|
|
observacoes: normalized.observacoes ?? null,
|
|
extra_fields: normalized.extra_fields ?? null,
|
|
price: normalized.price ?? null,
|
|
insurance_plan_id: normalized.insurance_plan_id ?? null,
|
|
insurance_guide_number: normalized.insurance_guide_number ?? null,
|
|
insurance_value: normalized.insurance_value ?? null,
|
|
insurance_plan_service_id: normalized.insurance_plan_service_id ?? null
|
|
});
|
|
|
|
// Propaga campos não-serviço para sessões já materializadas da série
|
|
await tenantDb().from('agenda_eventos')
|
|
.update({
|
|
modalidade: normalized.modalidade ?? 'presencial',
|
|
titulo_custom: normalized.titulo_custom ?? null,
|
|
observacoes: normalized.observacoes ?? null,
|
|
extra_fields: normalized.extra_fields ?? null,
|
|
price: normalized.price ?? null,
|
|
insurance_plan_id: normalized.insurance_plan_id ?? null,
|
|
insurance_guide_number: normalized.insurance_guide_number ?? null,
|
|
insurance_value: normalized.insurance_value ?? null,
|
|
insurance_plan_service_id: normalized.insurance_plan_service_id ?? null
|
|
})
|
|
.eq('recurrence_id', recurrenceId);
|
|
|
|
// Opção C — atualizar template e propagar para toda a série
|
|
const serviceItemsF = arg.serviceItems;
|
|
if (recurrenceId && serviceItemsF?.length) {
|
|
await saveRuleItems(recurrenceId, serviceItemsF);
|
|
await propagateToSerie(recurrenceId, serviceItemsF);
|
|
}
|
|
if (id) await arg.onSaved?.(id);
|
|
|
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Todas as sessões da série foram atualizadas.', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// ── CASO G: edição "todos sem exceção" — sobrescreve TUDO incluindo customizadas ──
|
|
if (recurrenceId && editMode === 'todos_sem_excecao') {
|
|
const startDate = new Date(normalized.inicio_em);
|
|
await updateRule(recurrenceId, {
|
|
weekdays: [startDate.getDay()],
|
|
start_time: startDate.toTimeString().slice(0, 8),
|
|
end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : undefined,
|
|
duration_min: recorrencia?.duracaoMin ?? 50,
|
|
modalidade: normalized.modalidade ?? 'presencial',
|
|
titulo_custom: normalized.titulo_custom ?? null,
|
|
observacoes: normalized.observacoes ?? null,
|
|
extra_fields: normalized.extra_fields ?? null,
|
|
price: normalized.price ?? null,
|
|
insurance_plan_id: normalized.insurance_plan_id ?? null,
|
|
insurance_guide_number: normalized.insurance_guide_number ?? null,
|
|
insurance_value: normalized.insurance_value ?? null,
|
|
insurance_plan_service_id: normalized.insurance_plan_service_id ?? null
|
|
});
|
|
|
|
// Propaga TODOS os campos para TODAS as sessões materializadas (sem exceção)
|
|
await tenantDb().from('agenda_eventos')
|
|
.update({
|
|
modalidade: normalized.modalidade ?? 'presencial',
|
|
titulo_custom: normalized.titulo_custom ?? null,
|
|
observacoes: normalized.observacoes ?? null,
|
|
extra_fields: normalized.extra_fields ?? null,
|
|
price: normalized.price ?? null,
|
|
insurance_plan_id: normalized.insurance_plan_id ?? null,
|
|
insurance_guide_number: normalized.insurance_guide_number ?? null,
|
|
insurance_value: normalized.insurance_value ?? null,
|
|
insurance_plan_service_id: normalized.insurance_plan_service_id ?? null,
|
|
services_customized: false
|
|
})
|
|
.eq('recurrence_id', recurrenceId);
|
|
|
|
// Propaga para todos — incluindo services_customized=true — e reseta o flag
|
|
const serviceItemsG = arg.serviceItems;
|
|
if (recurrenceId && serviceItemsG?.length) {
|
|
await saveRuleItems(recurrenceId, serviceItemsG);
|
|
await propagateToSerie(recurrenceId, serviceItemsG, { ignoreCustomized: true });
|
|
}
|
|
|
|
if (id) await arg.onSaved?.(id);
|
|
|
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Todas as sessões da série foram atualizadas (sem exceção).', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// ── CASO A/B: evento avulso ou sessão única ────────────────────────────
|
|
const dbPayload = pickDbFields(normalized);
|
|
|
|
if (id) {
|
|
await update(id, dbPayload);
|
|
await arg.onSaved?.(id);
|
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Compromisso atualizado.', life: 2500 });
|
|
} else {
|
|
const created = await create(dbPayload);
|
|
await arg.onSaved?.(created.id);
|
|
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado.', life: 2500 });
|
|
}
|
|
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
} catch (e) {
|
|
const msg = String(e?.message || '');
|
|
|
|
if (msg.includes('recurrence_rules_dates_chk') || (msg.includes('violates check constraint') && msg.includes('recurrence_rules'))) {
|
|
toast.add({
|
|
severity: 'warn',
|
|
summary: 'Não foi possível dividir a série',
|
|
detail: 'Esta é a primeira sessão da série. Para alterar todas as ocorrências, selecione "Todos" ou "Todos sem exceção".',
|
|
life: 6000
|
|
});
|
|
return;
|
|
}
|
|
|
|
const isOverlap = e?.code === '23P01' || msg.includes('agenda_eventos_sem_sobreposicao') || msg.includes('exclusion constraint') || msg.includes('conflicting key value violates exclusion constraint');
|
|
|
|
if (isOverlap) {
|
|
let detail = 'Já existe um compromisso nesse horário. Verifique a agenda e escolha outro horário.';
|
|
try {
|
|
if (normalized?.inicio_em && normalized?.fim_em && normalized?.owner_id) {
|
|
const { data: conflicting } = await tenantDb().from('agenda_eventos')
|
|
.select('titulo, inicio_em, fim_em')
|
|
.eq('owner_id', normalized.owner_id)
|
|
.lt('inicio_em', normalized.fim_em)
|
|
.gt('fim_em', normalized.inicio_em)
|
|
.limit(1)
|
|
.maybeSingle();
|
|
|
|
if (conflicting) {
|
|
const ini = new Date(conflicting.inicio_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
|
const fim = new Date(conflicting.fim_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
|
const titulo = conflicting.titulo || 'Compromisso';
|
|
detail = `Conflito com "${titulo}" (${ini} → ${fim}). Ajuste o horário ou a duração.`;
|
|
}
|
|
}
|
|
} catch {
|
|
/* mantém detail genérico */
|
|
}
|
|
|
|
toast.add({ severity: 'warn', summary: 'Conflito de horário', detail, life: 7000 });
|
|
return;
|
|
}
|
|
|
|
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao salvar.', life: 4500 });
|
|
}
|
|
}
|
|
|
|
async function onDialogDelete(arg) {
|
|
const id = typeof arg === 'string' ? arg : arg?.id;
|
|
const editMode = typeof arg === 'string' ? null : arg?.editMode;
|
|
const recurrenceId = typeof arg === 'string' ? null : (arg?.recurrence_id ?? arg?.serie_id ?? null);
|
|
const originalDate = typeof arg === 'string' ? null : (arg?.original_date ?? dialogEventRow.value?.original_date ?? null);
|
|
|
|
try {
|
|
// ── Somente este evento / ocorrência ──────────────────────────────────
|
|
if (!recurrenceId || editMode === 'somente_este') {
|
|
if (originalDate && recurrenceId) {
|
|
// Ocorrência virtual: cria exceção de cancelamento
|
|
await upsertException({
|
|
recurrence_id: recurrenceId,
|
|
tenant_id: clinicTenantId.value,
|
|
original_date: originalDate,
|
|
type: 'cancel_session'
|
|
});
|
|
} else if (id) {
|
|
await remove(id);
|
|
}
|
|
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Sessão removida.', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// ── Este e os seguintes ───────────────────────────────────────────────
|
|
if (editMode === 'este_e_seguintes' && originalDate) {
|
|
await cancelRuleFrom(recurrenceId, originalDate);
|
|
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Esta sessão e as seguintes foram canceladas.', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// ── Todos (encerrar série, manter sessão atual como avulsa) ──────────────
|
|
if (editMode === 'todos') {
|
|
const row = dialogEventRow.value || {};
|
|
const isVirtual = row.is_occurrence && !id;
|
|
|
|
if (isVirtual) {
|
|
// Ocorrência virtual: materializa como evento avulso (sem recurrence_id)
|
|
const rDate = row.original_date || row.inicio_em?.slice(0, 10);
|
|
const existing = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', rDate).maybeSingle();
|
|
|
|
if (existing.data?.id) {
|
|
await update(existing.data.id, { recurrence_id: null, recurrence_date: null });
|
|
} else {
|
|
await create({
|
|
owner_id: ownerId.value,
|
|
tenant_id: clinicTenantId.value,
|
|
tipo: row.tipo || 'sessao',
|
|
status: row.status || 'agendado',
|
|
inicio_em: row.inicio_em,
|
|
fim_em: row.fim_em,
|
|
titulo: row.titulo || 'Sessão',
|
|
patient_id: row.patient_id || row.paciente_id || null,
|
|
determined_commitment_id: row.determined_commitment_id || null,
|
|
modalidade: row.modalidade || 'presencial',
|
|
price: row.price ?? null,
|
|
observacoes: row.observacoes || null,
|
|
visibility_scope: 'public'
|
|
});
|
|
}
|
|
} else if (id) {
|
|
// Evento real: desvincula da série
|
|
await update(id, { recurrence_id: null, recurrence_date: null });
|
|
}
|
|
|
|
// Cancela a regra (todas as ocorrências virtuais futuras somem)
|
|
await cancelRule(recurrenceId);
|
|
toast.add({ severity: 'success', summary: 'Série encerrada', detail: 'A série foi encerrada. Esta sessão foi mantida como avulsa.', life: 3000 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
return;
|
|
}
|
|
|
|
// fallback
|
|
if (id) await remove(id);
|
|
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 });
|
|
dialogOpen.value = false;
|
|
await _reloadRange();
|
|
} catch (e) {
|
|
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao excluir.', life: 4500 });
|
|
}
|
|
}
|
|
|
|
async function _reloadRange() {
|
|
logEvent('AgendaTerapeutaPage', '_reloadRange chamado', { ownerId: ownerId.value, range: currentRange.value });
|
|
if (!currentRange.value.start || !currentRange.value.end) {
|
|
logEvent('AgendaTerapeutaPage', '_reloadRange: sem range, abortando');
|
|
return;
|
|
}
|
|
// aguarda ownerId ficar disponível (settings são async)
|
|
if (!ownerId.value) {
|
|
logEvent('AgendaTerapeutaPage', '_reloadRange: ownerId vazio — aguardando...');
|
|
const unwatch = watch(ownerId, async (v) => {
|
|
if (!v) return;
|
|
logEvent('AgendaTerapeutaPage', '_reloadRange: ownerId disponível');
|
|
unwatch();
|
|
await _reloadRange();
|
|
});
|
|
return;
|
|
}
|
|
|
|
const start = new Date(currentRange.value.start);
|
|
const end = new Date(currentRange.value.end);
|
|
const activeTenantId = tenantStore.activeTenantId || null;
|
|
|
|
// 1. carrega eventos reais do banco (tenant_id já filtrado dentro do composable)
|
|
await loadMyRange(start.toISOString(), end.toISOString(), ownerId.value);
|
|
logEvent('AgendaTerapeutaPage', '_reloadRange: eventos reais carregados', { count: rows.value.length });
|
|
|
|
// 2. expande regras de recorrência + merge com sessões reais (passa tenantId)
|
|
const merged = await loadAndExpand(ownerId.value, start, end, rows.value, activeTenantId);
|
|
logEvent('AgendaTerapeutaPage', '_reloadRange: merged total', { count: merged.length });
|
|
|
|
// 3. separa ocorrências virtuais (eventos reais já estão em rows.value)
|
|
_occurrenceRows.value = merged.filter((r) => r.is_occurrence);
|
|
logEvent('AgendaTerapeutaPage', '_reloadRange: ocorrências virtuais', { count: _occurrenceRows.value.length });
|
|
|
|
// 4. bloqueios (background events). Async em paralelo — não bloqueia
|
|
// render do calendário; bloqueioFcEvents é computed e atualiza sozinho.
|
|
loadBloqueios(ownerId.value, start, end);
|
|
}
|
|
|
|
// Ref auxiliar para ocorrências virtuais geradas pelo useRecurrence
|
|
const _occurrenceRows = ref([]);
|
|
|
|
// Empurra calendarEvents para o FC de forma imperativa.
|
|
// Remove a fonte anterior para não acumular; adiciona a nova com os eventos filtrados.
|
|
function _pushEventsToFC() {
|
|
const api = getApi();
|
|
if (!api) return;
|
|
api.getEventSources().forEach((src) => src.remove());
|
|
api.addEventSource(calendarEvents.value);
|
|
}
|
|
|
|
// Dispara sempre que calendarEvents mudar: filtro onlySessions, dados novos, etc.
|
|
watch(calendarEvents, async () => {
|
|
await nextTick();
|
|
_pushEventsToFC();
|
|
});
|
|
|
|
// -----------------------------
|
|
// Bloqueio
|
|
// -----------------------------
|
|
const blockMenuItems = [
|
|
{ label: 'Bloquear por Horário', icon: 'pi pi-clock', command: () => openBloqueioDialog('horario') },
|
|
{ label: 'Bloquear por Período', icon: 'pi pi-calendar-clock', command: () => openBloqueioDialog('periodo') },
|
|
{ label: 'Bloquear por Dia', icon: 'pi pi-calendar-times', command: () => openBloqueioDialog('dia') },
|
|
{ label: 'Bloqueio por Feriados', icon: 'pi pi-star', command: () => openBloqueioDialog('feriados') }
|
|
];
|
|
|
|
// -----------------------------
|
|
// Helpers
|
|
// -----------------------------
|
|
function shiftMonth(date, delta) {
|
|
const d = new Date(date);
|
|
d.setMonth(d.getMonth() + delta);
|
|
return d;
|
|
}
|
|
function capitalize(s) {
|
|
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
}
|
|
function padTime(hhmmss, deltaMin) {
|
|
const [hh, mm] = String(hhmmss || '00:00:00')
|
|
.split(':')
|
|
.map(Number);
|
|
let total = hh * 60 + mm + deltaMin;
|
|
if (total < 0) total = 0;
|
|
if (total > 24 * 60) total = 24 * 60;
|
|
return minutesToDuration(total);
|
|
}
|
|
|
|
// ── helper privado ─────────────────────────────────────────────────────────
|
|
function _addMinutesToTime(timeStr, minutes) {
|
|
const [h, m] = String(timeStr || '09:00')
|
|
.split(':')
|
|
.map(Number);
|
|
const total = h * 60 + m + Number(minutes || 0);
|
|
const nh = Math.floor(total / 60) % 24;
|
|
const nm = total % 60;
|
|
return `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}:00`;
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadSettings();
|
|
if (settingsError.value) toast.add({ severity: 'warn', summary: 'Agenda', detail: settingsError.value, life: 4500 });
|
|
|
|
// Sticky detection: sentinel sai da área visível → header colado
|
|
if (headerSentinelRef.value) {
|
|
const io = new IntersectionObserver(
|
|
([entry]) => {
|
|
headerStuck.value = !entry.isIntersecting;
|
|
},
|
|
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px` }
|
|
);
|
|
io.observe(headerSentinelRef.value);
|
|
}
|
|
|
|
mqDesktop?.addEventListener('change', onMqDesktopChange);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
mqDesktop?.removeEventListener('change', onMqDesktopChange);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<ConfirmDialog />
|
|
|
|
<!-- ════ AGENDA TERAPEUTA — Layout 3 colunas ════ -->
|
|
|
|
<!-- Sentinel -->
|
|
<div ref="headerSentinelRef" class="h-px" />
|
|
|
|
<!-- Hero compacto — padrão Compromissos -->
|
|
<div
|
|
ref="headerEl"
|
|
class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-2.5 my-3 mx-3 md:mx-4 sticky top-[var(--layout-sticky-top,56px)] z-20"
|
|
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
|
|
>
|
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
|
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
|
|
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.07]" />
|
|
</div>
|
|
<div class="relative z-1 flex items-center gap-3">
|
|
<!-- Brand -->
|
|
<div class="flex items-center gap-2 shrink-0">
|
|
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-calendar text-base" /></div>
|
|
<div class="min-w-0 hidden lg:block">
|
|
<div class="text-base font-bold tracking-tight text-[var(--text-color)]">Agenda</div>
|
|
<div class="text-xs text-[var(--text-color-secondary)]">{{ subtitleText }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Nav + filtros (desktop) -->
|
|
<div class="hidden min-[1500px]:flex items-center gap-2 flex-1 min-w-0 mx-2">
|
|
<!-- Navegação
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full" @click="goToday" />
|
|
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goPrev" />
|
|
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-sm font-semibold text-[var(--text-color)] cursor-pointer whitespace-nowrap transition-colors duration-150 hover:border-[var(--p-primary-400)]" @click="toggleMonthPicker">
|
|
<i class="pi pi-calendar text-xs opacity-60" />
|
|
{{ subtitleText }}
|
|
</span>
|
|
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" />
|
|
</div> -->
|
|
<!-- Filtros -->
|
|
<div class="flex items-center gap-1.5 flex-wrap">
|
|
<SelectButton v-model="calendarView" :options="viewOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
|
|
<SelectButton v-model="timeMode" :options="timeModeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
|
|
<SelectButton v-model="onlySessions" :options="onlySessionsOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ações desktop -->
|
|
<div class="hidden min-[1500px]:flex items-center gap-1 shrink-0">
|
|
<div class="w-44">
|
|
<FloatLabel variant="on">
|
|
<IconField>
|
|
<InputIcon class="pi pi-search" />
|
|
<InputText id="agendaSearch" v-model="search" class="w-full" autocomplete="off" @keyup.enter="openSearchModal" />
|
|
</IconField>
|
|
<label for="agendaSearch">Buscar...</label>
|
|
</FloatLabel>
|
|
</div>
|
|
<div v-if="feriadosTodosProximos.length" class="relative">
|
|
<Button icon="pi pi-bell" :severity="feriadosSemBloqueio.length ? 'danger' : 'secondary'" outlined class="h-9 w-9 rounded-full" @click="feriadosAlertaOpen = true" />
|
|
<span v-if="feriadosSemBloqueio.length" class="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[0.65rem] font-bold flex items-center justify-center pointer-events-none">{{
|
|
feriadosSemBloqueio.length
|
|
}}</span>
|
|
</div>
|
|
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="onCreateFromButton" />
|
|
<Button label="Bloquear" icon="pi pi-lock" size="small" class="rounded-full" severity="danger" outlined @click="(e) => blockMenuRef.toggle(e)" />
|
|
<Menu ref="blockMenuRef" :model="blockMenuItems" :popup="true" />
|
|
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Recarregar'" @click="refetch" />
|
|
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Recorrências'" @click="goRecorrencias" />
|
|
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Configurações'" @click="goSettings" />
|
|
</div>
|
|
|
|
<!-- Mobile -->
|
|
<div class="flex min-[1500px]:hidden items-center gap-1 shrink-0 ml-auto">
|
|
<div v-if="feriadosTodosProximos.length" class="relative">
|
|
<Button icon="pi pi-bell" :severity="feriadosSemBloqueio.length ? 'danger' : 'secondary'" outlined class="h-9 w-9 rounded-full" @click="feriadosAlertaOpen = true" />
|
|
<span v-if="feriadosSemBloqueio.length" class="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[0.65rem] font-bold flex items-center justify-center pointer-events-none">{{
|
|
feriadosSemBloqueio.length
|
|
}}</span>
|
|
</div>
|
|
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="onCreateFromButton" />
|
|
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchModalOpen = true" />
|
|
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => headerMenuRef.toggle(e)" />
|
|
<Menu ref="headerMenuRef" :model="headerMenuItems" :popup="true" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Aviso: fora da jornada -->
|
|
<div
|
|
ref="foraJornadaBannerRef"
|
|
v-if="hasEventsOutsideWorkHours"
|
|
class="my-3 mx-3 md:mx-4 rounded-[6px] p-3"
|
|
style="background: color-mix(in srgb, var(--yellow-400, #facc15) 10%, var(--surface-card)); border: 1px solid color-mix(in srgb, var(--yellow-400, #facc15) 35%, transparent)"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<i class="pi pi-exclamation-triangle shrink-0" style="color: var(--yellow-600, #ca8a04)" />
|
|
<div class="font-semibold text-sm">Compromissos fora da jornada</div>
|
|
<Button label="Ver 24h" size="small" severity="secondary" outlined class="rounded-full shrink-0" @click="timeMode = '24'" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ════ GRID 3 COLUNAS ════ -->
|
|
<!-- Overlay mobile -->
|
|
<div
|
|
v-if="agPanelOpen"
|
|
class="fixed left-0 right-0 bottom-0 top-[calc(var(--notice-banner-height,0px)+56px)] bg-black/40 backdrop-blur-sm z-[39] xl:hidden"
|
|
@click="agPanelOpen = false"
|
|
/>
|
|
|
|
<!-- Drawer mobile: col esquerda + col direita empilhadas -->
|
|
<aside
|
|
class="panel-drawer fixed top-[calc(var(--notice-banner-height,0px)+56px)] left-0 h-[calc(100dvh-var(--notice-banner-height,0px)-56px)] w-[min(340px,88vw)] z-40 bg-[var(--surface-card)] border-r border-[var(--surface-border)] shadow-[4px_0_24px_rgba(0,0,0,0.12)] transition-[transform,visibility] duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)]"
|
|
:class="agPanelOpen ? 'translate-x-0 visible' : '-translate-x-full invisible'"
|
|
>
|
|
<div class="flex flex-col gap-3 p-4 h-full overflow-y-auto">
|
|
<!-- Mini calendário + Jornada + Feriados -->
|
|
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
|
<div class="flex items-center justify-between">
|
|
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-calendar" />{{ visibleTitle }}</span>
|
|
<div class="flex items-center gap-0.5">
|
|
<Button icon="pi pi-home" severity="secondary" text class="h-7 w-7 rounded-full" v-tooltip.top="'Hoje'" @click="miniGoToday" />
|
|
<Button icon="pi pi-chevron-left" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniPrevMonth" />
|
|
<Button icon="pi pi-chevron-right" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniNextMonth" />
|
|
</div>
|
|
</div>
|
|
<DatePicker
|
|
v-model="miniDate"
|
|
inline
|
|
class="w-full"
|
|
@update:modelValue="
|
|
(val) => {
|
|
onMiniPick(val);
|
|
agPanelOpen = false;
|
|
}
|
|
"
|
|
:pt="{ day: ({ context }) => ({ class: miniDayClass(context.date) }) }"
|
|
>
|
|
<template #date="{ date }">
|
|
<span class="mini-day-num">{{ date.day }}</span>
|
|
<span v-if="hasMiniEvent(date)" class="mini-day-dot" />
|
|
</template>
|
|
</DatePicker>
|
|
</div>
|
|
|
|
<div v-if="jornadaHoje" class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-clock" />Jornada</span>
|
|
</div>
|
|
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">{{ jornadaHoje }}</div>
|
|
</div>
|
|
|
|
<ProximosFeriadosCard :ownerId="ownerId" :tenantId="clinicTenantId" :workRules="workRules" @bloqueado="refetch" />
|
|
|
|
<LoadedPhraseBlock v-if="eventsHasLoaded" />
|
|
|
|
<!-- Divisor -->
|
|
<div class="border-t border-[var(--surface-border)] my-1" />
|
|
|
|
<!-- Stats do dia -->
|
|
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-chart-bar" />Hoje</span>
|
|
</div>
|
|
<div class="grid grid-cols-4 gap-2">
|
|
<template v-if="eventsLoading">
|
|
<Skeleton v-for="n in 4" :key="n" height="3rem" class="rounded-md" />
|
|
</template>
|
|
<template v-else>
|
|
<div class="flex flex-col gap-0.5 p-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] text-center" v-for="s in todayStats" :key="s.label">
|
|
<div class="text-[1.25rem] font-bold leading-none text-[var(--text-color)]" :class="{ 'text-green-500': s.cls === 'ag-stat--ok', 'text-red-500': s.cls === 'ag-stat--warn' }">{{ s.value }}</div>
|
|
<div class="text-[0.65rem] font-semibold uppercase tracking-[0.04em] text-[var(--text-color-secondary)] opacity-70">{{ s.label }}</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sessões do dia -->
|
|
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-list" />Sessões hoje</span>
|
|
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.65rem] font-bold">{{ todayEvents.length }}</span>
|
|
</div>
|
|
<template v-if="eventsLoading">
|
|
<div class="flex flex-col gap-1.5 mt-1">
|
|
<Skeleton v-for="n in 3" :key="n" height="3.5rem" class="rounded-md" />
|
|
</div>
|
|
</template>
|
|
<div v-else-if="!todayEvents.length" class="flex flex-col items-center justify-center gap-2 py-6 text-[var(--text-color-secondary)] text-sm text-center">
|
|
<i class="pi pi-sun text-2xl opacity-20" />
|
|
<span>Nenhuma sessão hoje</span>
|
|
</div>
|
|
<div v-else class="flex flex-col gap-1.5">
|
|
<div
|
|
v-for="ev in todayEvents"
|
|
:key="ev.id"
|
|
class="flex items-center gap-2 px-2.5 py-2 rounded-md bg-[var(--surface-ground)] border-l-[3px] cursor-pointer transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
|
:class="{
|
|
'border-l-green-500': ['confirmado', 'realizado'].includes(ev.status),
|
|
'border-l-red-500 opacity-75': ev.status === 'faltou',
|
|
'border-l-[var(--surface-border)] opacity-60': ev.status === 'cancelado',
|
|
'border-l-[var(--primary-color,#6366f1)]': !['confirmado', 'realizado', 'faltou', 'cancelado'].includes(ev.status)
|
|
}"
|
|
@click="
|
|
onEventRowClick(ev);
|
|
agPanelOpen = false;
|
|
"
|
|
>
|
|
<div class="flex flex-col items-end min-w-[36px] shrink-0">
|
|
<span class="text-xs font-bold text-[var(--text-color)]">{{ fmtHoraEvento(ev.inicio_em) }}</span>
|
|
<span class="text-[0.65rem] text-[var(--text-color-secondary)]">{{ fmtDuracao(ev.inicio_em, ev.fim_em) }}</span>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<span class="block text-[1rem] font-semibold truncate">{{ ev.paciente_nome || ev.patient_name || ev.titulo || '—' }}</span>
|
|
<div class="flex items-center gap-1 mt-0.5 flex-wrap">
|
|
<span class="text-[0.65rem] bg-[var(--surface-border)] text-[var(--text-color-secondary)] px-1.5 py-px rounded font-semibold">{{ ev.modalidade || 'Presencial' }}</span>
|
|
<span v-if="ev.recurrence_id" class="text-[1rem] text-[var(--primary-color,#6366f1)]">↻</span>
|
|
<span v-if="ev.paciente_status === 'Inativo' || ev.paciente_status === 'Arquivado'" class="text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide">{{
|
|
ev.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
v-if="ev.patient_id || ev.paciente_id"
|
|
class="w-6 h-6 rounded-full flex items-center justify-center border-none bg-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--primary-color,#6366f1)] transition-colors duration-100 cursor-pointer shrink-0"
|
|
title="Opções"
|
|
@click.stop="openTodayEvMenu($event, ev)"
|
|
>
|
|
<i class="pi pi-ellipsis-v text-[0.7rem]" />
|
|
</button>
|
|
<i v-else class="text-xs text-[var(--text-color-secondary)] shrink-0" :class="statusIcon(ev.status)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
|
<Button
|
|
label="Novo Compromisso"
|
|
icon="pi pi-plus"
|
|
class="w-full rounded-full"
|
|
@click="
|
|
onCreateFromButton();
|
|
agPanelOpen = false;
|
|
"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Card: Pacientes Desativados/Arquivados com sessões pendentes -->
|
|
<div v-if="desativadoPatients.length" class="border border-orange-500/40 rounded-md bg-orange-500/5 p-3 cursor-pointer hover:bg-orange-500/10 transition-colors duration-150" @click="desativadoDialogOpen = true">
|
|
<div class="flex items-center gap-2 mb-1.5">
|
|
<i class="pi pi-exclamation-triangle text-orange-500 text-sm" />
|
|
<span class="text-[0.72rem] font-bold text-orange-600 uppercase tracking-wide flex-1">Atenção: sessões pendentes</span>
|
|
<span class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-orange-500 text-white text-[0.62rem] font-bold">
|
|
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
|
|
</span>
|
|
</div>
|
|
<p class="text-[0.7rem] text-orange-700/80 leading-snug m-0">
|
|
{{ desativadoPatients.length }} paciente{{ desativadoPatients.length > 1 ? 's' : '' }} desativado{{ desativadoPatients.length > 1 ? 's' : '' }} ou arquivado{{ desativadoPatients.length > 1 ? 's' : '' }}
|
|
com sessões agendadas.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Pacientes -->
|
|
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-users" />Pacientes</span>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<button
|
|
class="flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none bg-transparent cursor-pointer w-full text-left transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
|
@click="router.push('/therapist/patients')"
|
|
>
|
|
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-list" /></div>
|
|
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
|
<span class="text-[1rem] font-semibold text-[var(--text-color)] truncate">Lista de pacientes</span>
|
|
<span class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">Todos os cadastros ativos</span>
|
|
</div>
|
|
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 shrink-0" />
|
|
</button>
|
|
<button
|
|
class="flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none bg-transparent cursor-pointer w-full text-left transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
|
@click="router.push('/therapist/patients/cadastro')"
|
|
>
|
|
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] shrink-0 bg-green-500/10 text-green-600"><i class="pi pi-user-plus" /></div>
|
|
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
|
<span class="text-[1rem] font-semibold text-[var(--text-color)] truncate">Novo paciente</span>
|
|
<span class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">Cadastrar manualmente</span>
|
|
</div>
|
|
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 shrink-0" />
|
|
</button>
|
|
<!-- Link de cadastro externo -->
|
|
<div v-if="cadastroExternoLink" class="flex flex-col gap-1 px-2.5 py-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] mt-1">
|
|
<span class="text-[0.68rem] font-semibold text-[var(--text-color-secondary)] flex items-center gap-1"><i class="pi pi-link text-[0.65rem]" /> Link de cadastro externo</span>
|
|
<div class="flex items-center gap-1.5">
|
|
<input
|
|
readonly
|
|
:value="cadastroExternoLink"
|
|
class="flex-1 min-w-0 text-[0.72rem] text-[var(--primary-color,#6366f1)] bg-transparent border-none outline-none truncate cursor-text font-mono"
|
|
@click="$event.target.select()"
|
|
/>
|
|
<button
|
|
class="grid place-items-center w-[26px] h-[26px] rounded border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer text-xs shrink-0 transition-colors duration-100 hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-[var(--primary-color)]"
|
|
@click="copyLink(cadastroExternoLink)"
|
|
v-tooltip.top="'Copiar link'"
|
|
>
|
|
<i class="pi pi-copy" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Agendador Online -->
|
|
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-calendar-plus" />Agendador Online</span>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<button
|
|
class="flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none bg-transparent cursor-pointer w-full text-left transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
|
@click="router.push('/therapist/agendador/solicitacoes')"
|
|
>
|
|
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-inbox" /></div>
|
|
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
|
<span class="text-[1rem] font-semibold text-[var(--text-color)] truncate">Solicitações</span>
|
|
<span class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">Pedidos de agendamento</span>
|
|
</div>
|
|
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 shrink-0" />
|
|
</button>
|
|
<!-- Link do agendador -->
|
|
<div v-if="agendadorLink" class="flex flex-col gap-1 px-2.5 py-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] mt-1">
|
|
<span class="text-[0.68rem] font-semibold text-[var(--text-color-secondary)] flex items-center gap-1"><i class="pi pi-link text-[0.65rem]" /> Link público do agendador</span>
|
|
<div class="flex items-center gap-1.5">
|
|
<input readonly :value="agendadorLink" class="flex-1 min-w-0 text-[0.72rem] text-[var(--primary-color,#6366f1)] bg-transparent border-none outline-none truncate cursor-text font-mono" @click="$event.target.select()" />
|
|
<button
|
|
class="grid place-items-center w-[26px] h-[26px] rounded border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer text-xs shrink-0 transition-colors duration-100 hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-[var(--primary-color)]"
|
|
@click="copyLink(agendadorLink)"
|
|
v-tooltip.top="'Copiar link'"
|
|
>
|
|
<i class="pi pi-copy" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-xs text-[var(--text-color-secondary)] opacity-60 px-1 pt-1">Agendador não configurado</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="flex flex-col xl:flex-row gap-3 px-3 md:px-4 pb-5 items-start">
|
|
<!-- COL 1: Mini calendário + Jornada + Feriados -->
|
|
<div class="hidden xl:flex flex-col gap-3 w-full xl:w-[25%] shrink-0">
|
|
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
|
<div class="flex items-center justify-between min-w-0">
|
|
<!--<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-calendar" />{{ visibleTitle }}</span>-->
|
|
<div class="flex items-center gap-2 mb-2 min-w-0 flex-1">
|
|
<!--<Button icon="pi pi-home" severity="secondary" text class="h-7 w-7 rounded-full" v-tooltip.top="'Hoje'" @click="miniGoToday" />
|
|
<Button icon="pi pi-chevron-left" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniPrevMonth" />
|
|
<Button icon="pi pi-chevron-right" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniNextMonth" />-->
|
|
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full shrink-0" @click="goToday" />
|
|
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full shrink-0" @click="goPrev" />
|
|
<span
|
|
v-tooltip.top="subtitleText"
|
|
class="inline-flex flex-1 min-w-0 items-center gap-1.5 px-3 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-sm font-semibold text-[var(--text-color)] cursor-pointer transition-colors duration-150 hover:border-[var(--p-primary-400)]"
|
|
@click="toggleMonthPicker"
|
|
>
|
|
<i class="pi pi-calendar text-xs opacity-60 shrink-0" />
|
|
<span class="truncate">{{ subtitleText }}</span>
|
|
</span>
|
|
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full shrink-0" @click="goNext" />
|
|
</div>
|
|
</div>
|
|
<DatePicker v-model="miniDate" inline class="w-full" @update:modelValue="onMiniPick" :pt="{ day: ({ context }) => ({ class: miniDayClass(context.date) }) }">
|
|
<template #date="{ date }">
|
|
<span class="mini-day-num">{{ date.day }}</span>
|
|
<span v-if="hasMiniEvent(date)" class="mini-day-dot" />
|
|
</template>
|
|
</DatePicker>
|
|
</div>
|
|
|
|
<div v-if="jornadaHoje" class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-clock" />Jornada</span>
|
|
</div>
|
|
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">{{ jornadaHoje }}</div>
|
|
</div>
|
|
|
|
<ProximosFeriadosCard :ownerId="ownerId" :tenantId="clinicTenantId" :workRules="workRules" @bloqueado="refetch" />
|
|
|
|
<LoadedPhraseBlock v-if="eventsHasLoaded" />
|
|
</div>
|
|
|
|
<!-- Botão toggle painel (só mobile <xl) -->
|
|
<button
|
|
class="xl:hidden flex w-full items-center justify-between gap-3 px-3.5 py-2.5 bg-[var(--surface-card)] border border-[var(--surface-border)] rounded-md cursor-pointer text-sm font-semibold text-[var(--text-color)]"
|
|
@click="agPanelOpen = !agPanelOpen"
|
|
>
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<i class="pi pi-calendar-clock text-[var(--primary-color,#6366f1)]" />
|
|
<span class="truncate">Calendário · Sessões de hoje</span>
|
|
<span v-if="todayEvents.length" class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.68rem] font-bold shrink-0">{{
|
|
todayEvents.length
|
|
}}</span>
|
|
</div>
|
|
<i class="pi transition-transform duration-200" :class="agPanelOpen ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
|
</button>
|
|
|
|
<!-- COL 2: FullCalendar -->
|
|
<div class="flex flex-col gap-3 w-full xl:w-[45%] min-w-0">
|
|
<div v-if="searchTrim" class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3 mb-3">
|
|
<div class="flex items-center justify-between gap-2 mb-2">
|
|
<div class="font-semibold text-sm">Resultados para "{{ searchTrim }}"</div>
|
|
<div class="flex items-center gap-1">
|
|
<Tag v-if="!searchLoading" :value="`${searchResults.length}`" severity="secondary" />
|
|
<Button icon="pi pi-times" severity="secondary" text class="h-8 w-8 rounded-full" @click="clearSearch" />
|
|
</div>
|
|
</div>
|
|
<div v-if="searchLoading" class="text-color-secondary text-sm">Buscando…</div>
|
|
<div v-else-if="!searchResults.length" class="text-color-secondary text-sm">Nenhum resultado.</div>
|
|
<div v-else class="flex flex-col gap-2 max-h-[220px] overflow-auto pr-1">
|
|
<button v-for="r in searchResults" :key="r.id" class="text-left rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-ground)] p-2.5 transition hover:border-[var(--p-primary-300)]" @click="gotoResult(r)">
|
|
<div class="font-medium text-sm truncate">{{ r.titulo || 'Sem título' }}</div>
|
|
<div class="mt-0.5 flex items-center justify-between gap-2 text-xs opacity-70">
|
|
<span class="truncate">{{ fmtDateTime(r.inicio_em) }}</span>
|
|
<Tag :value="labelTipo(r.tipo)" severity="info" />
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] overflow-hidden shadow-sm agenda-altura">
|
|
<div v-if="calendarView === 'day' && miniBlockedDaySet.has(currentDateISO)" class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-red-700 bg-red-400/10 border-b border-red-400/25">
|
|
<i class="pi pi-lock text-xs" /> Dia bloqueado — sessões não permitidas
|
|
</div>
|
|
<div class="p-2">
|
|
<FullCalendar ref="fcRef" :key="fcKey" :options="fcOptions" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- COL 3: Resumo do dia -->
|
|
<div class="hidden xl:flex flex-col gap-3 w-full xl:w-[30%] shrink-0">
|
|
<!-- Stats do dia -->
|
|
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-chart-bar" />Hoje</span>
|
|
</div>
|
|
<div class="grid grid-cols-4 gap-2">
|
|
<template v-if="eventsLoading">
|
|
<Skeleton v-for="n in 4" :key="n" height="3rem" class="rounded-md" />
|
|
</template>
|
|
<template v-else>
|
|
<div class="flex flex-col gap-0.5 p-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] text-center" v-for="s in todayStats" :key="s.label">
|
|
<div class="text-[1.25rem] font-bold leading-none text-[var(--text-color)]" :class="{ 'text-green-500': s.cls === 'ag-stat--ok', 'text-red-500': s.cls === 'ag-stat--warn' }">{{ s.value }}</div>
|
|
<div class="text-[0.65rem] font-semibold uppercase tracking-[0.04em] text-[var(--text-color-secondary)] opacity-70">{{ s.label }}</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sessões do dia -->
|
|
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3 flex-1">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-list" />Sessões hoje</span>
|
|
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.65rem] font-bold">{{ todayEvents.length }}</span>
|
|
</div>
|
|
|
|
<template v-if="eventsLoading">
|
|
<div class="flex flex-col gap-1.5 mt-1">
|
|
<Skeleton v-for="n in 3" :key="n" height="3.5rem" class="rounded-md" />
|
|
</div>
|
|
</template>
|
|
<div v-else-if="!todayEvents.length" class="flex flex-col items-center justify-center gap-2 py-6 text-[var(--text-color-secondary)] text-sm text-center">
|
|
<i class="pi pi-sun text-2xl opacity-20" />
|
|
<span>Nenhuma sessão hoje</span>
|
|
</div>
|
|
|
|
<div v-else class="flex flex-col gap-1.5">
|
|
<div
|
|
v-for="ev in todayEvents"
|
|
:key="ev.id"
|
|
class="flex items-center gap-2 px-2.5 py-2 rounded-md bg-[var(--surface-ground)] border-l-[3px] cursor-pointer transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
|
:class="{
|
|
'border-l-green-500': ['confirmado', 'realizado'].includes(ev.status),
|
|
'border-l-red-500 opacity-75': ev.status === 'faltou',
|
|
'border-l-[var(--surface-border)] opacity-60': ev.status === 'cancelado',
|
|
'border-l-[var(--primary-color,#6366f1)]': !['confirmado', 'realizado', 'faltou', 'cancelado'].includes(ev.status)
|
|
}"
|
|
@click="onEventRowClick(ev)"
|
|
>
|
|
<div class="flex flex-col items-end min-w-[36px] shrink-0">
|
|
<span class="text-xs font-bold text-[var(--text-color)]">{{ fmtHoraEvento(ev.inicio_em) }}</span>
|
|
<span class="text-[0.65rem] text-[var(--text-color-secondary)]">{{ fmtDuracao(ev.inicio_em, ev.fim_em) }}</span>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<span class="block text-[1rem] font-semibold truncate">{{ ev.paciente_nome || ev.patient_name || ev.titulo || '—' }}</span>
|
|
<div class="flex items-center gap-1 mt-0.5 flex-wrap">
|
|
<span class="text-[0.65rem] bg-[var(--surface-border)] text-[var(--text-color-secondary)] px-1.5 py-px rounded font-semibold">{{ ev.modalidade || 'Presencial' }}</span>
|
|
<span v-if="ev.recurrence_id" class="text-[1rem] text-[var(--primary-color,#6366f1)]" title="Recorrente">↻</span>
|
|
<span v-if="ev.paciente_status === 'Inativo' || ev.paciente_status === 'Arquivado'" class="text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide">{{
|
|
ev.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
v-if="ev.patient_id || ev.paciente_id"
|
|
class="w-6 h-6 rounded-full flex items-center justify-center border-none bg-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--primary-color,#6366f1)] transition-colors duration-100 cursor-pointer shrink-0"
|
|
title="Opções"
|
|
@click.stop="openTodayEvMenu($event, ev)"
|
|
>
|
|
<i class="pi pi-ellipsis-v text-[0.7rem]" />
|
|
</button>
|
|
<i v-else class="text-xs text-[var(--text-color-secondary)] shrink-0" :class="statusIcon(ev.status)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
|
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton" />
|
|
</div>
|
|
|
|
<!-- Card: Pacientes Desativados/Arquivados com sessões pendentes -->
|
|
<div v-if="desativadoPatients.length" class="border border-orange-500/40 rounded-md bg-orange-500/5 p-3 cursor-pointer hover:bg-orange-500/10 transition-colors duration-150" @click="desativadoDialogOpen = true">
|
|
<div class="flex items-center gap-2 mb-1.5">
|
|
<i class="pi pi-exclamation-triangle text-orange-500 text-sm" />
|
|
<span class="text-[0.72rem] font-bold text-orange-600 uppercase tracking-wide flex-1">Atenção: sessões pendentes</span>
|
|
<span class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-orange-500 text-white text-[0.62rem] font-bold">
|
|
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
|
|
</span>
|
|
</div>
|
|
<p class="text-[0.7rem] text-orange-700/80 leading-snug m-0">
|
|
{{ desativadoPatients.length }} paciente{{ desativadoPatients.length > 1 ? 's' : '' }} desativado{{ desativadoPatients.length > 1 ? 's' : '' }} ou arquivado{{ desativadoPatients.length > 1 ? 's' : '' }}
|
|
com sessões agendadas.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Pacientes -->
|
|
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-users" />Pacientes</span>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<button
|
|
class="flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none bg-transparent cursor-pointer w-full text-left transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
|
@click="router.push('/therapist/patients')"
|
|
>
|
|
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-list" /></div>
|
|
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
|
<span class="text-[1rem] font-semibold text-[var(--text-color)] truncate">Lista de pacientes</span>
|
|
<span class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">Todos os cadastros ativos</span>
|
|
</div>
|
|
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 shrink-0" />
|
|
</button>
|
|
<button
|
|
class="flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none bg-transparent cursor-pointer w-full text-left transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
|
@click="router.push('/therapist/patients/cadastro')"
|
|
>
|
|
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] shrink-0 bg-green-500/10 text-green-600"><i class="pi pi-user-plus" /></div>
|
|
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
|
<span class="text-[1rem] font-semibold text-[var(--text-color)] truncate">Novo paciente</span>
|
|
<span class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">Cadastrar manualmente</span>
|
|
</div>
|
|
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 shrink-0" />
|
|
</button>
|
|
<!-- Link de cadastro externo -->
|
|
<div v-if="cadastroExternoLink" class="flex flex-col gap-1 px-2.5 py-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] mt-1">
|
|
<span class="text-[0.68rem] font-semibold text-[var(--text-color-secondary)] flex items-center gap-1"><i class="pi pi-link text-[0.65rem]" /> Link de cadastro externo</span>
|
|
<div class="flex items-center gap-1.5">
|
|
<input
|
|
readonly
|
|
:value="cadastroExternoLink"
|
|
class="flex-1 min-w-0 text-[0.72rem] text-[var(--primary-color,#6366f1)] bg-transparent border-none outline-none truncate cursor-text font-mono"
|
|
@click="$event.target.select()"
|
|
/>
|
|
<button
|
|
class="grid place-items-center w-[26px] h-[26px] rounded border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer text-xs shrink-0 transition-colors duration-100 hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-[var(--primary-color)]"
|
|
@click="copyLink(cadastroExternoLink)"
|
|
v-tooltip.top="'Copiar link'"
|
|
>
|
|
<i class="pi pi-copy" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Agendador Online -->
|
|
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-calendar-plus" />Agendador Online</span>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<button
|
|
class="flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none bg-transparent cursor-pointer w-full text-left transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
|
@click="router.push('/therapist/agendador/solicitacoes')"
|
|
>
|
|
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-inbox" /></div>
|
|
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
|
<span class="text-[1rem] font-semibold text-[var(--text-color)] truncate">Solicitações</span>
|
|
<span class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">Pedidos de agendamento</span>
|
|
</div>
|
|
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 shrink-0" />
|
|
</button>
|
|
<!-- Link do agendador -->
|
|
<div v-if="agendadorLink" class="flex flex-col gap-1 px-2.5 py-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] mt-1">
|
|
<span class="text-[0.68rem] font-semibold text-[var(--text-color-secondary)] flex items-center gap-1"><i class="pi pi-link text-[0.65rem]" /> Link público do agendador</span>
|
|
<div class="flex items-center gap-1.5">
|
|
<input readonly :value="agendadorLink" class="flex-1 min-w-0 text-[0.72rem] text-[var(--primary-color,#6366f1)] bg-transparent border-none outline-none truncate cursor-text font-mono" @click="$event.target.select()" />
|
|
<button
|
|
class="grid place-items-center w-[26px] h-[26px] rounded border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer text-xs shrink-0 transition-colors duration-100 hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-[var(--primary-color)]"
|
|
@click="copyLink(agendadorLink)"
|
|
v-tooltip.top="'Copiar link'"
|
|
>
|
|
<i class="pi pi-copy" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-xs text-[var(--text-color-secondary)] opacity-60 px-1 pt-1">Agendador não configurado</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dialog Busca (mobile + desktop) -->
|
|
<Dialog v-model:visible="searchModalOpen" modal header="Buscar na agenda" :style="{ width: '96vw', maxWidth: '720px' }" :breakpoints="{ '960px': '92vw', '640px': '96vw' }" :draggable="false">
|
|
<div class="flex flex-col gap-3">
|
|
<!-- Campo + seletor de escopo -->
|
|
<div class="flex gap-2 items-end">
|
|
<FloatLabel variant="on" class="flex-1">
|
|
<IconField>
|
|
<InputIcon><i class="pi pi-search" /></InputIcon>
|
|
<InputText id="agendaSearchModal" v-model="search" class="w-full" autocomplete="off" autofocus />
|
|
</IconField>
|
|
<label for="agendaSearchModal">Paciente, título ou observação…</label>
|
|
</FloatLabel>
|
|
|
|
<!-- Escopo: Período / Mês -->
|
|
<div class="flex rounded-full border border-[var(--surface-border)] overflow-hidden shrink-0">
|
|
<button
|
|
class="px-3 py-2 text-xs font-semibold transition"
|
|
:class="searchScope === 'range' ? 'bg-[var(--p-primary-500)] text-white' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:bg-[var(--surface-hover)]'"
|
|
@click="searchScope = 'range'"
|
|
>
|
|
Período
|
|
</button>
|
|
<button
|
|
class="px-3 py-2 text-xs font-semibold transition"
|
|
:class="searchScope === 'month' ? 'bg-[var(--p-primary-500)] text-white' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:bg-[var(--surface-hover)]'"
|
|
@click="searchScope = 'month'"
|
|
>
|
|
Mês
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Divider class="my-0" />
|
|
|
|
<!-- Loading -->
|
|
<div v-if="monthSearchLoading" class="flex items-center gap-2 text-sm text-color-secondary py-2"><i class="pi pi-spin pi-spinner" /> Buscando no mês…</div>
|
|
|
|
<!-- Aguardando digitação -->
|
|
<div v-else-if="!searchTrim" class="text-color-secondary text-sm py-2">Digite para buscar compromissos na agenda.</div>
|
|
|
|
<!-- Sem resultados -->
|
|
<div v-else-if="searchResults.length === 0" class="text-color-secondary text-sm">
|
|
Nenhum resultado para "<b>{{ searchTrim }}</b
|
|
>" <span class="text-xs"> ({{ searchScope === 'month' ? 'mês inteiro' : 'período atual' }})</span>.
|
|
</div>
|
|
|
|
<!-- Resultados -->
|
|
<div v-else class="flex flex-col gap-2 max-h-[60vh] overflow-auto pr-1">
|
|
<div class="text-xs text-color-secondary mb-1">
|
|
{{ searchResults.length }} resultado(s) ·
|
|
<span>{{ searchScope === 'month' ? 'mês inteiro' : 'período atual' }}</span>
|
|
</div>
|
|
|
|
<button
|
|
v-for="r in searchResults"
|
|
:key="r.id"
|
|
class="text-left rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 transition hover:shadow-md hover:border-[var(--p-primary-300)]"
|
|
@click="gotoResultFromModal(r)"
|
|
>
|
|
<!-- Linha 1: data + hora + tipo -->
|
|
<div class="flex items-center justify-between gap-2 mb-1">
|
|
<span class="text-xs font-bold text-[var(--p-primary-500)]">
|
|
{{ fmtDateOnly(r.inicio_em) }}
|
|
<span class="font-normal text-color-secondary ml-1">{{ fmtTimeOnly(r.inicio_em) }}</span>
|
|
</span>
|
|
<Tag :value="labelTipo(r.tipo)" severity="info" class="text-xs" />
|
|
</div>
|
|
|
|
<!-- Linha 2: nome do paciente ou título -->
|
|
<div class="font-semibold text-sm truncate">
|
|
{{ r.paciente_nome || r.patient_name || r.titulo || 'Sem título' }}
|
|
</div>
|
|
<span v-if="r.paciente_status === 'Inativo' || r.paciente_status === 'Arquivado'" class="inline-block text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide mt-0.5">{{
|
|
r.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'
|
|
}}</span>
|
|
|
|
<!-- Linha 3: título (se paciente diferente de título) -->
|
|
<div v-if="(r.paciente_nome || r.patient_name) && r.titulo" class="text-xs text-color-secondary truncate">
|
|
{{ r.titulo }}
|
|
</div>
|
|
|
|
<!-- Linha 4: observações -->
|
|
<div v-if="r.observacoes" class="mt-1 text-xs text-color-secondary truncate opacity-75">
|
|
{{ r.observacoes }}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<Button label="Fechar" icon="pi pi-times" text @click="searchModalOpen = false" />
|
|
<Button v-if="searchTrim" label="Limpar busca" icon="pi pi-eraser" severity="secondary" outlined class="rounded-full" @click="clearSearchAndClose" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- Month Picker -->
|
|
<Dialog v-model:visible="monthPickerVisible" modal header="Escolher mês" :style="{ width: '420px' }">
|
|
<div class="p-2">
|
|
<DatePicker v-model="monthPickerDate" view="month" dateFormat="mm/yy" class="w-full" />
|
|
<div class="mt-3 flex justify-end gap-2">
|
|
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="monthPickerVisible = false" />
|
|
<Button label="Ir" class="rounded-full" @click="applyMonthPick" />
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
|
|
<!-- Dialog real -->
|
|
<AgendaEventDialog
|
|
v-model="dialogOpen"
|
|
:eventRow="dialogEventRow"
|
|
:initialStartISO="dialogStartISO"
|
|
:initialEndISO="dialogEndISO"
|
|
:ownerId="ownerId"
|
|
:tenantId="clinicTenantId"
|
|
:commitmentOptions="commitmentOptionsNormalized"
|
|
:workRules="workRules"
|
|
:blockedDates="[...miniBlockedDaySet]"
|
|
:agendaSettings="settings"
|
|
:allEvents="calendarRows"
|
|
:pausasSemanais="settings?.pausas_semanais || []"
|
|
:feriados="feriadosTodosProximos"
|
|
newPatientRoute="/therapist/patients/cadastro"
|
|
@save="onDialogSave"
|
|
@delete="onDialogDelete"
|
|
@updateSeriesEvent="onUpdateSeriesEvent"
|
|
@editSeriesOccurrence="onEditSeriesOccurrence"
|
|
/>
|
|
|
|
<!-- Dialog de Bloqueio -->
|
|
<BloqueioDialog v-model="bloqueioDialogOpen" :mode="bloqueioMode" :workRules="workRules" :settings="settings" :ownerId="ownerId" :tenantId="clinicTenantId" @saved="refetch" />
|
|
|
|
<!-- Dialog: feriados próximos (bloqueados e pendentes) -->
|
|
<Dialog v-model:visible="feriadosAlertaOpen" modal :draggable="false" header="Feriados nos próximos 30 dias" :style="{ width: '520px', maxWidth: '96vw' }">
|
|
<div class="flex flex-col gap-3">
|
|
<div v-if="feriadosSemBloqueio.length" class="flex items-start gap-3 p-3 rounded-2xl" style="background: color-mix(in srgb, var(--red-400) 10%, var(--surface-card)); border: 1px solid color-mix(in srgb, var(--red-400) 28%, transparent)">
|
|
<i class="pi pi-exclamation-triangle mt-0.5 shrink-0" style="color: var(--red-500)" />
|
|
<p class="text-sm leading-relaxed m-0">{{ feriadosSemBloqueio.length }} feriado(s) em dias de trabalho ainda sem bloqueio. Bloqueie para evitar agendamentos indevidos.</p>
|
|
</div>
|
|
<div v-else class="flex items-center gap-2 p-3 rounded-2xl" style="background: color-mix(in srgb, var(--green-400) 10%, var(--surface-card)); border: 1px solid color-mix(in srgb, var(--green-400) 28%, transparent)">
|
|
<i class="pi pi-check-circle" style="color: var(--green-600)" />
|
|
<p class="text-sm m-0">Todos os feriados próximos estão bloqueados.</p>
|
|
</div>
|
|
|
|
<ul class="flex flex-col gap-2 max-h-[360px] overflow-y-auto pr-1">
|
|
<li v-for="f in feriadosTodosProximos" :key="f.data">
|
|
<div class="flex items-center gap-2 p-2 rounded-xl border" :class="f.bloqueado ? 'border-[var(--surface-border)] opacity-70' : 'border-red-300'">
|
|
<i class="text-sm shrink-0" :class="f.bloqueado ? 'pi pi-lock' : 'pi pi-calendar-times'" :style="{ color: f.bloqueado ? 'var(--text-color-secondary)' : 'var(--red-500)' }" />
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium truncate">{{ f.nome }}</div>
|
|
<div class="text-xs text-[var(--text-color-secondary)] capitalize">{{ fmtFeriadoDateLong(f.data) }}</div>
|
|
</div>
|
|
|
|
<!-- Já bloqueado: botão desbloquear -->
|
|
<template v-if="f.bloqueado">
|
|
<span class="text-xs text-[var(--text-color-secondary)] flex items-center gap-1 shrink-0 mr-1"> <i class="pi pi-check-circle" style="color: var(--green-500)" /> Bloqueado </span>
|
|
<Button label="Desfazer" icon="pi pi-lock-open" size="small" severity="secondary" outlined class="rounded-full shrink-0" :loading="feriadosAlertaSalvando === 'unblock_' + f.data" @click="desbloquearFeriadoDoAlerta(f)" />
|
|
</template>
|
|
|
|
<!-- Não bloqueado: botão bloquear -->
|
|
<Button v-else label="Bloquear" icon="pi pi-lock" size="small" severity="danger" outlined class="rounded-full shrink-0" :loading="feriadosAlertaSalvando === f.data" @click="bloquearFeriadoDoAlerta(f)" />
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="feriadosAlertaOpen = false" />
|
|
<Button v-if="feriadosSemBloqueio.length" label="Bloquear todos" icon="pi pi-lock" severity="danger" class="rounded-full" :loading="feriadosAlertaSalvando === 'all'" @click="bloquearTodosFeriadosAlerta" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════
|
|
Dialog: Sessões Pendentes de Pacientes Desativados/Arquivados
|
|
═══════════════════════════════════════════════════════════ -->
|
|
<Dialog
|
|
v-model:visible="desativadoDialogOpen"
|
|
modal
|
|
:draggable="false"
|
|
:style="{ width: '1100px', maxWidth: '97vw', height: '85vh' }"
|
|
:pt="{ content: { style: 'padding:0; display:flex; flex-direction:column; height:100%; overflow:hidden;' }, header: { style: 'padding: 1rem 1.25rem 0.75rem' } }"
|
|
>
|
|
<template #header>
|
|
<div class="flex items-center gap-2 w-full">
|
|
<i class="pi pi-exclamation-triangle text-orange-500" />
|
|
<span class="font-semibold text-base">Sessões agendadas — pacientes desativados</span>
|
|
<span class="ml-1 inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full bg-orange-500 text-white text-xs font-bold">
|
|
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
|
|
</span>
|
|
<!-- Tabs pacientes -->
|
|
<div class="flex gap-1 ml-auto flex-wrap">
|
|
<button
|
|
v-for="p in desativadoPatients"
|
|
:key="p.id"
|
|
class="px-3 py-1 rounded-full text-xs font-semibold border transition-all duration-150"
|
|
:class="desativadoSelected?.id === p.id ? 'bg-orange-500 text-white border-orange-500' : 'bg-transparent text-orange-600 border-orange-500/40 hover:bg-orange-500/10'"
|
|
@click="desativadoSelected = p"
|
|
>
|
|
{{ (p.nome_completo || '—').split(' ')[0] }}
|
|
<span class="ml-1 opacity-70">({{ p.sessions.length }})</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Body: split panel -->
|
|
<div v-if="desativadoSelected" class="flex flex-col lg:flex-row flex-1 min-h-0 overflow-hidden">
|
|
<!-- Sidebar esquerda: lista de sessões -->
|
|
<div class="w-full lg:w-[340px] lg:shrink-0 flex flex-col border-b lg:border-b-0 lg:border-r border-[var(--surface-border)]">
|
|
<!-- Patient info -->
|
|
<div class="px-4 py-3 border-b border-[var(--surface-border)] bg-orange-500/5">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm shrink-0" style="background: #f97316">
|
|
{{ (desativadoSelected.nome_completo || '?').charAt(0).toUpperCase() }}
|
|
</div>
|
|
<div class="min-w-0">
|
|
<div class="font-semibold text-sm truncate">{{ desativadoSelected.nome_completo }}</div>
|
|
<span style="display: inline-block; background: #f97316; color: #fff; font-size: 9px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; padding: 1px 6px; border-radius: 3px; line-height: 1.5">
|
|
{{ desativadoSelected.status === 'Arquivado' ? 'arquivado' : 'desativado' }}
|
|
</span>
|
|
</div>
|
|
<div class="ml-auto text-right shrink-0">
|
|
<div class="text-lg font-bold text-orange-500">{{ desativadoSelected.sessions.length }}</div>
|
|
<div class="text-[0.65rem] text-[var(--text-color-secondary)]">sessão(ões)</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sessions list -->
|
|
<div class="flex-1 overflow-y-auto">
|
|
<div class="p-3 flex flex-col gap-2">
|
|
<div
|
|
v-for="s in desativadoSelected.sessions"
|
|
:key="s.id"
|
|
class="rounded-lg border p-2.5 cursor-pointer transition-all duration-150 group"
|
|
:class="desativadoFocused?.id === s.id ? 'border-orange-500 bg-orange-500/8 shadow-sm' : 'border-[var(--surface-border)] bg-[var(--surface-card)] hover:border-orange-500/40 hover:bg-orange-500/5'"
|
|
@click="focusDesativadoSession(s)"
|
|
>
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="min-w-0">
|
|
<div class="text-xs font-bold text-[var(--text-color)]">
|
|
{{ fmtDesativadoDate(s.inicio_em) }}
|
|
</div>
|
|
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">{{ fmtDesativadoTime(s.inicio_em) }} · {{ fmtDesativadoDur(s.inicio_em, s.fim_em) }}</div>
|
|
<div v-if="s.titulo" class="text-[0.7rem] text-[var(--text-color-secondary)] truncate mt-0.5">{{ s.titulo }}</div>
|
|
</div>
|
|
<Tag :value="s.modalidade || 'Presencial'" severity="secondary" class="text-[0.6rem] shrink-0" />
|
|
</div>
|
|
<div class="flex gap-1.5 mt-2">
|
|
<Button label="Ver na agenda" icon="pi pi-external-link" size="small" severity="warn" outlined class="flex-1 text-[0.65rem]" @click.stop="openSessionInMainCalendar(s)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Painel direito: mini FullCalendar -->
|
|
<div class="flex-1 min-w-0 min-h-[300px] lg:min-h-0 overflow-hidden flex flex-col">
|
|
<div class="px-4 py-2 border-b border-[var(--surface-border)] flex items-center gap-2">
|
|
<i class="pi pi-calendar text-[var(--text-color-secondary)] text-xs" />
|
|
<span class="text-xs text-[var(--text-color-secondary)]">Clique em uma sessão para abrir na agenda principal e cancelar ou remarcar</span>
|
|
</div>
|
|
<div class="flex-1 overflow-auto p-2">
|
|
<FullCalendar v-if="desativadoDialogOpen && desativadoSelected" ref="desativadoFcRef" :options="desativadoFcOptions" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<Button label="Fechar" severity="secondary" outlined @click="desativadoDialogOpen = false" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- Menu contexto: Sessões Hoje -->
|
|
<Menu ref="todayEvMenuRef" :model="todayEvMenuItems" :popup="true" />
|
|
|
|
<!-- Dialog: Prontuário -->
|
|
<PatientProntuario :key="selectedPatient?.id || 'none'" v-model="prontuarioOpen" :patient="selectedPatient" @close="closeProntuario" />
|
|
|
|
<!-- Fase C: confirma status change com decisões de billing
|
|
(multa, consumir saldo, gerar cobrança, reverse transition). -->
|
|
<AgendaStatusChangeConfirmDialog
|
|
v-model="statusDialogOpen"
|
|
:evento="statusDialogProps.evento"
|
|
:novoStatus="statusDialogProps.novoStatus"
|
|
:regraExcecao="statusDialogProps.regraExcecao"
|
|
:billingContract="statusDialogProps.billingContract"
|
|
:billingContractStyle="statusDialogProps.billingContractStyle"
|
|
:pendingRecord="statusDialogProps.pendingRecord"
|
|
:sessionPrice="statusDialogProps.sessionPrice"
|
|
@confirm="onStatusDialogConfirm"
|
|
@update:modelValue="(v) => !v && onStatusDialogCancel()"
|
|
/>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Fade transition */
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.14s ease;
|
|
}
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
/* FullCalendar event dim/hit helpers */
|
|
.evt-dim {
|
|
opacity: 0.25;
|
|
}
|
|
.evt-hit {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Mini calendar — :deep overrides for PrimeVue Calendar component */
|
|
:deep(.w-full.p-datepicker) {
|
|
width: 100%;
|
|
border: none;
|
|
padding: 0;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
}
|
|
:deep(.w-full .p-datepicker-header) {
|
|
padding: 0 0 0.5rem;
|
|
border: none;
|
|
background: transparent;
|
|
}
|
|
:deep(.w-full .p-datepicker-calendar) {
|
|
width: 100%;
|
|
font-size: 0.78rem;
|
|
}
|
|
:deep(.w-full .p-datepicker-calendar td) {
|
|
padding: 1px;
|
|
}
|
|
:deep(.w-full .p-datepicker-calendar td > span) {
|
|
width: 100%;
|
|
min-width: unset;
|
|
border-radius: 6px;
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
aspect-ratio: 1;
|
|
}
|
|
|
|
/* Mini dot inside calendar day slot */
|
|
.mini-day-num {
|
|
display: block;
|
|
text-align: center;
|
|
line-height: 1;
|
|
}
|
|
.mini-day-dot {
|
|
position: absolute;
|
|
bottom: 2px;
|
|
right: 2px;
|
|
width: 4px;
|
|
height: 4px;
|
|
border-radius: 50%;
|
|
background: var(--primary-color, #6366f1);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Drawer — needs CSS transition + fixed positioning, not expressible in Tailwind base */
|
|
.panel-drawer {
|
|
transition:
|
|
transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
|
visibility 0.25s;
|
|
}
|
|
|
|
/* Mini day classes injected via :pt */
|
|
:deep(.mini-day-blocked) {
|
|
background: color-mix(in srgb, #ef4444 20%, transparent) !important;
|
|
border-radius: 4px;
|
|
}
|
|
:deep(.mini-day-work) {
|
|
}
|
|
:deep(.mini-day-off) {
|
|
opacity: 0.45;
|
|
}
|
|
:deep(.p-disabled.mini-day-work) {
|
|
background: color-mix(in srgb, #9ca3af 18%, transparent) !important;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
/* Semana atual — faixa de fundo contínua seg→dom */
|
|
:deep(.mini-week-hl) {
|
|
background: color-mix(in srgb, var(--primary-color, #6366f1) 12%, transparent) !important;
|
|
border-radius: 0 !important;
|
|
}
|
|
:deep(.mini-week-hl--start) {
|
|
border-radius: 6px 0 0 6px !important;
|
|
}
|
|
:deep(.mini-week-hl--end) {
|
|
border-radius: 0 6px 6px 0 !important;
|
|
}
|
|
|
|
/* Hoje — cartão com borda + sombra */
|
|
:deep(.mini-day-today) {
|
|
background: color-mix(in srgb, var(--primary-color, #6366f1) 80%, #00000000) !important;
|
|
border: 1px solid var(--surface-border) !important;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06) !important;
|
|
border-radius: 6px !important;
|
|
color: #ffffff !important;
|
|
font-weight: 600 !important;
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
/* ── Altura mínima dos slots ───────────────────────────── */
|
|
.fc-timegrid-slot {
|
|
height: 14px !important;
|
|
}
|
|
.fc-timegrid-slot-label {
|
|
font-size: 10px !important;
|
|
line-height: 1 !important;
|
|
}
|
|
|
|
/* ── Slot labels customizados ──────────────────────────── */
|
|
.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;
|
|
}
|
|
|
|
/* ── Cores dos eventos (global — aplicadas pelo FullCalendar) ── */
|
|
/* Cor primária padrão só quando o evento não tem cor personalizada do commitment */
|
|
.fc-event.evt-session:not(.evt-has-color) {
|
|
background-color: var(--p-primary-500, #6366f1) !important;
|
|
border-color: var(--p-primary-600, #4f46e5) !important;
|
|
color: #fff !important;
|
|
}
|
|
/* Cor de texto branca garantida para eventos com cor personalizada */
|
|
.fc-event.evt-session.evt-has-color {
|
|
color: #fff !important;
|
|
}
|
|
/* Bloqueios são background events (cinza ~20%, inline em _makeBloqueioEvent).
|
|
A regra .fc-event.evt-block antiga pintava de vermelho — removida.
|
|
Deixar o backgroundColor inline (#6b728033) vencer. */
|
|
/* dayGridMonth: o dot também precisa de cor */
|
|
.fc-daygrid-event.evt-session .fc-event-main {
|
|
color: #fff;
|
|
}
|
|
|
|
/* Evento customizado — fora do scoped pois é HTML injetado pelo FullCalendar */
|
|
.ev-custom {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 5px;
|
|
overflow: hidden;
|
|
padding: 1px 2px;
|
|
height: 100%;
|
|
width: 100%;
|
|
}
|
|
.ev-avatar {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
margin-top: 1px;
|
|
}
|
|
.ev-avatar-img {
|
|
object-fit: cover;
|
|
}
|
|
.ev-avatar-initials {
|
|
background: rgba(255, 255, 255, 0.25);
|
|
color: #fff;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.ev-body {
|
|
min-width: 0;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
.ev-title {
|
|
font-size: 11px;
|
|
line-height: 1.3;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.ev-name {
|
|
font-weight: 600;
|
|
}
|
|
.ev-hour {
|
|
font-weight: 400;
|
|
font-size: 10px;
|
|
opacity: 0.75;
|
|
margin-left: 2px;
|
|
}
|
|
.ev-badge {
|
|
display: inline-block;
|
|
background: #f97316;
|
|
color: #fff;
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
padding: 1px 5px;
|
|
border-radius: 3px;
|
|
line-height: 1.4;
|
|
margin-top: 2px;
|
|
}
|
|
.ev-obs {
|
|
font-size: 10px;
|
|
opacity: 0.75;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
margin-top: 1px;
|
|
}
|
|
|
|
/* Mini calendário — colorir dias por expediente (global: applied by PrimeVue outside scoped) */
|
|
.p-datepicker-day.mini-day-work:not(.p-datepicker-day-selected) {
|
|
background: rgba(34, 197, 94, 0.25);
|
|
}
|
|
.p-datepicker-day.mini-day-off:not(.p-datepicker-day-selected) {
|
|
background: rgba(239, 68, 68, 0.2);
|
|
}
|
|
.p-datepicker-day.mini-day-blocked:not(.p-datepicker-day-selected) {
|
|
background: rgba(239, 68, 68, 0.55);
|
|
color: #fff;
|
|
font-weight: 700;
|
|
}
|
|
.p-datepicker-day span {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.mini-day-dot {
|
|
position: absolute;
|
|
right: -2px;
|
|
width: 5px;
|
|
height: 5px;
|
|
border-radius: 50%;
|
|
background: var(--primary-color, #6366f1);
|
|
}
|
|
</style>
|