Control Kinésico

CyJ Kinesiología

Acceso profesional protegido por Supabase Auth

CyJ Kinesiología · Panel clínico privado

`); printWindow.document.close(); } function renderPatientChart(records) { const labels = records.map( record => `Sesión ${record.sesion_num || ''}` ); const values = records.map( record => Number( record.eva_fin ) || 0 ); const ctx = $('evaChartCanvas') .getContext('2d'); if (evaChartInstance) { evaChartInstance.destroy(); } evaChartInstance = new Chart( ctx, { type: 'line', data: { labels: labels.length ? labels : ['Sin datos'], datasets: [ { label: 'Dolor EVA Final (0-10)', data: values.length ? values : [0], borderColor: '#0d9488', backgroundColor: 'rgba(13,148,136,.12)', fill: true, tension: .3, pointRadius: 5 } ] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { min: 0, max: 10, ticks: { stepSize: 1 } } } } } ); } function renderPatientHistory(records) { const container = $('patientHistoryContainer'); if (!records.length) { container.innerHTML = '

No hay atenciones registradas.

'; return; } container.innerHTML = records .slice() .reverse() .map( record => `

Sesión #${escapeHtml(record.sesion_num || '')}

${escapeHtml( record.created_at ? new Date(record.created_at).toLocaleDateString('es-CL') : '' )}

EVA

${escapeHtml(record.eva_inicio)} → ${escapeHtml(record.eva_fin)}

${ record.zona ? `

Zona: ${escapeHtml(record.zona)}

` : '' } ${ record.notas ? `

${escapeHtml(record.notas)}

` : '' }
` ) .join(''); } /* ========================================================= EVA ========================================================== */ function buildEvaButtons() { buildEvaGroup( 'evaInicioButtons', 'attEvaInicio' ); buildEvaGroup( 'evaFinButtons', 'attEvaFin' ); } function buildEvaGroup(containerId, inputId) { const container = $(containerId); container.innerHTML = ''; for ( let value = 0; value <= 10; value++ ) { const button = document.createElement( 'button' ); button.type = 'button'; button.textContent = value; button.className = 'eva-button h-10 rounded-xl border border-slate-200 bg-white text-xs font-bold text-slate-700 hover:border-teal-400 hover:text-teal-700'; button.addEventListener( 'click', () => { $(inputId).value = String(value); container .querySelectorAll( '.eva-button' ) .forEach( item => { item.classList.remove( 'active', 'bg-teal-600', 'text-white' ); } ); button.classList.add( 'active', 'bg-teal-600', 'text-white' ); } ); container.appendChild( button ); } } /* ========================================================= HISTÓRICO Y GRÁFICO MENSUAL ========================================================== */ async function openAttentionHistoryModal() { await loadAttentions(); const container = $('attentionHistoryContainer'); if (!allAttentions.length) { container.innerHTML = `

No existen atenciones registradas.

`; openModal( 'attentionHistoryModal' ); return; } const sorted = allAttentions .slice() .sort( (a, b) => String( b.created_at || '' ).localeCompare( String( a.created_at || '' ) ) ); container.innerHTML = sorted .map( attention => { const patient = allPatients.find( p => cleanRut(p.rut) === cleanRut( attention.rut_paciente ) ); return `

${escapeHtml( patient?.nombre || attention.rut_paciente )}

${escapeHtml( attention.rut_paciente )}

Sesión #${escapeHtml( attention.sesion_num || '' )}

${escapeHtml( attention.created_at ? new Date( attention.created_at ).toLocaleString('es-CL') : '' )}

ZONA

${escapeHtml( attention.zona || '-' )}

EVA INICIO

${escapeHtml( attention.eva_inicio ?? '-' )}

EVA FINAL

${escapeHtml( attention.eva_fin ?? '-' )}

EVOLUCIÓN

${ Number(attention.eva_inicio) - Number(attention.eva_fin) }

