diff --git a/src/features/agenda/utils/colors.js b/src/features/agenda/utils/colors.js new file mode 100644 index 0000000..6cc4fad --- /dev/null +++ b/src/features/agenda/utils/colors.js @@ -0,0 +1,28 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI — paleta de cores da agenda +|-------------------------------------------------------------------------- +| Mapping (tipo, status, isOccurrence) → hex color. Usado pelo card do +| FullCalendar (borderColor/backgroundColor) e popovers de evento. +| +| Status manda mais do que tipo: realizado/faltou/cancelado têm cores +| dedicadas (emerald/red/slate) independentes do tipo. +| +| Extraído de useMelissaAgenda.js (Fase A) pra reuso em Rail/Clínica. +|-------------------------------------------------------------------------- +*/ + +export function pickColor(tipo, status, isOccurrence) { + const s = String(status || '').toLowerCase(); + if (s === 'realizado' || s === 'realizada') return '#10b981'; // emerald-500 + if (s === 'faltou') return '#ef4444'; // red-500 + if (s === 'cancelado' || s === 'cancelada') return '#94a3b8'; // slate-400 + + const t = String(tipo || '').toLowerCase(); + if (t === 'bloqueio') return '#64748b'; // slate-500 + if (t === 'supervisao' || t === 'supervisão') return '#a855f7'; // purple-500 + if (t === 'reuniao' || t === 'reunião') return '#0ea5e9'; // sky-500 + + // Sessão default — distingue virtual (violet-500) vs real (indigo-500) + return isOccurrence ? '#8b5cf6' : '#6366f1'; +} diff --git a/src/features/agenda/utils/dbFields.js b/src/features/agenda/utils/dbFields.js new file mode 100644 index 0000000..b09f1ea --- /dev/null +++ b/src/features/agenda/utils/dbFields.js @@ -0,0 +1,34 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI — whitelist de campos do agenda_eventos +|-------------------------------------------------------------------------- +| Whitelist canônica de campos aceitos na tabela agenda_eventos pra INSERT/ +| UPDATE via cliente. Filtra qualquer chave não-prevista (defesa contra bug +| onde payload acidentalmente carrega field defaultado pelo banco — como +| modalidade='presencial' do bug de 2026-05-16). +| +| Memoria: project_pickdbfields_whitelist.md — antes era inline em +| useMelissaAgenda.js. Extraído na Fase A. +|-------------------------------------------------------------------------- +*/ + +const ALLOWED_FIELDS = [ + 'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id', + 'tipo', 'status', 'titulo', 'observacoes', 'modalidade', + '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' +]; + +export function pickDbFields(obj) { + const out = {}; + for (const k of ALLOWED_FIELDS) { + if (obj[k] !== undefined) out[k] = obj[k]; + } + return out; +} + +export { ALLOWED_FIELDS }; diff --git a/src/features/agenda/utils/eventoTipo.js b/src/features/agenda/utils/eventoTipo.js new file mode 100644 index 0000000..143b0c1 --- /dev/null +++ b/src/features/agenda/utils/eventoTipo.js @@ -0,0 +1,37 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI — utils de tipo de evento (agenda) +|-------------------------------------------------------------------------- +| Helpers puros pra classificar/normalizar tipo de evento. Extraídos de +| useMelissaAgenda.js (Fase A da decomposição agenda) pra reuso em +| Rail/Clínica + utility puro testável. +|-------------------------------------------------------------------------- +*/ + +export const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' }); +export 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. +export const MAX_SESSION_MINUTES = 120; + +export 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; +} + +export 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; +} + +export function deriveTituloDefaultByTipo(tipo) { + return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão'; +} diff --git a/src/features/agenda/utils/timeHelpers.js b/src/features/agenda/utils/timeHelpers.js new file mode 100644 index 0000000..07a829b --- /dev/null +++ b/src/features/agenda/utils/timeHelpers.js @@ -0,0 +1,46 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI — utils de tempo/data (agenda) +|-------------------------------------------------------------------------- +| Helpers puros pra manipulação de tempo na agenda. Extraídos de +| useMelissaAgenda.js (Fase A) pra reuso em Rail/Clínica. +|-------------------------------------------------------------------------- +*/ + +const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isUuid(v) { + return UUID_RX.test(String(v || '')); +} + +/** + * Soma minutos a um time "HH:MM" e retorna "HH:MM:SS". + * Tolerante a input vazio (default 09:00). + */ +export 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`; +} + +/** + * ISO timestamp → hora decimal (ex: "2026-05-21T14:30:00Z" → 14.5). + * Usa hora local (não UTC) — propósito de exibição no calendário. + */ +export function isoToDecimalHour(iso) { + if (!iso) return 0; + const d = new Date(iso); + return d.getHours() + d.getMinutes() / 60; +} + +/** + * Date object → "YYYY-MM-DD" (formato ISO date sem hora). + */ +export function dateToISO(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${dd}`; +} diff --git a/src/layout/melissa/composables/useMelissaAgenda.js b/src/layout/melissa/composables/useMelissaAgenda.js index 8961a72..39bc372 100644 --- a/src/layout/melissa/composables/useMelissaAgenda.js +++ b/src/layout/melissa/composables/useMelissaAgenda.js @@ -38,84 +38,19 @@ import { useCommitmentServices } from '@/features/agenda/composables/useCommitme import { useFeriados } from '@/composables/useFeriados'; import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios'; -// ─── 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', 'modalidade', - '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; -} +// ─── Utilities puros (extraídos na Fase A da decomposição agenda) ─────────── +// Mantidos em features/agenda/utils/ pra reuso em Rail/Clínica. +import { + EVENTO_TIPO, + EVENTO_TIPOS_VALIDOS, + MAX_SESSION_MINUTES, + normalizeEventoTipo, + deriveEventoTipoForNewEvent, + deriveTituloDefaultByTipo +} from '@/features/agenda/utils/eventoTipo'; +import { pickDbFields } from '@/features/agenda/utils/dbFields'; +import { isUuid, addMinutesToTime as _addMinutesToTime, isoToDecimalHour, dateToISO as _dateToISO } from '@/features/agenda/utils/timeHelpers'; +import { pickColor } from '@/features/agenda/utils/colors'; function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) { // r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand @@ -2854,10 +2789,4 @@ function _generateOccurrenceDates(rule, max, exceptionDates) { } return dates; } - -function _dateToISO(d) { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${y}-${m}-${day}`; -} +// _dateToISO foi extraído pra @/features/agenda/utils/timeHelpers — import no topo.