agenda Fase A: extrai utils puros pra features/agenda/utils

Decomposicao da agenda em prep pra replicar Rail/Clinica.

4 arquivos novos em src/features/agenda/utils/:
- eventoTipo.js  -> EVENTO_TIPO + normalize/derive + MAX_SESSION_MINUTES
- dbFields.js    -> pickDbFields whitelist (memoria pickdbfields_whitelist)
- timeHelpers.js -> isUuid + addMinutesToTime + isoToDecimalHour + dateToISO
- colors.js      -> pickColor (status+tipo+isOccurrence)

useMelissaAgenda.js (2863L -> 2792L): removeu definicoes locais
(83 linhas), passou a importar dos utils. Aliases _addMinutesToTime
e _dateToISO mantidos no escopo via import "as" pra nao mexer
em 70+ callsites internos.

Fase A = baseline zero-comportamental pra Rail/Clinica adotarem
os mesmos helpers. Fase B (service de billing — applyStatusDecisions,
createPackageContract, materializeAndCharge) vem em seguida.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 09:21:12 -03:00
parent b7f3c23ad6
commit ee117eafe6
5 changed files with 159 additions and 85 deletions
+28
View File
@@ -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';
}
+34
View File
@@ -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 };
+37
View File
@@ -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 10240; 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';
}
+46
View File
@@ -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}`;
}
@@ -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 10240; 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.