${ attention.notas ? `

${escapeHtml(attention.notas)}

` : '' }
`; } ) .join(''); openModal( 'attentionHistoryModal' ); } function updateMonthAttentionStat() { const now = new Date(); const year = now.getFullYear(); const month = String( now.getMonth() + 1 ) .padStart( 2, '0' ); const prefix = `${year}-${month}`; const total = allAttentions.filter( attention => { const date = attention.fecha || String( attention.created_at || '' ).slice(0, 10); return date.startsWith( prefix ); } ).length; $('statMonthAttentions').textContent = total; } function openMonthChartModal() { const now = new Date(); const year = now.getFullYear(); const month = now.getMonth(); const days = new Date( year, month + 1, 0 ).getDate(); const labels = []; const values = []; for ( let day = 1; day <= days; day++ ) { const date = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; labels.push( String(day) ); values.push( allAttentions.filter( attention => { const attentionDate = attention.fecha || String( attention.created_at || '' ).slice(0, 10); return ( attentionDate === date ); } ).length ); } $('monthChartSubtitle').textContent = new Intl.DateTimeFormat( 'es-CL', { month: 'long', year: 'numeric' } ).format(now); const ctx = $('monthChartCanvas') .getContext('2d'); if (monthChartInstance) { monthChartInstance.destroy(); } monthChartInstance = new Chart( ctx, { type: 'bar', data: { labels, datasets: [ { label: 'Atenciones', data: values, borderWidth: 1 } ] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } } } ); openModal( 'monthChartModal' ); } /* ========================================================= EJERCICIOS ========================================================== */ async function loadExercises() { if (!supabaseClient) { return; } const { data, error } = await supabaseClient .from('ejercicios') .select('*') .order( 'nombre', { ascending: true } ); if (error) { showToast( 'Error cargando ejercicios: ' + error.message, 'error' ); return; } allExercises = data || []; renderExercises( allExercises ); } function filterExercises() { const query = $('exerciseSearch') .value .trim() .toLowerCase(); const filtered = allExercises.filter( exercise => String( exercise.nombre || '' ).toLowerCase() .includes(query) || String( exercise.zona || '' ).toLowerCase() .includes(query) ); renderExercises( filtered ); } function renderExercises(exercises) { const grid = $('exerciseGrid'); if (!exercises.length) { grid.innerHTML = `
No existen ejercicios en el catálogo.
`; return; } grid.innerHTML = exercises.map( exercise => `

${escapeHtml(exercise.nombre)}

${escapeHtml( exercise.zona || 'Zona no especificada' )}

SERIES

${escapeHtml( exercise.series || '-' )}

REPS

${escapeHtml( exercise.repeticiones || '-' )}

TIEMPO

${escapeHtml( exercise.tiempo_sosten || '-' )}

