ffcb8b17f9
Composable useMelissaAgenda (~1150 linhas, exclusivo Melissa): - Orquestra useAgendaEvents + useRecurrence + useDeterminedCommitments + useFeriados + useCommitmentServices - 7 cases de save (avulso, recorrente C, somente_este D, este_e_seguintes E, todos F, todos_sem_excecao G + tratamento de exclusion constraint) - 3 cases de delete (somente_este, este_e_seguintes, todos com encerrar série) - onCreateEvento (botão Agendar), onSelectTime com cap de 120min, persistMoveOrResize com confirm dialog descritivo e bold em datas/horas - Bloqueio: openBloqueioDialog(mode) com 4 modos MelissaLayout: - Provide composable via MELISSA_AGENDA_KEY (inject em MelissaAgenda) - Renderiza AgendaEventDialog + BloqueioDialog + ConfirmDialog - Slot #message v-html pra renderizar HTML em messages do confirm - onEditEvento liga panel ao dialog completo (B3 não-stub) MelissaAgenda: - Drop useMelissaEventosRange — eventos vêm do composable injetado - Drag/resize/select-to-create habilitados quando há composable - Cluster Paciente + Agendar (50/50 primary) - Toolbar: timeMode (24/12/Meu) + onlySessions + bloquear-menu (desktop) - Header: Pacientes (mobile-only, abre drawer) + Configurações + Fechar - Mobile <lg: aside + widgets viram drawer off-canvas (slide esquerda); calendar fullwidth; "Ações" menu mobile concentra timeMode/onlySessions/ bloquear; backdrop com click-outside MelissaEventoPanel (B3 estático-revisado): - Substitui panel inline que crashava em campos inexistentes - Action bar agrupada (status / paciente / geral) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1304 lines
62 KiB
JavaScript
1304 lines
62 KiB
JavaScript
/*
|
||
* useMelissaAgenda — Orquestrador da Agenda dentro do Layout Melissa.
|
||
* --------------------------------------------------
|
||
* Equivalente Melissa-exclusivo de AgendaTerapeutaPage.vue: carrega settings,
|
||
* eventos reais (useAgendaEvents), expande recorrências (useRecurrence),
|
||
* resolve compromissos determinados (catálogo) e feriados, mantém o estado
|
||
* do dialog de edição, e expõe TODOS os handlers que o dialog/FC precisam:
|
||
* onDialogSave / onDialogDelete — CRUD com 7 cases (avulso, recorrente,
|
||
* somente_este, este_e_seguintes, todos,
|
||
* todos_sem_excecao)
|
||
* onUpdateSeriesEvent — mudança de status numa ocorrência
|
||
* onEditSeriesOccurrence — preview ao alternar editScope
|
||
* persistMoveOrResize — drag/resize do FC com checagem de conflito
|
||
* onSelectTime — click-drag pra criar evento novo
|
||
* onEditEvento — popula dialogEventRow a partir do raw row
|
||
*
|
||
* Eventos retornados em `eventos` são Melissa-shape (color, label, startH/endH,
|
||
* dateKey, _raw) — `_raw` carrega o registro bruto + flags de ocorrência virtual
|
||
* pra alimentar o AgendaEventDialog.
|
||
*
|
||
* Cria refs `viewStart`/`viewEnd` internamente — MelissaAgenda escreve neles
|
||
* no datesSet do FC (via provide/inject). O composable observa e refetcha.
|
||
*
|
||
* Os handlers exibem toasts (success/warn) — o composable assume que os
|
||
* componentes consumidores já registraram `<Toast />` e `<ConfirmDialog />`.
|
||
*/
|
||
import { ref, computed, watch, onMounted } from 'vue';
|
||
import { useToast } from 'primevue/usetoast';
|
||
import { useConfirm } from 'primevue/useconfirm';
|
||
import { supabase } from '@/lib/supabase/client';
|
||
import { useTenantStore } from '@/stores/tenantStore';
|
||
|
||
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
|
||
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents';
|
||
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
||
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
|
||
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
|
||
import { useFeriados } from '@/composables/useFeriados';
|
||
|
||
// ─── Constantes do domínio (espelhadas de AgendaTerapeutaPage) ──────────────
|
||
const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' });
|
||
const EVENTO_TIPOS_VALIDOS = new Set([EVENTO_TIPO.SESSAO, EVENTO_TIPO.BLOQUEIO]);
|
||
|
||
// Limite máximo de duração de sessão (regra de negócio Melissa). DB constraint
|
||
// `session_duration_min_chk` permite 10–240; convencionamos 120 (2h) aqui pra
|
||
// evitar slots gigantes acidentais. Futuro: ler de `agenda_configuracoes` se
|
||
// `max_session_duration_min` for adicionado.
|
||
const MAX_SESSION_MINUTES = 120;
|
||
|
||
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 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';
|
||
}
|
||
|
||
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',
|
||
'recurrence_id', 'recurrence_date',
|
||
'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 _addMinutesToTime(timeStr, minutes) {
|
||
const [h, m] = String(timeStr || '09:00').split(':').map(Number);
|
||
const total = h * 60 + m + Number(minutes || 0);
|
||
const hh = Math.floor(total / 60);
|
||
const mm = total % 60;
|
||
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00`;
|
||
}
|
||
|
||
// ─── Melissa-style normalize (color, label, startH/endH, dateKey, _raw) ─────
|
||
function pickColor(tipo, status, isOccurrence) {
|
||
const s = String(status || '').toLowerCase();
|
||
if (s === 'realizado' || s === 'realizada') return '#10b981';
|
||
if (s === 'faltou') return '#ef4444';
|
||
if (s === 'cancelado' || s === 'cancelada') return '#94a3b8';
|
||
|
||
const t = String(tipo || '').toLowerCase();
|
||
if (t === 'bloqueio') return '#64748b';
|
||
if (t === 'supervisao' || t === 'supervisão') return '#a855f7';
|
||
if (t === 'reuniao' || t === 'reunião') return '#0ea5e9';
|
||
return isOccurrence ? '#8b5cf6' : '#6366f1'; // virtual: violet, real: indigo
|
||
}
|
||
|
||
function isoToDecimalHour(iso) {
|
||
if (!iso) return 0;
|
||
const d = new Date(iso);
|
||
return d.getHours() + d.getMinutes() / 60;
|
||
}
|
||
|
||
function normalizeForMelissa(r) {
|
||
// r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand
|
||
// (ocorrência virtual com is_occurrence=true e id "rec::uuid::date").
|
||
const isOccurrence = !!r.is_occurrence;
|
||
const pacNome = r.paciente_nome ?? r.patient_name ?? r.patients?.nome_completo ?? '';
|
||
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO);
|
||
|
||
return {
|
||
id: r.id,
|
||
tipo,
|
||
status: r.status || (isOccurrence ? 'agendado' : ''),
|
||
titulo: r.titulo || r.titulo_custom || '',
|
||
patient_id: r.patient_id ?? r.paciente_id ?? null,
|
||
pacienteNome: pacNome,
|
||
modalidade: r.modalidade || '',
|
||
descricao: r.observacoes || '',
|
||
color: pickColor(tipo, r.status, isOccurrence),
|
||
label: pacNome || r.titulo || r.titulo_custom || (tipo === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : '—'),
|
||
inicio_em: r.inicio_em,
|
||
fim_em: r.fim_em,
|
||
startH: isoToDecimalHour(r.inicio_em),
|
||
endH: isoToDecimalHour(r.fim_em),
|
||
dateKey: String(r.inicio_em || '').slice(0, 10),
|
||
is_occurrence: isOccurrence,
|
||
recurrence_id: r.recurrence_id ?? null,
|
||
recurrence_date: r.recurrence_date ?? r.original_date ?? null,
|
||
_raw: r // ← consumido pelo MelissaLayout pra popular dialogEventRow
|
||
};
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// Composable principal
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// Símbolo de injeção pra MelissaAgenda recuperar o composable do MelissaLayout
|
||
// sem prop drilling. Pattern: MelissaLayout chama `provide(MELISSA_AGENDA_KEY, m)`
|
||
// e MelissaAgenda chama `inject(MELISSA_AGENDA_KEY)`.
|
||
export const MELISSA_AGENDA_KEY = Symbol('melissaAgenda');
|
||
|
||
export function useMelissaAgenda() {
|
||
const toast = useToast();
|
||
const confirm = useConfirm();
|
||
const tenantStore = useTenantStore();
|
||
|
||
// Refs do range visível — mutados pela MelissaAgenda no datesSet do FC.
|
||
// Início default: semana ao redor de hoje (FC sobrescreve no mount).
|
||
const viewStart = ref(new Date());
|
||
const viewEnd = ref(new Date());
|
||
{
|
||
const hoje = new Date();
|
||
const dow = hoje.getDay();
|
||
const diff = dow === 0 ? -6 : 1 - dow;
|
||
const segunda = new Date(hoje);
|
||
segunda.setDate(hoje.getDate() + diff);
|
||
segunda.setHours(0, 0, 0, 0);
|
||
const domingoNext = new Date(segunda);
|
||
domingoNext.setDate(segunda.getDate() + 7);
|
||
viewStart.value = segunda;
|
||
viewEnd.value = domingoNext;
|
||
}
|
||
|
||
const clinicTenantId = computed(
|
||
() => tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
|
||
);
|
||
|
||
// ── Settings + workRules ────────────────────────────────────
|
||
const { settings, workRules, load: loadSettings } = useAgendaSettings();
|
||
const ownerId = computed(() => settings.value?.owner_id || '');
|
||
|
||
// ── Eventos reais (CRUD) ────────────────────────────────────
|
||
const {
|
||
rows,
|
||
loading: eventsLoading,
|
||
error: eventsError,
|
||
loadMyRange,
|
||
create,
|
||
update,
|
||
remove
|
||
} = useAgendaEvents();
|
||
|
||
// ── Recorrência ─────────────────────────────────────────────
|
||
const {
|
||
loadAndExpand,
|
||
createRule,
|
||
updateRule,
|
||
cancelRule,
|
||
splitRuleAt,
|
||
cancelRuleFrom,
|
||
upsertException
|
||
} = useRecurrence();
|
||
const _occurrenceRows = ref([]);
|
||
|
||
// ── Compromissos determinados (catálogo) ────────────────────
|
||
const { rows: determinedCommitments, load: loadDeterminedCommitments } = useDeterminedCommitments(clinicTenantId);
|
||
|
||
// Adapta pro shape que o AgendaEventDialog espera (mesma lógica da
|
||
// AgendaTerapeutaPage — prioridade pra "Sessão" primeiro)
|
||
const commitmentOptions = computed(() => {
|
||
const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : [];
|
||
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))
|
||
: []
|
||
}));
|
||
});
|
||
|
||
// ── Feriados + commitment services ──────────────────────────
|
||
const { todos: feriados, fcEvents: feriadoFcEvents, load: loadFeriadosBase } = useFeriados();
|
||
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
|
||
|
||
// ── Linhas combinadas (real + virtual) ──────────────────────
|
||
const allRows = computed(() => [...(rows.value || []), ...(_occurrenceRows.value || [])]);
|
||
|
||
// ── Eventos Melissa-normalizados (consumidos pelo MelissaAgenda FC) ──
|
||
const eventos = computed(() => allRows.value.map(normalizeForMelissa));
|
||
|
||
// ── Eventos do FC original (se precisar — AgendaEventDialog quer
|
||
// `allEvents` no shape FC pra checar conflitos) ─────────
|
||
const allEventsForDialog = computed(() =>
|
||
allRows.value.map((r) => ({
|
||
id: r.id,
|
||
start: r.inicio_em,
|
||
end: r.fim_em,
|
||
extendedProps: { ...r }
|
||
}))
|
||
);
|
||
|
||
// ── Dialog state ────────────────────────────────────────────
|
||
const dialogOpen = ref(false);
|
||
const dialogEventRow = ref(null);
|
||
const dialogStartISO = ref('');
|
||
const dialogEndISO = ref('');
|
||
|
||
// Bloqueio dialog (modo: 'horario' | 'periodo' | 'dia' | 'feriados')
|
||
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 || 'horario';
|
||
bloqueioDialogOpen.value = true;
|
||
}
|
||
|
||
// ── Refetch (com merge real+virtual) ────────────────────────
|
||
async function _reloadRange() {
|
||
const s = viewStart.value;
|
||
const e = viewEnd.value;
|
||
if (!s || !e) return;
|
||
|
||
// Aguarda ownerId — settings é async
|
||
if (!ownerId.value) {
|
||
const unwatch = watch(ownerId, async (v) => {
|
||
if (!v) return;
|
||
unwatch();
|
||
await _reloadRange();
|
||
});
|
||
return;
|
||
}
|
||
|
||
const start = new Date(s);
|
||
const end = new Date(e);
|
||
const tid = clinicTenantId.value;
|
||
|
||
await loadMyRange(start.toISOString(), end.toISOString(), ownerId.value);
|
||
|
||
// Expande regras + merge com sessões reais
|
||
const merged = await loadAndExpand(ownerId.value, start, end, rows.value, tid);
|
||
_occurrenceRows.value = merged.filter((r) => r.is_occurrence);
|
||
}
|
||
|
||
async function refetch() {
|
||
await _reloadRange();
|
||
}
|
||
|
||
// ── Inicialização ───────────────────────────────────────────
|
||
onMounted(async () => {
|
||
await loadSettings();
|
||
await loadDeterminedCommitments();
|
||
const tid = clinicTenantId.value;
|
||
if (tid) await loadFeriadosBase(tid);
|
||
});
|
||
|
||
// Reload quando view muda OU quando settings/ownerId aparece
|
||
watch([viewStart, viewEnd], _reloadRange);
|
||
watch(ownerId, (v) => {
|
||
if (v) _reloadRange();
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// Handlers — populados na Stage 2
|
||
// ──────────────────────────────────────────────────────────
|
||
const _stageTwo = {}; // placeholder pra próxima passada
|
||
Object.assign(_stageTwo, _buildHandlers({
|
||
toast, confirm, supabase,
|
||
ownerId, clinicTenantId, settings,
|
||
rows, allRows, eventsError,
|
||
create, update, remove,
|
||
loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException,
|
||
saveRuleItems, propagateToSerie,
|
||
dialogOpen, dialogEventRow, dialogStartISO, dialogEndISO,
|
||
_reloadRange
|
||
}));
|
||
|
||
return {
|
||
// View range (mutado pela MelissaAgenda via FC.datesSet)
|
||
viewStart,
|
||
viewEnd,
|
||
|
||
// Eventos pro FC
|
||
eventos,
|
||
rawRows: allRows,
|
||
loading: eventsLoading,
|
||
refetch,
|
||
|
||
// Dialog state
|
||
dialogOpen,
|
||
dialogEventRow,
|
||
dialogStartISO,
|
||
dialogEndISO,
|
||
|
||
// Bloqueio dialog
|
||
bloqueioDialogOpen,
|
||
bloqueioMode,
|
||
openBloqueioDialog,
|
||
|
||
// Settings + commitments + feriados (props pro AgendaEventDialog)
|
||
settings,
|
||
workRules,
|
||
ownerId,
|
||
clinicTenantId,
|
||
commitmentOptions,
|
||
feriados,
|
||
feriadoFcEvents,
|
||
allEventsForDialog,
|
||
|
||
// Handlers
|
||
..._stageTwo
|
||
};
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// Handlers — extraídos numa função à parte só por organização (mantém o
|
||
// composable principal legível). Stage 2 preenche as funções abaixo.
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
function _buildHandlers(deps) {
|
||
// Só desempacota o que os handlers desta função usam diretamente —
|
||
// _buildOnDialogSave/_buildOnDialogDelete recebem `deps` completo.
|
||
const {
|
||
toast, confirm,
|
||
ownerId, clinicTenantId, settings,
|
||
allRows, eventsError,
|
||
create, update,
|
||
dialogOpen, dialogEventRow, dialogStartISO, dialogEndISO
|
||
} = deps;
|
||
|
||
// Helpers de formatação pra mensagens de confirm/toast (PT-BR estilo
|
||
// "16h" / "15:15h" / "27/04").
|
||
function _fmtH(d) {
|
||
if (!d) return '?';
|
||
const h = d.getHours();
|
||
const m = d.getMinutes();
|
||
return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`;
|
||
}
|
||
function _fmtD(d) {
|
||
if (!d) return '';
|
||
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||
}
|
||
// Escape HTML — usado quando a mensagem do confirm tem partes
|
||
// controladas pelo usuário (nome do paciente) e v-html no slot.
|
||
function _esc(s) {
|
||
return String(s ?? '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// ── onEditEvento — chamado pelo MelissaEventoPanel ao clicar "Editar" ──
|
||
// Recebe o `_raw` normalizado e popula o dialogEventRow no shape esperado
|
||
// pelo AgendaEventDialog (mesma estrutura de AgendaTerapeutaPage.onEventClick).
|
||
function onEditEvento(rawRow) {
|
||
if (!rawRow) return;
|
||
dialogEventRow.value = {
|
||
id: rawRow.is_occurrence ? null : rawRow.id || null,
|
||
owner_id: rawRow.owner_id ?? ownerId.value,
|
||
terapeuta_id: rawRow.terapeuta_id ?? null,
|
||
paciente_id: rawRow.patient_id ?? rawRow.paciente_id ?? null,
|
||
paciente_nome: rawRow.paciente_nome ?? rawRow.patient_name ?? rawRow.patients?.nome_completo ?? null,
|
||
paciente_avatar: rawRow.paciente_avatar ?? rawRow.patients?.avatar_url ?? null,
|
||
paciente_status: rawRow.paciente_status ?? rawRow.patients?.status ?? null,
|
||
tipo: rawRow.tipo || 'sessao',
|
||
status: rawRow.status,
|
||
titulo: rawRow.titulo,
|
||
observacoes: rawRow.observacoes ?? null,
|
||
visibility_scope: rawRow.visibility_scope ?? 'public',
|
||
inicio_em: rawRow.inicio_em,
|
||
fim_em: rawRow.fim_em,
|
||
modalidade: rawRow.modalidade ?? null,
|
||
determined_commitment_id: rawRow.determined_commitment_id ?? null,
|
||
titulo_custom: rawRow.titulo_custom ?? null,
|
||
extra_fields: rawRow.extra_fields ?? null,
|
||
price: rawRow.price != null ? Number(rawRow.price) : null,
|
||
insurance_plan_id: rawRow.insurance_plan_id ?? null,
|
||
insurance_guide_number: rawRow.insurance_guide_number ?? null,
|
||
insurance_value: rawRow.insurance_value != null ? Number(rawRow.insurance_value) : null,
|
||
insurance_plan_service_id: rawRow.insurance_plan_service_id ?? null,
|
||
// recorrência
|
||
recurrence_id: rawRow.recurrence_id ?? null,
|
||
original_date: rawRow.original_date ?? rawRow.recurrence_date ?? null,
|
||
is_occurrence: !!rawRow.is_occurrence,
|
||
exception_type: rawRow.exception_type ?? rawRow.exceptionType ?? null,
|
||
// legado
|
||
serie_id: rawRow.serie_id ?? rawRow.recurrence_id ?? null,
|
||
serie_dia_semana: rawRow.serie_dia_semana ?? null,
|
||
serie_hora: rawRow.serie_hora ?? null
|
||
};
|
||
dialogStartISO.value = '';
|
||
dialogEndISO.value = '';
|
||
dialogOpen.value = true;
|
||
}
|
||
|
||
// ── onCreateEvento — botão "+ Agendar" sem seleção no FC ──
|
||
// Espelha AgendaTerapeutaPage.onCreateFromButton:1423 — usa o dia
|
||
// visualizado + hora atual como defaults razoáveis.
|
||
function onCreateEvento() {
|
||
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 base = new Date();
|
||
// Arredonda pra próximo slot de 15min — UX mais limpa que minuto solto.
|
||
base.setSeconds(0, 0);
|
||
const remainder = base.getMinutes() % 15;
|
||
if (remainder !== 0) {
|
||
base.setMinutes(base.getMinutes() + (15 - remainder));
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// ── onSelectTime — click-drag no FC pra criar evento novo ──
|
||
// Dinâmica de duração:
|
||
// click sem drag → settings.session_duration_min (default 50)
|
||
// drag ≤ default → default (mínimo da configuração)
|
||
// drag entre default e MAX_SESSION_MINUTES → respeita o drag (snap em
|
||
// 15min já feito pelo FC via snapDuration)
|
||
// drag > MAX → capa em MAX (regra de negócio Melissa: 2h
|
||
// é o slot máximo permitido)
|
||
function onSelectTime(selection) {
|
||
const defaultDur =
|
||
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
|
||
? (selection.end instanceof Date ? selection.end : new Date(selection.end))
|
||
: null;
|
||
const dragMin = rawEnd ? Math.round((rawEnd.getTime() - rawStart.getTime()) / 60000) : 0;
|
||
|
||
const durMin =
|
||
dragMin <= defaultDur ? defaultDur
|
||
: dragMin <= MAX_SESSION_MINUTES ? dragMin
|
||
: MAX_SESSION_MINUTES;
|
||
|
||
const startISO = rawStart.toISOString();
|
||
const endISO = new Date(rawStart.getTime() + durMin * 60000).toISOString();
|
||
|
||
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;
|
||
}
|
||
|
||
// ── persistMoveOrResize — drag/resize do FC ──
|
||
// Fluxo:
|
||
// 1. Bail se for ocorrência virtual (sem id real)
|
||
// 2. Conflict-check ANTES de confirmar (revert+toast imediato evita
|
||
// sequência ruim de "confirma? > ah não, conflito")
|
||
// 3. Confirm dialog descrevendo a alteração ("trocando Leonardo de
|
||
// 16h para 15:15h, confirma?")
|
||
// 4. Se aceitar, UPDATE + toast success. Se recusar, revert.
|
||
async function persistMoveOrResize(info, actionLabel) {
|
||
try {
|
||
const ev = info?.event;
|
||
if (!ev) return;
|
||
const id = ev.id;
|
||
|
||
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;
|
||
|
||
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;
|
||
}
|
||
|
||
// Confirm: monta mensagem descrevendo o que mudou. Usa HTML
|
||
// (renderizado via v-html no slot #message do ConfirmDialog em
|
||
// MelissaLayout) pra destacar datas/horas em <strong>. Subject
|
||
// é escapado pra evitar XSS via nome de paciente.
|
||
const oldStart = info.oldEvent?.start || null;
|
||
const oldEnd = info.oldEvent?.end || null;
|
||
const ext = ev.extendedProps || {};
|
||
const subject = ext.pacienteNome || ext.label || ext.titulo || 'evento';
|
||
const subjectEsc = _esc(subject);
|
||
|
||
const startMoved = oldStart && start.getTime() !== oldStart.getTime();
|
||
const endMoved = oldEnd && end.getTime() !== oldEnd.getTime();
|
||
|
||
let message;
|
||
if (startMoved) {
|
||
const sameDay = oldStart.toDateString() === start.toDateString();
|
||
if (sameDay) {
|
||
// Mudou só hora (mesmo dia)
|
||
message =
|
||
`Está trocando o horário de ${subjectEsc} de ` +
|
||
`<strong>${_fmtH(oldStart)}</strong> para ` +
|
||
`<strong>${_fmtH(start)}</strong>. Confirma?`;
|
||
} else {
|
||
// Mudou de dia (com ou sem mudança de hora) — destaca
|
||
// data E hora em ambos os lados
|
||
message =
|
||
`Está movendo ${subjectEsc} de ` +
|
||
`<strong>${_fmtD(oldStart)} ${_fmtH(oldStart)}</strong> para ` +
|
||
`<strong>${_fmtD(start)} ${_fmtH(start)}</strong>. Confirma?`;
|
||
}
|
||
} else if (endMoved) {
|
||
// Só resize — mostra duração antiga → nova
|
||
const oldDur = Math.round((oldEnd.getTime() - oldStart.getTime()) / 60000);
|
||
const newDur = Math.round((end.getTime() - start.getTime()) / 60000);
|
||
message =
|
||
`Está alterando a duração de ${subjectEsc} de ` +
|
||
`<strong>${oldDur}min</strong> para ` +
|
||
`<strong>${newDur}min</strong>. Confirma?`;
|
||
} else {
|
||
message = `Confirmar alteração de ${subjectEsc}?`;
|
||
}
|
||
|
||
const accepted = await new Promise((resolve) => {
|
||
confirm.require({
|
||
header: actionLabel,
|
||
message,
|
||
icon: 'pi pi-clock',
|
||
acceptLabel: 'Confirmar',
|
||
rejectLabel: 'Cancelar',
|
||
acceptClass: 'p-button-primary',
|
||
rejectClass: 'p-button-text',
|
||
accept: () => resolve(true),
|
||
reject: () => resolve(false),
|
||
onHide: () => resolve(false) // fechar via Esc/clickoutside conta como cancelar
|
||
});
|
||
});
|
||
|
||
if (!accepted) {
|
||
info?.revert?.();
|
||
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 });
|
||
}
|
||
}
|
||
|
||
// ── onEditSeriesOccurrence — preview ao alternar editScope no dialog ──
|
||
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
|
||
};
|
||
}
|
||
|
||
// ── onUpdateSeriesEvent — mudança de status numa ocorrência ──
|
||
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) {
|
||
try {
|
||
if (id) {
|
||
await update(id, { status });
|
||
return;
|
||
}
|
||
if (!is_virtual || !inicio_em) return;
|
||
|
||
const rid = dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
|
||
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
||
|
||
const { data: existing } = await supabase
|
||
.from('agenda_eventos')
|
||
.select('id')
|
||
.eq('recurrence_id', rid)
|
||
.eq('recurrence_date', rDate)
|
||
.maybeSingle();
|
||
|
||
if (existing?.id) {
|
||
await update(existing.id, { status });
|
||
} else {
|
||
const row = dialogEventRow.value || {};
|
||
await create({
|
||
owner_id: ownerId.value,
|
||
tenant_id: clinicTenantId.value,
|
||
recurrence_id: rid,
|
||
recurrence_date: rDate,
|
||
tipo: 'sessao',
|
||
status,
|
||
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
|
||
});
|
||
}
|
||
} catch (e) {
|
||
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
|
||
}
|
||
}
|
||
|
||
// ── onDialogSave / onDialogDelete ── populados na Stage 2 ──
|
||
const onDialogSave = _buildOnDialogSave(deps);
|
||
const onDialogDelete = _buildOnDialogDelete(deps);
|
||
|
||
return {
|
||
onEditEvento,
|
||
onCreateEvento,
|
||
onSelectTime,
|
||
persistMoveOrResize,
|
||
onEditSeriesOccurrence,
|
||
onUpdateSeriesEvent,
|
||
onDialogSave,
|
||
onDialogDelete
|
||
};
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// onDialogSave — porte direto de AgendaTerapeutaPage.onDialogSave (linhas
|
||
// 1714-2116). Cobre 7 cases: criação avulsa, sessão única (A/B), série nova
|
||
// (C), edição "somente esta" (D), "este e seguintes" (E), "todos" (F),
|
||
// "todos sem exceção" (G), além de tratamento explícito de exclusion
|
||
// constraint (conflict de horário).
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
function _buildOnDialogSave(deps) {
|
||
const {
|
||
toast, confirm,
|
||
ownerId, clinicTenantId, settings,
|
||
eventsError,
|
||
create, update,
|
||
createRule, updateRule, splitRuleAt, upsertException,
|
||
saveRuleItems, propagateToSerie,
|
||
dialogOpen, dialogEventRow,
|
||
_reloadRange
|
||
} = deps;
|
||
|
||
return 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;
|
||
const recurrenceId = arg?.recurrence_id ?? arg?.serie_id ?? null;
|
||
const originalDate = arg?.original_date ?? dialogEventRow.value?.original_date ?? null;
|
||
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 ───────────────────────────────
|
||
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);
|
||
|
||
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);
|
||
|
||
if (id && createdRule?.id) {
|
||
await update(id, { recurrence_id: createdRule.id, recurrence_date: firstRecISO });
|
||
}
|
||
|
||
if (recorrencia?.conflitos?.length && createdRule?.id) {
|
||
const exceptions = recorrencia.conflitos.map((c) => ({
|
||
recurrence_id: createdRule.id,
|
||
tenant_id: clinicId,
|
||
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 supabase.from('recurrence_exceptions').insert(exceptions);
|
||
if (exErr) console.warn('[useMelissaAgenda] exceptions insert', exErr);
|
||
}
|
||
|
||
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();
|
||
|
||
if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) {
|
||
await _offerBillingContract({ normalized, recorrencia, tenantId: clinicId, confirm, toast });
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ── CASO D: edição "somente_este" ─────────────────────────────────
|
||
if (recurrenceId && editMode === 'somente_este') {
|
||
let eventId = id ?? null;
|
||
|
||
if (id) {
|
||
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) {
|
||
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 supabase
|
||
.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;
|
||
}
|
||
}
|
||
}
|
||
|
||
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_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
|
||
});
|
||
|
||
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
|
||
});
|
||
|
||
await supabase
|
||
.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);
|
||
|
||
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_excecao" ────────────────────────────
|
||
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
|
||
});
|
||
|
||
await supabase
|
||
.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);
|
||
|
||
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 supabase
|
||
.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 });
|
||
}
|
||
};
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// onDialogDelete — porte direto de AgendaTerapeutaPage:2118-2204
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
function _buildOnDialogDelete(deps) {
|
||
const {
|
||
toast,
|
||
ownerId, clinicTenantId,
|
||
create, update, remove,
|
||
cancelRule, cancelRuleFrom, upsertException,
|
||
dialogOpen, dialogEventRow,
|
||
eventsError,
|
||
_reloadRange
|
||
} = deps;
|
||
|
||
return 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) {
|
||
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) {
|
||
const rDate = row.original_date || row.inicio_em?.slice(0, 10);
|
||
const existing = await supabase
|
||
.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) {
|
||
await update(id, { recurrence_id: null, recurrence_date: null });
|
||
}
|
||
|
||
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 });
|
||
}
|
||
};
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// Helper: oferece geração de billing_contract após criar série recorrente
|
||
// com serviços. Chamado APÓS fechar o dialog principal.
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
async function _offerBillingContract({ normalized, recorrencia, tenantId, confirm, toast }) {
|
||
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 supabase.from('billing_contracts').insert({
|
||
owner_id: normalized.owner_id,
|
||
tenant_id: tenantId,
|
||
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()
|
||
});
|
||
});
|
||
}
|