${ exercise.instrucciones ? `

${escapeHtml( exercise.instrucciones )}

` : '' }
${ exercise.video_url ? ` Video ` : '' } ${ exercise.recurso_url ? ` Recurso ` : '' }
` ).join(''); } function openExerciseModal() { resetExerciseForm(); openModal( 'exerciseModal' ); } function closeExerciseModal() { closeModal( 'exerciseModal' ); } function resetExerciseForm() { [ 'exerciseName', 'exerciseZone', 'exerciseSeries', 'exerciseReps', 'exerciseHold', 'exerciseVideo', 'exerciseResource', 'exerciseInstructions' ].forEach( id => { $(id).value = ''; } ); } async function saveExercise(event) { event.preventDefault(); const nombre = $('exerciseName') .value .trim(); const zona = $('exerciseZone') .value .trim(); const series = $('exerciseSeries') .value .trim(); const repeticiones = $('exerciseReps') .value .trim(); const tiempo_sosten = $('exerciseHold') .value .trim(); const video_url = $('exerciseVideo') .value .trim(); const recurso_url = $('exerciseResource') .value .trim(); const instrucciones = $('exerciseInstructions') .value .trim(); if (!nombre) { showToast( 'Ingresa el nombre del ejercicio.', 'warning' ); return; } setLoading( 'saveExerciseButton', true ); try { const payload = { nombre, zona, series: series ? Number(series) : null, repeticiones, tiempo_sosten, video_url, recurso_url, instrucciones, activo: true }; const { error } = await supabaseClient .from('ejercicios') .insert([ payload ]); if (error) { throw error; } showToast( 'Ejercicio agregado al catálogo.' ); closeExerciseModal(); resetExerciseForm(); await loadExercises(); } catch (error) { showToast( 'No se pudo guardar el ejercicio: ' + error.message, 'error' ); } finally { setLoading( 'saveExerciseButton', false ); } } function addExerciseToRoutine(exerciseId) { const exercise = allExercises.find( item => String(item.id) === String(exerciseId) ); if (!exercise) { return; } const series = exercise.series || 3; let prescription = ''; if (exercise.repeticiones) { prescription = `${exercise.repeticiones} repeticiones`; } else if ( exercise.tiempo_sosten ) { prescription = exercise.tiempo_sosten; } else { prescription = 'según indicación'; } const line = `${exercise.nombre}: ${series} series x ${prescription}`; const textarea = $('pautaText'); textarea.value = textarea.value.trim() ? textarea.value.trim() + '\n' + line : line; showToast( 'Ejercicio agregado a la pauta.' ); textarea.focus(); } function sendPautaWhatsApp() { const phone = $('pautaPhone') .value .replace(/\D/g, ''); const text = $('pautaText') .value .trim(); if (!phone) { showToast( 'Ingresa el teléfono del paciente.', 'warning' ); return; } if (!text) { showToast( 'La pauta está vacía.', 'warning' ); return; } const message = `🏥 PAUTA DE EJERCICIOS - CYJ KINESIOLOGÍA ${text} Klgo. Cristian Barrera`; window.open( `https://wa.me/${phone}?text=${encodeURIComponent(message)}`, '_blank', 'noopener' ); } /* ========================================================= AGENDA ========================================================== */ function normalizeTime(value) { if ( value === null || value === undefined ) { return null; } let time = String(value).trim(); if (!time) { return null; } if ( time.includes('T') ) { time = time.split('T').pop(); } time = time.replace( /\s+/g, '' ); const match = time.match( /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/ ); if (!match) { return null; } const hour = Number(match[1]); const minute = Number(match[2]); const second = Number(match[3] || 0); if ( hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 ) { return null; } return ( `${String(hour).padStart(2, '0')}:` + `${String(minute).padStart(2, '0')}:` + `${String(second).padStart(2, '0')}` ); } function calculateEndTime(startTime, durationMinutes) { const normalized = normalizeTime(startTime); if (!normalized) { return null; } const parts = normalized .split(':') .map(Number); const totalSeconds = ( parts[0] * 3600 + parts[1] * 60 + parts[2] ) + ( Number(durationMinutes || 60) * 60 ); const secondsInDay = 24 * 60 * 60; const normalizedSeconds = ( totalSeconds % secondsInDay ) + ( totalSeconds < 0 ? secondsInDay : 0 ); const hour = Math.floor( normalizedSeconds / 3600 ); const minute = Math.floor( ( normalizedSeconds % 3600 ) / 60 ); const second = normalizedSeconds % 60; return ( `${String(hour).padStart(2, '0')}:` + `${String(minute).padStart(2, '0')}:` + `${String(second).padStart(2, '0')}` ); } function parseAppointmentDates(fecha, hora, durationMinutes = 60) { if (!fecha) { throw new Error( 'La cita no tiene fecha válida.' ); } const dateMatch = String(fecha) .trim() .match( /^(\d{4})-(\d{2})-(\d{2})$/ ); if (!dateMatch) { throw new Error( 'La fecha de la cita no tiene formato YYYY-MM-DD válido.' ); } const year = Number(dateMatch[1]); const month = Number(dateMatch[2]); const day = Number(dateMatch[3]); const normalizedTime = normalizeTime(hora); if (!normalizedTime) { throw new Error( 'La hora de la cita no tiene formato HH:mm o HH:mm:ss válido.' ); } const [ hour, minute, second ] = normalizedTime .split(':') .map(Number); const start = new Date( year, month - 1, day, hour, minute, second, 0 ); if ( Number.isNaN( start.getTime() ) ) { throw new Error( 'La fecha/hora de inicio es inválida.' ); } if ( start.getFullYear() !== year || start.getMonth() !== month - 1 || start.getDate() !== day || start.getHours() !== hour || start.getMinutes() !== minute ) { throw new Error( 'La fecha/hora de la cita no es válida.' ); } const end = new Date( start.getTime() + Number(durationMinutes || 60) * 60000 ); if ( Number.isNaN( end.getTime() ) ) { throw new Error( 'La fecha/hora de término es inválida.' ); } return { start, end }; } function buildEventFromAppointment(appointment) { const patient = allPatients.find( p => cleanRut(p.rut) === cleanRut( appointment.rut_paciente ) ); const hora = appointment.hora_inicio || appointment.hora; const duration = Number( appointment.duracion_minutos || $('appointmentDuration')?.value || 60 ); const dates = parseAppointmentDates( appointment.fecha, hora, duration ); return { id: appointment.id, title: `Atención kinésica - ${ patient?.nombre || appointment.rut_paciente }`, description: `Cita de kinesiología con el Klgo. Cristian Barrera para ${ patient?.nombre || appointment.rut_paciente }.`, location: 'CyJ Kinesiología', start: dates.start, end: dates.end, phone: patient?.telefono || '', patientName: patient?.nombre || appointment.rut_paciente, rut: appointment.rut_paciente }; } async function saveAppointment(event) { event.preventDefault(); const rut = $('appointmentRut') .value .trim(); if (!rut) { showToast( 'Selecciona un paciente.', 'warning' ); return; } const fecha = $('appointmentDate') .value; const hora = $('appointmentTime') .value; const duration = Number( $('appointmentDuration') .value || 60 ); const estado = $('appointmentStatus') .value; if (!fecha || !hora) { showToast( 'Completa fecha y hora.', 'warning' ); return; } if (fecha < todayISO()) { showToast( 'La fecha de la cita no puede ser anterior a hoy.', 'warning' ); return; } try { parseAppointmentDates( fecha, hora, duration ); } catch (error) { showToast( error.message, 'warning' ); return; } const horaInicio = normalizeTime( hora ); const horaFin = calculateEndTime( hora, duration ); if (!horaInicio || !horaFin) { showToast( 'No fue posible calcular el horario de la cita.', 'error' ); return; } setLoading( 'saveAppointmentButton', true ); try { const payload = { rut_paciente: rut, fecha, hora: horaInicio, hora_inicio: horaInicio, hora_fin: horaFin, estado }; let insertResult = await supabaseClient .from('citas') .insert([ payload ]) .select() .single(); if ( insertResult.error && /hora_fin/i.test( insertResult.error.message || '' ) ) { const compatibilityPayload = { rut_paciente: rut, fecha, hora: horaInicio, hora_inicio: horaInicio, estado }; insertResult = await supabaseClient .from('citas') .insert([ compatibilityPayload ]) .select() .single(); } if (insertResult.error) { throw insertResult.error; } const appointment = insertResult.data; lastCreatedEvent = buildEventFromAppointment( { ...appointment, hora_inicio: appointment.hora_inicio || horaInicio, hora: appointment.hora || horaInicio, duracion_minutos: duration } ); showToast( 'Cita guardada correctamente.' ); resetAppointmentForm(); await loadAppointments(); updateMonthAttentionStat(); openEventModal( lastCreatedEvent ); } catch (error) { showToast( 'No se pudo guardar la cita: ' + error.message, 'error' ); } finally { setLoading( 'saveAppointmentButton', false ); } } function resetAppointmentForm() { $('appointmentPatientSearch').value = ''; $('appointmentRut').value = ''; $('appointmentDate').value = todayISO(); $('appointmentTime').value = ''; $('appointmentDuration').value = '60'; $('appointmentStatus').value = 'agendada'; $('appointmentPatientBadge') .classList.add('hidden'); $('appointmentPatientSuggestions') .classList.add('hidden'); } function validateAppointmentDate() { const date = $('appointmentDate') .value; if ( date && date < todayISO() ) { $('appointmentDate').value = todayISO(); showToast( 'No se pueden crear citas con fechas anteriores a hoy.', 'warning' ); } } async function loadAppointments() { if (!supabaseClient) { return; } const today = todayISO(); const { data, error } = await supabaseClient .from('citas') .select('*') .gte( 'fecha', today ) .order( 'fecha', { ascending: true } ) .order( 'hora_inicio', { ascending: true } ); if (error) { showToast( 'Error cargando citas: ' + error.message, 'error' ); return; } allAppointments = (data || []) .filter( appointment => String( appointment.fecha ) >= today ) .sort( (a, b) => { const dateA = `${a.fecha}T${normalizeTime( a.hora_inicio || a.hora ) || '00:00:00'}`; const dateB = `${b.fecha}T${normalizeTime( b.hora_inicio || b.hora ) || '00:00:00'}`; return dateA.localeCompare( dateB ); } ); renderAppointments(); renderDashboardUpcoming(); $('statUpcomingAppointments').textContent = allAppointments.length; } function renderAppointments() { const container = $('appointmentsList'); if (!allAppointments.length) { container.innerHTML = `

No hay próximas citas.

`; return; } container.innerHTML = allAppointments .map( appointment => { const patient = allPatients.find( p => cleanRut(p.rut) === cleanRut( appointment.rut_paciente ) ); const hora = normalizeTime( appointment.hora_inicio || appointment.hora ) || '00:00:00'; return `

${escapeHtml( patient?.nombre || appointment.rut_paciente )}

${escapeHtml( appointment.rut_paciente )}

${escapeHtml( formatDateCL( appointment.fecha ) )}

${escapeHtml( formatTimeDisplay(hora) )}

${escapeHtml( appointment.estado || 'Agendada' )}
`; } ) .join(''); } function renderDashboardUpcoming() { const container = $('dashboardUpcoming'); const appointments = allAppointments.slice( 0, 5 ); if (!appointments.length) { container.innerHTML = '

No hay próximas citas.

'; return; } container.innerHTML = appointments .map( appointment => { const patient = allPatients.find( p => cleanRut(p.rut) === cleanRut( appointment.rut_paciente ) ); const hora = normalizeTime( appointment.hora_inicio || appointment.hora ) || '00:00:00'; return ` `; } ) .join(''); } function searchAppointmentPatients() { const query = $('appointmentPatientSearch') .value .trim() .toLowerCase(); $('appointmentRut').value = ''; $('appointmentPatientBadge') .classList.add('hidden'); const container = $('appointmentPatientSuggestions'); if (!query) { container.classList.add( 'hidden' ); return; } const matches = allPatients .filter( patient => String( patient.nombre || '' ).toLowerCase() .includes(query) || String( patient.rut || '' ).toLowerCase() .includes(query) ) .slice(0, 10); container.innerHTML = matches.length ? matches.map( patient => ` ` ).join('') : `
No se encontraron pacientes.
`; container.classList.remove( 'hidden' ); } function selectAppointmentPatientByRut(rut) { const patient = allPatients.find( p => cleanRut(p.rut) === cleanRut(rut) ); if (!patient) { showToast( 'No se encontró el paciente.', 'error' ); return; } $('appointmentPatientSearch').value = `${patient.nombre} · ${patient.rut}`; $('appointmentRut').value = patient.rut; $('appointmentPatientSuggestions') .classList.add('hidden'); $('appointmentPatientBadge').innerHTML = ` ${escapeHtml(patient.nombre)} · ${escapeHtml(patient.rut)}`; $('appointmentPatientBadge') .classList.remove('hidden'); showToast( `${patient.nombre} seleccionado.`, 'info' ); } /* ========================================================= CALENDARIO GOOGLE / ICS ========================================================== */ function assertValidDate(date) { return ( date instanceof Date && !Number.isNaN( date.getTime() ) ); } function formatCalendarUTC(date) { if (!assertValidDate(date)) { throw new Error( 'No se puede generar el calendario porque la fecha es inválida.' ); } const year = date.getUTCFullYear(); const month = String( date.getUTCMonth() + 1 ) .padStart(2, '0'); const day = String( date.getUTCDate() ) .padStart(2, '0'); const hour = String( date.getUTCHours() ) .padStart(2, '0'); const minute = String( date.getUTCMinutes() ) .padStart(2, '0'); const second = String( date.getUTCSeconds() ) .padStart(2, '0'); return ( `${year}${month}${day}` + `T${hour}${minute}${second}Z` ); } function escapeICS(value) { return String(value || '') .replace(/\\/g, '\\\\') .replace(/\r?\n/g, '\\n') .replace(/;/g, '\\;') .replace(/,/g, '\\,'); } function createICSContent(event) { if ( !event || !assertValidDate(event.start) || !assertValidDate(event.end) ) { throw new Error( 'No se pudo generar el ICS porque la fecha de la cita es inválida.' ); } const uid = `${event.id || Date.now()}@cyj-kinesiologia`; const now = new Date(); const dtstamp = formatCalendarUTC( now ); const dtstart = formatCalendarUTC( event.start ); const dtend = formatCalendarUTC( event.end ); return [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//CyJ Kinesiologia//KineControl//ES', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:CyJ Kinesiología', 'BEGIN:VEVENT', `UID:${escapeICS(uid)}`, `DTSTAMP:${dtstamp}`, `DTSTART:${dtstart}`, `DTEND:${dtend}`, `SUMMARY:${escapeICS(event.title)}`, `DESCRIPTION:${escapeICS(event.description)}`, `LOCATION:${escapeICS(event.location || 'CyJ Kinesiología')}`, 'BEGIN:VALARM', 'ACTION:DISPLAY', 'TRIGGER:-P1D', 'DESCRIPTION:Recordatorio de cita kinésica - 24 horas', 'END:VALARM', 'BEGIN:VALARM', 'ACTION:DISPLAY', 'TRIGGER:-PT1H', 'DESCRIPTION:Recordatorio de cita kinésica - 1 hora', 'END:VALARM', 'END:VEVENT', 'END:VCALENDAR' ].join('\r\n'); } function downloadICS(event) { try { const content = createICSContent( event ); const blob = new Blob( [content], { type: 'text/calendar;charset=utf-8' } ); const url = URL.createObjectURL( blob ); const link = document.createElement( 'a' ); link.href = url; const datePart = event.start .toISOString() .slice( 0, 10 ); link.download = `cita-cyj-${datePart}.ics`; link.style.display = 'none'; document.body.appendChild( link ); link.click(); document.body.removeChild( link ); setTimeout( () => { URL.revokeObjectURL( url ); }, 1000 ); showToast( 'Archivo ICS generado correctamente.' ); } catch (error) { showToast( error.message, 'error' ); } } function googleCalendarUrl(event) { if ( !event || !assertValidDate(event.start) || !assertValidDate(event.end) ) { throw new Error( 'La fecha de la cita no es válida para Google Calendar.' ); } const start = formatCalendarUTC( event.start ); const end = formatCalendarUTC( event.end ); const params = new URLSearchParams({ action: 'TEMPLATE', text: event.title, dates: `${start}/${end}`, details: event.description, location: event.location || 'CyJ Kinesiología' }); return ( 'https://calendar.google.com/calendar/render?' + params.toString() ); } function openGoogleCalendar(event) { try { const url = googleCalendarUrl( event ); window.open( url, '_blank', 'noopener' ); } catch (error) { showToast( error.message, 'error' ); } } function openEventModal(event) { if ( !event || !assertValidDate(event.start) || !assertValidDate(event.end) ) { showToast( 'No se puede mostrar el evento porque la fecha/hora es inválida.', 'error' ); return; } lastCreatedEvent = event; const date = event.start.toLocaleDateString( 'es-CL' ); const time = event.start.toLocaleTimeString( 'es-CL', { hour: '2-digit', minute: '2-digit' } ); $('eventModalText').textContent = `${event.patientName} · ${date} ${time}`; $('googleCalendarButton').onclick = () => openGoogleCalendar( event ); $('downloadICSButton').onclick = () => downloadICS( event ); $('eventWhatsAppButton').onclick = () => sendEventWhatsApp( event ); openModal( 'eventModal' ); } function generateEventFromExisting( appointmentId ) { const appointment = allAppointments.find( item => String(item.id) === String(appointmentId) ); if (!appointment) { showToast( 'No se encontró la cita.', 'error' ); return; } try { const event = buildExistingEvent( appointment ); openEventModal( event ); } catch (error) { showToast( error.message, 'error' ); } } function buildExistingEvent( appointment ) { const patient = allPatients.find( p => cleanRut(p.rut) === cleanRut( appointment.rut_paciente ) ); const hora = appointment.hora_inicio || appointment.hora; const duration = Number( appointment.duracion_minutos || 60 ); const dates = parseAppointmentDates( appointment.fecha, hora, duration ); return { id: appointment.id, title: `Atención kinésica - ${ patient?.nombre || appointment.rut_paciente }`, description: `Cita de kinesiología con el Klgo. Cristian Barrera para ${ patient?.nombre || appointment.rut_paciente }.`, location: 'CyJ Kinesiología', start: dates.start, end: dates.end, phone: patient?.telefono || '', patientName: patient?.nombre || appointment.rut_paciente, rut: appointment.rut_paciente }; } function sendEventWhatsApp(event) { const phone = String( event.phone || '' ).replace( /\D/g, '' ); if (!phone) { showToast( 'El paciente no tiene teléfono registrado.', 'warning' ); return; } if ( !assertValidDate(event.start) ) { showToast( 'La fecha de la cita es inválida.', 'error' ); return; } const date = event.start.toLocaleDateString( 'es-CL' ); const time = event.start.toLocaleTimeString( 'es-CL', { hour: '2-digit', minute: '2-digit' } ); const message = `Hola ${event.patientName}, te recordamos tu cita de kinesiología con el Klgo. Cristian Barrera. 📅 Fecha: ${date} 🕐 Hora: ${time} Puedes guardar la cita en tu calendario mediante Google Calendar o descargando el archivo ICS. Nos vemos en CyJ Kinesiología.`; window.open( `https://wa.me/${phone}?text=${encodeURIComponent(message)}`, '_blank', 'noopener' ); } function sendAppointmentWhatsApp( appointmentId ) { const appointment = allAppointments.find( item => String(item.id) === String(appointmentId) ); if (!appointment) { return; } try { const event = buildExistingEvent( appointment ); sendEventWhatsApp( event ); } catch (error) { showToast( error.message, 'error' ); } } /* ========================================================= INIT ========================================================== */ async function initApp() { $('attDate').value = todayISO(); $('appointmentDate').min = todayISO(); $('appointmentDate').value = todayISO(); buildEvaButtons(); if ( !SUPABASE_URL || !SUPABASE_ANON_KEY ) { showLoginError( 'Falta configurar la conexión con Supabase.' ); return; } try { supabaseClient = window.supabase.createClient( SUPABASE_URL, SUPABASE_ANON_KEY, { auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true } } ); } catch (error) { showLoginError( 'No fue posible inicializar Supabase: ' + error.message ); return; } supabaseClient.auth.onAuthStateChange( async ( event, session ) => { if ( event === 'SIGNED_OUT' ) { showLogin(); return; } if ( session && session.user ) { currentUser = session.user; if ( $('appShell') .classList .contains('hidden') ) { await showApplication(); } } } ); const { data, error } = await supabaseClient.auth.getSession(); if (error) { showLoginError( 'No fue posible recuperar la sesión: ' + error.message ); return; } if ( data.session && data.session.user ) { currentUser = data.session.user; await showApplication(); } else { showLogin(); } } /* ========================================================= EVENTOS GLOBALES ========================================================== */ $('loginForm') .addEventListener( 'submit', login ); document.addEventListener( 'keydown', event => { if ( event.key === 'Escape' ) { [ 'newPatientModal', 'exerciseModal', 'attentionHistoryModal', 'monthChartModal', 'patientChartDetail', 'eventModal' ].forEach( closeModal ); } } ); [ 'newPatientModal', 'exerciseModal', 'attentionHistoryModal', 'monthChartModal', 'patientChartDetail', 'eventModal' ].forEach( id => { const modal = $(id); if (!modal) { return; } modal.addEventListener( 'click', event => { if ( event.target === modal ) { closeModal( id ); } } ); } ); document.addEventListener( 'click', event => { const attentionSearch = $('attPatientSearch'); const attentionSuggestions = $('attPatientSuggestions'); if ( attentionSearch && attentionSuggestions && !attentionSearch.contains( event.target ) && !attentionSuggestions.contains( event.target ) ) { attentionSuggestions .classList .add('hidden'); } const appointmentSearch = $('appointmentPatientSearch'); const appointmentSuggestions = $('appointmentPatientSuggestions'); if ( appointmentSearch && appointmentSuggestions && !appointmentSearch.contains( event.target ) && !appointmentSuggestions.contains( event.target ) ) { appointmentSuggestions .classList .add('hidden'); } } ); window.addEventListener( 'DOMContentLoaded', initApp );