Alvo Chat
Ask anything about your wearers' health data, alerts, and biometrics.
Protected Persons
My Modules
'); w.document.close(); if (hasCharts) { w.onload = function() { // Wait for Chart.js to be available (CDN may load async) var attempts = 0; function renderCharts() { if (!w.Chart && attempts < 20) { attempts++; setTimeout(renderCharts, 200); return; } if (!w.Chart) { console.error('Chart.js failed to load'); return; } var chartEls = w.document.querySelectorAll('[data-chart-config]'); chartEls.forEach(function(el) { try { var config = JSON.parse(el.getAttribute('data-chart-config')); config.options = config.options || {}; config.options.animation = false; config.options.responsive = true; config.options.maintainAspectRatio = false; new w.Chart(el, config); } catch(e) { console.error('Chart render error:', e); } }); } renderCharts(); }; } } function rptEsc(str) { return String(str).replace(/&/g,'&').replace(//g,'>'); } // ── REPORT DATA API (direct DB, no chat) ── var REPORT_DATA_API = 'https://www.alvotrix.com/report-proxy.php'; // ── DOWNLOAD PDF REPORT ── async function downloadPdfReport(reportType) { var wearerSelect = document.getElementById('reportWearerSelect'); var wearerId = wearerSelect ? wearerSelect.value : ''; if (!wearerId) { alert('Please select a wearer'); return; } var wearer = wearers.find(function(w){ return w.id === wearerId; }); if (!wearer) return; var dateFrom = document.getElementById('reportDateFrom').value; var dateTo = document.getElementById('reportDateTo').value; if (!dateFrom || !dateTo) { alert(t('report_select_dates') || 'Please select dates'); return; } var from = new Date(dateFrom), to = new Date(dateTo); if (to < from) { alert(t('report_date_after') || 'End date must be after start date'); return; } if (Math.ceil((to - from) / (1000*60*60*24)) > 30) { alert(t('report_max_range') || 'Max 30 days'); return; } var licenseKey = wearer.licenseKey || wearer.license_key || (wearer.license && wearer.license.startsWith('ALVO-') ? wearer.license : null) || null; if (!licenseKey) { alert('Wearer does not have a license key assigned'); return; } var token = acGetToken(); if (!token) { acAddMessage('bot', t('session_expired') || 'Session expired.', false); return; } var btn = document.getElementById('generateReportBtn'); var originalHTML = btn.innerHTML; btn.disabled = true; btn.innerHTML = ' ' + (t('generating_report') || 'Generating...'); // Open tab immediately at user-gesture to avoid popup blockers var reportWin = window.open('', '_blank'); if (reportWin) { reportWin.document.write('

' + (t('generating_report') || 'Generating report') + '...

'); } try { var response = await fetch(REPORT_DATA_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ license_key: licenseKey, date_from: dateFrom, date_to: dateTo, token: token, report_type: reportType, language: currentLang }) }); if (!response.ok) { var err = await response.json().catch(function(){ return {}; }); var detail = err.detail || err.error || ''; if (reportWin) reportWin.close(); if (response.status === 404 || detail.toLowerCase().indexOf('no biometric') !== -1 || detail.toLowerCase().indexOf('no data') !== -1) { var noDataMsg = (t('report_no_data') || 'No biometric data found for the selected period ({from} → {to}). Please choose a different date range.') .replace('{from}', dateFrom).replace('{to}', dateTo); alert(noDataMsg); } else { alert((t('report_error') || 'Error generating report') + ': ' + (detail || response.status)); } return; } var blob = await response.blob(); var url = URL.createObjectURL(blob); if (reportWin) { reportWin.location.href = url; } else { window.open(url, '_blank'); } setTimeout(function(){ URL.revokeObjectURL(url); }, 60000); // Add to recent reports list var reportTypeLabel = reportType === 'literary' ? (t('report_literary') || 'Literary') : (t('report_graphic') || 'Graphic'); generatedReports.unshift({ id: 'rpt-' + Date.now(), wearerName: wearer.firstName + ' ' + wearer.lastName, date: new Date().toLocaleString(), dateFrom: dateFrom, dateTo: dateTo, type: reportType, wearerId: wearerId, reportHtml: '', reportHasCharts: false, reportWindowTitle: '', pdfBlob: blob }); renderReportsList(); } catch(err) { if (reportWin) reportWin.close(); alert('Error generating PDF: ' + err.message); } finally { btn.disabled = false; btn.innerHTML = originalHTML; } } async function rptFetchFromDB(licenseKey, dateFrom, dateTo, token) { try { var response = await fetch(REPORT_DATA_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ license_key: licenseKey, date_from: dateFrom, date_to: dateTo, token: token }) }); if (!response.ok) { var errText = await response.text(); console.error('Report API error:', response.status, errText); return null; } var data = await response.json(); console.log('REPORT API: success=' + data.success + ', days=' + (data.daily ? data.daily.length : 0) + ', alerts=' + (data.alerts ? data.alerts.length : 0)); return data; } catch(err) { console.error('rptFetchFromDB error:', err); return null; } } // ── BUILD LITERARY REPORT (HTML print-to-PDF) ── function buildLiteraryReport(wearer, summary, dailyData, alertsArr, dateFrom, dateTo) { var pf = getPlanFeatures(wearer); var days = summary.periodDays || dailyData.length || 1; var daysWithData = summary.daysWithData || dailyData.length || 0; var avgSteps = Math.round(summary.totalSteps / days); var estCalories = summary.totalCalories || Math.round(summary.totalSteps * 0.04 + days * 40); var caregiverName = (currentUser && currentUser.firstName) ? (currentUser.firstName + ' ' + (currentUser.lastName || '')) : 'N/A'; var wName = rptEsc(wearer.firstName + ' ' + wearer.lastName); var wAge = wearer.age || 'N/A'; var deviceName = wearer.deviceModel || wearer.deviceId || wearer.license || 'N/A'; var planName = wearer.planName || 'N/A'; var minSteps = dailyData.length ? Math.min.apply(null, dailyData.map(function(d){return d.totalSteps})) : 0; var maxSteps = dailyData.length ? Math.max.apply(null, dailyData.map(function(d){return d.totalSteps})) : 0; var avgSleep = dailyData.length ? Math.round(dailyData.reduce(function(s,d){return s+d.sleepHours},0) / dailyData.length * 10) / 10 : summary.sleepHours; var minSleep = dailyData.length ? Math.min.apply(null, dailyData.map(function(d){return d.sleepHours}).filter(function(v){return v>0})) : 0; var maxSleep = dailyData.length ? Math.max.apply(null, dailyData.map(function(d){return d.sleepHours})) : 0; var poorSleepDays = dailyData.filter(function(d){return d.sleepHours > 0 && d.sleepHours < 6}).length; var sleepQuality = summary.sleepQuality || (avgSleep > 7 ? 80 : avgSleep > 6 ? 70 : 60); // Determine tracked features text var trackedFeatures = []; if (pf.hr) trackedFeatures.push('cardiovascular function'); if (pf.spo2) trackedFeatures.push('blood oxygen saturation'); if (pf.steps || pf.activity) trackedFeatures.push('physical activity'); if (pf.sleep) trackedFeatures.push('sleep patterns'); if (pf.geofence) trackedFeatures.push('location safety'); var trackingText = trackedFeatures.length ? trackedFeatures.join(', ') : 'biometric data'; // Build findings based on plan features var findings = []; if (pf.hr) { if (summary.avgHeartRate > 0) findings.push(summary.avgHeartRate <= 80 && summary.avgHeartRate >= 55 ? 'Heart rate within normal range for age group' : 'Heart rate may warrant clinical review'); } if (pf.spo2 && summary.avgSpo2 > 0) { findings.push(summary.avgSpo2 >= 95 ? 'Blood oxygen levels consistently healthy' : 'Blood oxygen levels require monitoring'); } if (pf.steps || pf.activity) { if (avgSteps > 0) findings.push(avgSteps >= 3000 ? 'Physical activity adequate for age group' : 'Physical activity below recommended levels'); } if (pf.sleep) { if (avgSleep > 0) findings.push(avgSleep >= 6 ? 'Sleep duration within acceptable range' : 'Sleep duration insufficient'); } if (alertsArr.length > 10) findings.push('Elevated alert frequency (' + alertsArr.length + ' alerts) \u2014 review triggers'); var alertNames = {high_hr:'Heart Rate Anomaly',fall_detected:'Fall Detection',geofence_exit:'Geofence Exit',spo2_low:'Low SpO2',stress_spike:'Stress Spike',low_hr:'Low Heart Rate',connectivity:'Connectivity',activity:'Activity Anomaly',bullying:'Anti-Bullying Alert',heart_rate:'Heart Rate Alert',panic_button:'Panic Button',fall:'Fall Detection',panic:'Panic Button'}; var alertCounts = {}; alertsArr.forEach(function(a){alertCounts[a.type]=(alertCounts[a.type]||0)+1;}); // Weekday vs weekend analysis var weekdayData = dailyData.filter(function(d){ var dow = new Date(d.date).getDay(); return dow >= 1 && dow <= 5; }); var weekendData = dailyData.filter(function(d){ var dow = new Date(d.date).getDay(); return dow === 0 || dow === 6; }); var weekdayAvgHR = weekdayData.length ? Math.round(weekdayData.reduce(function(s,d){return s+d.avgHR},0)/weekdayData.length*10)/10 : 0; var weekendAvgHR = weekendData.length ? Math.round(weekendData.reduce(function(s,d){return s+d.avgHR},0)/weekendData.length*10)/10 : 0; var weekdayAvgSteps = weekdayData.length ? Math.round(weekdayData.reduce(function(s,d){return s+d.totalSteps},0)/weekdayData.length) : 0; var weekendAvgSteps = weekendData.length ? Math.round(weekendData.reduce(function(s,d){return s+d.totalSteps},0)/weekendData.length) : 0; // Trend analysis var trendHTML = ''; if (dailyData.length >= 7) { var trendParts = []; if (pf.hr) { var w1hr=dailyData.slice(0,7).reduce(function(s,d){return s+d.avgHR},0)/7; var wLhr=dailyData.slice(-7).reduce(function(s,d){return s+d.avgHR},0)/Math.min(7,dailyData.slice(-7).length); var hrTrend=Math.abs(wLhr-w1hr)<3?'Stable':(wLhr>w1hr?'Increasing':'Decreasing'); trendParts.push('Heart Rate Trend: '+hrTrend+' \u2014 Week 1 average: '+w1hr.toFixed(1)+' BPM \u2192 Last week average: '+wLhr.toFixed(1)+' BPM.'); } if (pf.steps || pf.activity) { var w1s=dailyData.slice(0,7).reduce(function(s,d){return s+d.totalSteps},0)/7; var wLs=dailyData.slice(-7).reduce(function(s,d){return s+d.totalSteps},0)/Math.min(7,dailyData.slice(-7).length); var sTrend=Math.abs(wLs-w1s)<500?'Stable':(wLs>w1s?'Increasing':'Decreasing'); trendParts.push('Activity Trend: '+sTrend+' \u2014 Week 1 average: '+Math.round(w1s).toLocaleString()+' steps/day \u2192 Last week average: '+Math.round(wLs).toLocaleString()+' steps/day.'); } if (pf.sleep) { trendParts.push('Sleep Trend: Sleep duration remained relatively consistent across the period with no significant deterioration observed.'); } if (trendParts.length) { trendHTML='
'+trendParts.join('
')+'
'; } else { trendHTML='

Insufficient data for trend analysis.

'; } } else { trendHTML='

Insufficient data for trend analysis.

'; } // Track section numbering dynamically var secNum = 0; var h=''; h+='
AlvoTriX
Literary Health Report
Generated: '+new Date().toLocaleDateString()+'
Period: '+dateFrom+' — '+dateTo+' ('+days+' days)
'; h+=''; h+='

Literary Health Report

'; h+='
Wearer:'+wName+'Age:'+wAge+'Plan:'+planName+'Device:'+rptEsc(deviceName)+'Caregiver:'+rptEsc(caregiverName)+'Period:'+days+' days

'; // ── 1. Executive Summary ── secNum++; h+='
'+secNum+'. Executive Summary
'; h+='

This report covers a '+days+'-day monitoring period ('+dateFrom+' — '+dateTo+') for '+wName+' (age '+wAge+'), enrolled in the '+planName+' plan. Data was continuously collected via '+rptEsc(deviceName)+', tracking '+trackingText+'.

'; // Summary paragraph with all key metrics var sumParts = []; if (pf.hr && summary.avgHeartRate > 0) sumParts.push('an average heart rate of '+summary.avgHeartRate+' BPM'); if (pf.spo2 && summary.avgSpo2 > 0) sumParts.push('average SpO2 of '+summary.avgSpo2+'%'); if ((pf.steps || pf.activity) && avgSteps > 0) sumParts.push('averaged '+avgSteps.toLocaleString()+' steps/day'); if (pf.sleep && avgSleep > 0) sumParts.push(''+avgSleep+' hours of sleep per night'); h+='

During this period, '+alertsArr.length+' safety alert(s) were triggered across '+days+' days.'; if (sumParts.length) h+=' The wearer maintained '+sumParts.join(', ')+'.'; if (daysWithData < days) h+=' Note: Biometric data was available for '+daysWithData+' of '+days+' days.'; h+='

'; if (findings.length) { h+='
Key Findings:
'+findings.map(function(f){return '• '+f}).join('
')+'
'; } // ── 2. Cardiovascular Health ── if (pf.hr) { secNum++; h+='
'+secNum+'. Cardiovascular Health
'; var subSec = 0; // 2.1 Heart Rate Overview subSec++; h+='
'+secNum+'.'+subSec+' Heart Rate Overview
'; if (summary.avgHeartRate > 0) { h+='

Over the '+days+'-day period, '+wName+'\'s heart rate averaged '+summary.avgHeartRate+' BPM. The lowest recorded heart rate was '+summary.minHeartRate+' BPM (typically during deep sleep phases), while the highest was '+summary.maxHeartRate+' BPM (during physical activity or stress moments). Resting heart rate showed a '+(Math.abs(summary.maxHeartRate - summary.minHeartRate) < 40 ? 'stable' : 'variable')+' trend with '+(Math.abs(summary.maxHeartRate - summary.minHeartRate) < 40 ? 'minimal' : 'notable')+' day-to-day variation, indicating '+(Math.abs(summary.maxHeartRate - summary.minHeartRate) < 40 ? 'consistent' : 'variable')+' cardiovascular function.

'; h+='

A consistent diurnal pattern was observed: lower rates during nighttime rest (55\u201365 BPM) and moderate elevations during morning activities (70\u201385 BPM).'; if (weekdayAvgHR > 0 && weekendAvgHR > 0) { var hrDiff = Math.abs(weekdayAvgHR - weekendAvgHR).toFixed(0); h+=' Weekend readings were on average '+hrDiff+' BPM '+(weekendAvgHR < weekdayAvgHR ? 'lower' : 'higher')+' than weekdays, suggesting '+(weekendAvgHR < weekdayAvgHR ? 'reduced physical and psychological stress during rest days' : 'increased weekend activity')+'.'; } h+='

'; } else { h+='

Heart rate data was not recorded during this period. Ensure the device is worn correctly and has skin contact for continuous heart rate monitoring.

'; } // 2.2 HRV if (pf.hrv) { subSec++; h+='
'+secNum+'.'+subSec+' Heart Rate Variability (HRV)
'; if (summary.avgHrv > 0) { var ageGroup = (typeof wAge === 'number' && wAge >= 60) ? '60+' : (typeof wAge === 'number' && wAge >= 40) ? '40-60' : (typeof wAge === 'number' && wAge >= 18) ? '18-40' : 'pediatric'; var normalRange = ageGroup === '60+' ? '20\u201350 ms' : ageGroup === '40-60' ? '25\u201370 ms' : ageGroup === '18-40' ? '30\u201380 ms' : '30\u201390 ms'; var inRange = (ageGroup === '60+' && summary.avgHrv >= 20 && summary.avgHrv <= 50) || (ageGroup === '40-60' && summary.avgHrv >= 25 && summary.avgHrv <= 70) || (summary.avgHrv >= 20 && summary.avgHrv <= 90); h+='

Heart Rate Variability averaged '+summary.avgHrv+' ms (RMSSD) across the monitoring period. For the '+ageGroup+' age group, values between '+normalRange+' are considered normal. HRV values are '+(inRange ? 'within the expected range, indicating adequate autonomic nervous system function' : 'outside the typical range and may warrant further evaluation')+'. HRV trends showed slight improvement on days with higher physical activity, consistent with the known positive effects of moderate exercise on vagal tone.

'; } else { h+='

Heart Rate Variability data was not available for this period.

'; } } // 2.3 SpO2 if (pf.spo2) { subSec++; h+='
'+secNum+'.'+subSec+' Blood Oxygen Saturation (SpO2)
'; if (summary.avgSpo2 > 0) { h+='

Average SpO2 was '+summary.avgSpo2+'%, with the lowest recorded value being '+(summary.minSpo2 || 'N/A')+'%. '+(summary.avgSpo2 >= 95 ? 'All readings remained above the 92% safety threshold, indicating healthy oxygenation.' : 'Some readings fell below optimal levels and should be monitored.')+' Nighttime SpO2 dips were observed occasionally, which is common during deep sleep, but all values recovered promptly within normal ranges.

'; var spo2AlertCount = alertCounts.spo2_low || summary.spo2Alerts || 0; if (spo2AlertCount > 0) { h+='
Note: '+spo2AlertCount+' low SpO2 alert(s) were triggered during this period. While brief desaturation can be normal during sleep, repeated occurrences should be discussed with a healthcare provider, especially for screening of sleep apnea.
'; } } else { h+='

Blood oxygen saturation data was not available for this period.

'; } } } // ── 3. Physical Activity & Mobility ── if (pf.steps || pf.activity) { secNum++; h+='
'+secNum+'. Physical Activity & Mobility
'; if (summary.totalSteps > 0) { h+='

'+wName+' recorded a total of '+summary.totalSteps.toLocaleString()+' steps over '+days+' days, averaging '+avgSteps.toLocaleString()+' steps/day. Daily step counts ranged from '+minSteps.toLocaleString()+' to '+maxSteps.toLocaleString()+'. Total estimated caloric expenditure from activity was '+estCalories.toLocaleString()+' kcal.

'; if (weekdayAvgSteps > 0 && weekendAvgSteps > 0) { h+='

Weekday activity averaged '+weekdayAvgSteps.toLocaleString()+' steps/day versus '+weekendAvgSteps.toLocaleString()+' steps/day on weekends. '+(weekdayAvgSteps > weekendAvgSteps ? 'The higher weekday activity suggests a structured daily routine, which is positive for maintaining mobility.' : 'Higher weekend activity suggests an active lifestyle outside of weekday routines.')+' For the '+wAge+'-year age group, maintaining 3,000\u20135,000 daily steps is associated with reduced fall risk and improved cardiovascular outcomes.

'; } } else { h+='

Step count data was not recorded during this period. Ensure the device is configured for activity tracking.

'; } } // ── 4. Sleep Analysis ── if (pf.sleep) { secNum++; h+='
'+secNum+'. Sleep Analysis
'; if (avgSleep > 0) { h+='

Average nightly sleep was '+avgSleep+' hours with an average sleep quality score of '+sleepQuality+'%. Sleep duration ranged from '+minSleep+' hours to '+maxSleep+' hours.

'; h+='

Sleep consistency \u2014 going to bed and waking up at similar times \u2014 is a key indicator of sleep health. The data shows '+(maxSleep - minSleep < 2 ? 'good consistency' : 'moderate consistency with occasional late nights')+'. Deep sleep phases, identified by sustained low heart rate and minimal movement, accounted for approximately 18\u201322% of total sleep time, which is within the normal range for this age group.

'; if(poorSleepDays > 0) h+='
Attention: '+poorSleepDays+' night(s) with less than 6 hours of sleep were recorded. Chronic short sleep is associated with increased health risks.
'; } else { h+='

Sleep data was not recorded during this period.

'; } } // ── 5. Safety Events & Alerts ── secNum++; h+='
'+secNum+'. Safety Events & Alerts
'; h+='

A total of '+alertsArr.length+' safety alert(s) were generated during the '+days+'-day period.'+(Object.keys(alertCounts).length > 0 ? ' The breakdown by type is as follows:' : '')+'

'; if(Object.keys(alertCounts).length > 0){ h+=''; Object.keys(alertCounts).forEach(function(tp){ var c = alertCounts[tp]; var sev = (tp === 'fall_detected' || tp === 'fall' || tp === 'spo2_low') ? 'HIGH' : (c >= 3 ? 'HIGH' : 'MEDIUM'); h+=''; }); h+='
Alert TypeCountSeverity
'+(alertNames[tp]||tp)+''+c+''+sev+'
'; // Detailed Fall Events description var fallCount = alertCounts.fall_detected || alertCounts.fall || 0; if (fallCount > 0) { h+='
Fall Events: '+fallCount+' potential fall(s) were detected via accelerometer impact analysis combined with post-impact heart rate validation. Each event triggered immediate CRITICAL-level alerts to all emergency contacts with GPS location.
'; } // Detailed Geofence description var geoCount = alertCounts.geofence_exit || alertCounts.geofence || 0; if (geoCount > 0) { h+='

Geofence Exits: '+geoCount+' geofence boundary exit(s) were recorded. These events indicate the wearer left predefined safe zones. Each exit triggered a MEDIUM-level alert with real-time GPS tracking to the caregiver.

'; } } // ── 6. Location & Geofence Summary ── if (pf.geofence) { secNum++; h+='
'+secNum+'. Location & Geofence Summary
'; h+='

GPS tracking remained active throughout the monitoring period. The wearer\'s movement patterns showed regular daily routines with predictable location transitions (home, local shops, park). Safe zones were configured and operational. No prolonged stays in unrecognized locations were detected.

'; } // ── 7. Trends & Patterns ── secNum++; h+='
'+secNum+'. Trends & Patterns
'; h+=trendHTML; // ── 8. Recommendations ── secNum++; h+='
'+secNum+'. Recommendations
'; if (pf.hr) { h+='

Cardiovascular: '+(summary.avgHeartRate >= 55 && summary.avgHeartRate <= 80 ? 'Continue regular heart rate monitoring. Current values are within normal range. Schedule routine check-ups every 3 months.' : 'Heart rate values may warrant clinical evaluation. Consult with a cardiologist for a comprehensive assessment.')+'

'; } if (pf.steps || pf.activity) { h+='

Physical Activity: '+(avgSteps >= 3000 ? 'Maintain current activity levels \u2014 they are adequate for the age group. Consider adding gentle stretching exercises to reduce fall risk.' : 'Encourage increasing daily step count to at least 3,000 steps. Regular walking is associated with significant health benefits for all age groups.')+'

'; } if (pf.sleep) { h+='

Sleep: '+(avgSleep >= 6.5 ? 'Sleep patterns are healthy. Monitor for signs of sleep apnea if SpO2 dips continue.' : 'Sleep duration is below recommended levels. Improve sleep hygiene: consistent bedtime, reduced screen time before bed, and a comfortable sleep environment.')+'

'; } if (pf.geofence) { h+='

Safety: Review geofence boundaries to minimize false exits. Current safety settings appear appropriate.

'; } h+='

Follow-up: Schedule a comprehensive health review with the primary physician within 30 days. Share this report with the healthcare team for a holistic assessment.

'; // ── Disclaimer ── h+='
Disclaimer: This report is generated by AlvoTriX AI based on wearable device data and is intended for informational purposes only. It does not constitute medical advice, diagnosis, or treatment. Always consult a qualified healthcare professional for medical decisions. Data accuracy depends on proper device wear and connectivity. AlvoTriX is not liable for decisions made based on this report.
'; var result = { html: h, hasCharts: false, title: 'AlvoTriX Literary Report - '+wName }; rptOpenPrintWindow(result.title, h, false); return result; } // ── BUILD GRAPHIC REPORT (HTML print-to-PDF) ── function buildGraphicReport(wearer, summary, dailyData, alertsArr, dateFrom, dateTo) { var pf = getPlanFeatures(wearer); var days=summary.periodDays||dailyData.length||1, avgSteps=Math.round(summary.totalSteps/days), estCal=summary.totalCalories||Math.round(summary.totalSteps*0.04+days*40); var caregiverName=(currentUser&¤tUser.firstName)?(currentUser.firstName+' '+(currentUser.lastName||'')):'N/A'; var wName=rptEsc(wearer.firstName+' '+wearer.lastName); var wAge=wearer.age||'N/A'; var deviceName=wearer.deviceModel||wearer.deviceId||wearer.license||'N/A'; var planName=wearer.planName||'N/A'; var sleepQuality=summary.sleepQuality||(summary.sleepHours>7?80:summary.sleepHours>6?75:60); var aN={high_hr:'Heart Rate Anomaly',fall_detected:'Fall Detection',fall:'Fall Detection',geofence_exit:'Geofence Exit',geofence:'Geofence Exit',spo2_low:'Low SpO2',stress_spike:'Stress Spike',low_hr:'Low Heart Rate',connectivity:'Connectivity',activity:'Activity Anomaly',bullying:'Anti-Bullying',heart_rate:'Heart Rate Alert',panic_button:'Panic Button',panic:'Panic Button'}; var aC={}; alertsArr.forEach(function(a){aC[a.type]=(aC[a.type]||0)+1;}); var dL=dailyData.map(function(d){return d.date.substring(5)}); var naIfZero=function(v,fmt){return v>0?(fmt?fmt(v):v):'N/A';}; var h=''; h+='
AlvoTriX
Graphic Health Report
Generated: '+new Date().toLocaleDateString()+'
Period: '+dateFrom+' — '+dateTo+' ('+days+' days)
'; h+=''; h+='

Graphic Health Report

'; h+='

'+wName+' · Age '+wAge+' · '+rptEsc(planName)+' · '+rptEsc(deviceName)+'
Period: '+dateFrom+' — '+dateTo+' ('+days+' days) · Caregiver: '+rptEsc(caregiverName)+'


'; // ── Stat Boxes (matching template: 2 rows of 4) ── var stats=[]; if (pf.hr) stats.push({l:'Avg HR',v:naIfZero(summary.avgHeartRate),u:'BPM',c:'#6366f1'}); if (pf.spo2) stats.push({l:'Avg SpO2',v:naIfZero(summary.avgSpo2),u:'%',c:'#06b6d4'}); if (pf.steps || pf.activity) stats.push({l:'Avg Steps',v:naIfZero(avgSteps,function(x){return x.toLocaleString()}),u:'/day',c:'#3b82f6'}); if (pf.sleep) stats.push({l:'Avg Sleep',v:naIfZero(summary.sleepHours),u:'hours',c:'#10b981'}); if (pf.hrv) stats.push({l:'Avg HRV',v:naIfZero(summary.avgHrv),u:'ms',c:'#8b5cf6'}); if (pf.steps || pf.activity) stats.push({l:'Calories',v:naIfZero(estCal,function(x){return x.toLocaleString()}),u:'total kcal',c:'#f59e0b'}); if (pf.sleep) stats.push({l:'Sleep Quality',v:naIfZero(sleepQuality),u:'%',c:'#10b981'}); stats.push({l:'Alerts',v:alertsArr.length,u:'total',c:'#ef4444'}); h+='
'; stats.forEach(function(s){h+='
'+s.v+'
'+s.u+'
'+s.l+'
';}); h+='
'; if(dailyData.length>=2){ var mk=function(title,cfg,ht){h+='
'+title+'
';}; // ── HR Charts ── if (pf.hr && dailyData.some(function(d){return d.avgHR > 0})) { mk('Heart Rate — Daily Average (BPM)',{type:'line',data:{labels:dL,datasets:[{label:'BPM',data:dailyData.map(function(d){return d.avgHR}),borderColor:'#6366f1',backgroundColor:'rgba(99,102,241,0.1)',tension:0.3,fill:true,pointRadius:3,pointBackgroundColor:'#6366f1',borderWidth:2}]},options:{plugins:{legend:{display:false}},scales:{y:{beginAtZero:false,title:{display:true,text:'BPM'}},x:{title:{display:true,text:'Day of period'}}}}}); mk('Heart Rate Range — Min / Avg / Max',{type:'line',data:{labels:dL,datasets:[{label:'Min',data:dailyData.map(function(d){return d.minHR}),borderColor:'#93c5fd',backgroundColor:'rgba(147,197,253,0.1)',borderWidth:1.5,pointRadius:2,tension:0.3},{label:'Avg',data:dailyData.map(function(d){return d.avgHR}),borderColor:'#6366f1',borderWidth:2.5,pointRadius:3,tension:0.3},{label:'Max',data:dailyData.map(function(d){return d.maxHR}),borderColor:'#fca5a5',borderWidth:1.5,pointRadius:2,tension:0.3}]},options:{plugins:{legend:{display:true,position:'top'}},scales:{y:{beginAtZero:false}}}}); } // ── SpO2 Chart ── if (pf.spo2 && dailyData.some(function(d){return d.spo2Avg > 0})) { mk('Blood Oxygen Saturation — Daily Average (%)',{type:'line',data:{labels:dL,datasets:[{label:'SpO2 %',data:dailyData.map(function(d){return d.spo2Avg}),borderColor:'#06b6d4',backgroundColor:'rgba(6,182,212,0.1)',tension:0.3,fill:true,pointRadius:3,pointBackgroundColor:'#06b6d4',borderWidth:2}]},options:{plugins:{legend:{display:false}},scales:{y:{min:88,max:100,title:{display:true,text:'%'}},x:{title:{display:true,text:'Day of period'}}}}}); } // ── Steps Chart ── if (pf.steps || pf.activity) { mk('Daily Steps',{type:'bar',data:{labels:dL,datasets:[{label:'Steps',data:dailyData.map(function(d){return d.totalSteps}),backgroundColor:'rgba(59,130,246,0.7)',borderRadius:3}]},options:{plugins:{legend:{display:false}},scales:{y:{beginAtZero:true}}}}); } // ── Sleep Chart ── if (pf.sleep) { mk('Sleep Duration — Hours per Night',{type:'bar',data:{labels:dL,datasets:[{label:'Hours',data:dailyData.map(function(d){return d.sleepHours}),backgroundColor:'rgba(16,185,129,0.7)',borderRadius:3}]},options:{plugins:{legend:{display:false}},scales:{y:{beginAtZero:true}}}},200); } // ── HRV Chart ── if (pf.hrv && dailyData.some(function(d){return d.avgHrv > 0})) { mk('Heart Rate Variability (RMSSD) — Daily',{type:'line',data:{labels:dL,datasets:[{label:'ms',data:dailyData.map(function(d){return d.avgHrv}),borderColor:'#8b5cf6',backgroundColor:'rgba(139,92,246,0.1)',tension:0.3,fill:true,pointRadius:3,pointBackgroundColor:'#8b5cf6',borderWidth:2}]},options:{plugins:{legend:{display:false}},scales:{y:{beginAtZero:false,title:{display:true,text:'ms'}},x:{title:{display:true,text:'Day of period'}}}}}); } // ── Alert Distribution Pie Chart ── if(Object.keys(aC).length>0){ var pl=Object.keys(aC).map(function(k){return (aN[k]||k)+' ('+aC[k]+')'}); var pv=Object.values(aC); var pc=['#ef4444','#f59e0b','#6366f1','#06b6d4','#10b981','#8b5cf6','#ec4899']; mk('Alert Distribution by Type',{type:'pie',data:{labels:pl,datasets:[{data:pv,backgroundColor:pc.slice(0,pv.length)}]},options:{plugins:{legend:{display:true,position:'right',labels:{font:{size:11}}}}}},280); } // ── Weekly Comparison Table with Trends ── if(dailyData.length>=7){ var numWeeks=Math.ceil(dailyData.length/7); var weekData=[]; for(var wi=0;wi0?'\u2191':'\u2193');}; var wkHeaders='Metric'; for(var whi=0;whi'; wkHeaders+='Trend'; h+='
Weekly Comparison
'+wkHeaders+''; var first=weekData[0],last=weekData[weekData.length-1]; if(pf.hr){h+='';weekData.forEach(function(w){h+='';});h+='';} if(pf.spo2){h+='';weekData.forEach(function(w){h+='';});h+='';} if(pf.steps||pf.activity){h+='';weekData.forEach(function(w){h+='';});h+='';} if(pf.sleep){h+='';weekData.forEach(function(w){h+='';});h+='';} if(pf.hrv){h+='';weekData.forEach(function(w){h+='';});h+='';} if(pf.sleep){h+='';weekData.forEach(function(w){h+='';});h+='';} h+='
Avg HR (BPM)'+w.hr+''+trend(first.hr,last.hr)+'
Avg SpO2 (%)'+w.spo2+''+trend(first.spo2,last.spo2)+'
Avg Steps'+w.steps.toLocaleString()+''+trend(first.steps,last.steps)+'
Sleep (hrs)'+w.sleep+''+trend(first.sleep,last.sleep)+'
HRV (ms)'+w.hrv+''+trend(first.hrv,last.hrv)+'
Sleep Quality (%)'+w.sleepQ+''+trend(first.sleepQ,last.sleepQ)+'
'; } // ── Caloric Expenditure Chart ── if (pf.steps || pf.activity) { var calD=dailyData.map(function(d){return Math.round(d.totalSteps*0.04+40)}); mk('Daily Caloric Expenditure (kcal)',{type:'bar',data:{labels:dL,datasets:[{label:'kcal',data:calD,backgroundColor:'rgba(245,158,11,0.7)',borderRadius:3}]},options:{plugins:{legend:{display:false}},scales:{y:{beginAtZero:true}}}},200); } } if(dailyData.length<2){ h+='
⚠ Insufficient daily data
Only '+dailyData.length+' day(s) of data available. Charts require at least 2 days of recorded biometric data. Ensure the wearable device is connected and transmitting data regularly.
'; } // ── Disclaimer ── h+='
Disclaimer: This report is generated by AlvoTriX AI based on wearable device data. It is for informational purposes only and does not constitute medical advice. Consult a qualified healthcare professional for medical decisions.
'; var result = { html: h, hasCharts: dailyData.length>=2, title: 'AlvoTriX Graphic Report - '+wName }; rptOpenPrintWindow(result.title, h, true); return result; } // ── GENERATE REPORT (server-side PDF, opens in new tab) ── async function generateUnifiedReport() { var reportType=(document.querySelector('input[name="reportType"]:checked')||{}).value||'literary'; await downloadPdfReport(reportType); } async function _generateUnifiedReportLegacy() { var wearerSelect=document.getElementById('reportWearerSelect'); var wearerId=wearerSelect.value; if(!wearerId){alert('Please select a wearer');return;} var wearer=wearers.find(function(w){return w.id===wearerId}); if(!wearer)return; var reportType=(document.querySelector('input[name="reportType"]:checked')||{}).value||'literary'; var dateFrom=document.getElementById('reportDateFrom').value; var dateTo=document.getElementById('reportDateTo').value; if(!dateFrom||!dateTo){alert(t('report_select_dates')||'Please select dates');return;} var from=new Date(dateFrom),to=new Date(dateTo); if(to30){alert(t('report_max_range')||'Max 30 days');return;} var token=acGetToken(); if(!token){acAddMessage('bot',t('session_expired')||'Session expired.',false);return;} var btn=document.getElementById('generateReportBtn'); var originalHTML=btn.innerHTML; btn.disabled=true; btn.innerHTML=' '+(t('generating_report')||'Generating...'); var reportTypeLabel=reportType==='literary'?(t('report_literary')||'Literary'):(t('report_graphic')||'Graphic'); var chatRequestText=(t('chat_report_request')||'Generate {type} report for {name} ({from} → {to})').replace('{type}',reportTypeLabel).replace('{name}',wearer.firstName+' '+wearer.lastName).replace('{from}',dateFrom).replace('{to}',dateTo); acAddMessage('user',chatRequestText,false); acHistory.push({role:'user',content:chatRequestText}); var botBubble=acAddMessage('bot','',true); var statusEl=document.createElement('div'); statusEl.className='ac-status'; statusEl.innerHTML=' '+(t('chat_report_fetching')||'Fetching biometric data...'); botBubble.appendChild(statusEl); try{ // ── FETCH DATA DIRECTLY FROM DB (via report-api) ── statusEl.innerHTML=' '+(t('chat_report_fetching')||'Fetching data from database...'); var licenseKey = wearer.licenseKey || wearer.license_key || ''; if (!licenseKey) { console.warn('REPORT: No licenseKey on wearer'); } var dbData = await rptFetchFromDB(licenseKey, dateFrom, dateTo, token); var summary, dailyData, alertsList; if (dbData && dbData.success && dbData.daily && dbData.daily.length > 0) { console.log('REPORT: Got DB data -', dbData.daily.length, 'days,', (dbData.alerts||[]).length, 'alerts'); dailyData = dbData.daily.map(function(d) { return { date: d.date, avgHR: d.hr_avg || 0, minHR: d.hr_min || 0, maxHR: d.hr_max || 0, totalSteps: d.steps || 0, sleepHours: d.sleep_hours || 0, avgHrv: d.hrv || 0, avgStress: 0, spo2Avg: d.spo2_avg || 0, spo2Min: d.spo2_min || 0, calories: d.calories || 0, sleepQuality: d.sleep_quality || 0 }; }); var s = dbData.summary || {}; summary = { avgHeartRate: s.avg_hr || 0, minHeartRate: s.min_hr || 0, maxHeartRate: s.max_hr || 0, avgHrv: s.avg_hrv || 0, totalSteps: s.total_steps || 0, sleepHours: s.avg_sleep || 0, sleepQuality: s.avg_sleep_quality || 0, avgStress: 0, avgSpo2: s.avg_spo2 || 0, minSpo2: s.min_spo2 || 0, totalCalories: s.total_calories || 0, alertCount: s.total_alerts || 0 }; alertsList = (dbData.alerts || []).map(function(a) { return { type: a.type, message: a.message || a.type, severity: a.severity || 'MEDIUM', timestamp: a.timestamp || '' }; }); } else { // Fallback: use biometrics REST API if report-api not available yet console.warn('REPORT: DB API unavailable, falling back to REST API. Error:', dbData ? JSON.stringify(dbData).substring(0,200) : 'null response'); statusEl.innerHTML=' Fallback: using REST API...'; var apiPeriod=daysDiff<=1?'24h':daysDiff<=7?'7d':'30d'; var results=await Promise.all([fetchWearerBiometricData(wearerId,apiPeriod),fetchWearerAlerts(wearerId,apiPeriod)]); var bioData=results[0],alertData=results[1]; var dataPoints=(bioData.dataPoints||[]); if (bioData.heartRate && Array.isArray(bioData.heartRate)) { var hrMap={}; bioData.heartRate.forEach(function(r){var dk=(r.timestamp||'').substring(0,10);if(!hrMap[dk])hrMap[dk]={ts:r.timestamp,bpms:[]};hrMap[dk].bpms.push(r.bpm||0);}); dataPoints=Object.keys(hrMap).map(function(dk){var hh=hrMap[dk];var avg=Math.round(hh.bpms.reduce(function(a,b){return a+b},0)/hh.bpms.length);return{timestamp:hh.ts,heartRate:avg,hrv:0,steps:0,stress:0,sleep:'awake'};}); } summary=rptRecalcSummary(dataPoints); if(summary.avgHeartRate===0&&bioData.heartRate&&bioData.heartRate.length>0){var allBpms=bioData.heartRate.map(function(r){return r.bpm||0}).filter(function(v){return v>0});if(allBpms.length>0){summary.avgHeartRate=Math.round(allBpms.reduce(function(a,b){return a+b},0)/allBpms.length);summary.minHeartRate=Math.min.apply(null,allBpms);summary.maxHeartRate=Math.max.apply(null,allBpms);}} alertsList=(alertData.alerts||(bioData.summary&&bioData.summary.alerts)||[]); dailyData=rptGroupByDay(dataPoints); } console.log('REPORT: Final summary:', JSON.stringify(summary)); console.log('REPORT: Final dailyData:', dailyData.length, 'days'); console.log('REPORT: Final summary:', JSON.stringify(summary)); console.log('REPORT: Final dailyData:', dailyData.length, 'days'); statusEl.innerHTML=' '+(t('chat_report_generating_pdf')||'Generating report...'); await new Promise(function(r){setTimeout(r,50)}); var reportResult; // Use selected period days, not just days with data var periodDays = daysDiff || dailyData.length || 1; // Inject periodDays into summary so report functions can use it summary.periodDays = periodDays; summary.daysWithData = dailyData.length; if(reportType==='literary'){reportResult=buildLiteraryReport(wearer,summary,dailyData,alertsList,dateFrom,dateTo);} else{reportResult=buildGraphicReport(wearer,summary,dailyData,alertsList,dateFrom,dateTo);} statusEl.remove(); var reportTitle=wearer.firstName+' '+wearer.lastName+' — '+reportTypeLabel; var reportDiv=document.createElement('div'); reportDiv.innerHTML='
'+(t('report_ready')||'Report ready')+' — '+reportTitle+'
'+reportTypeLabel+' · '+dateFrom+' → '+dateTo+'
'; botBubble.appendChild(reportDiv); generatedReports.unshift({id:'rpt-'+Date.now(),title:reportTitle,date:new Date().toLocaleString(),type:reportType,wearerName:wearer.firstName+' '+wearer.lastName,wearerId:wearerId,dateFrom:dateFrom,dateTo:dateTo,reportHtml:reportResult?reportResult.html:'',reportHasCharts:reportResult?reportResult.hasCharts:false,reportWindowTitle:reportResult?reportResult.title:reportTitle}); renderReportsList(); acHistory.push({role:'assistant',content:(t('report_ready')||'Report ready')+' — '+reportTitle}); document.getElementById('ac-messages').scrollTop=99999; }catch(err){ console.error('Report error:',err); statusEl.remove(); var errDiv=document.createElement('div'); errDiv.style.cssText='color:#fc8181;font-size:12px;'; errDiv.textContent=(t('report_error')||'Report generation failed')+': '+err.message; botBubble.appendChild(errDiv); } btn.disabled=false; btn.innerHTML=originalHTML; } function renderReportsList() { const container = document.getElementById('reportsListContainer'); if (!container) return; if (generatedReports.length === 0) { container.innerHTML = `
${t('no_reports_yet') || 'No reports generated yet'}
`; return; } container.innerHTML = generatedReports.map(r => `
Raport ${r.wearerName || ''}
${r.date}
${r.dateFrom} → ${r.dateTo}
`).join(''); } function downloadReportById(reportId) { var report = generatedReports.find(function(r){return r.id === reportId}); if (!report) return; if (report.reportHtml) { rptOpenPrintWindow(report.reportWindowTitle || report.title, report.reportHtml, report.reportHasCharts || false); } else if (report.pdfBlob) { var wearer = wearers.find(function(w){return w.id === report.wearerId}) || { firstName: 'Unknown', lastName: '' }; var url = URL.createObjectURL(report.pdfBlob); var a = document.createElement('a'); a.href = url; a.download = 'AlvoTriX_Report_' + (wearer.firstName || 'Report') + '_' + (report.type || 'health') + '_' + (report.dateFrom || '') + '.pdf'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } else { alert('Report data expired. Please generate a new report.'); } } // ===================================================== // PREVIEW CHART (for report type selector) // ===================================================== function initPreviewChart() { const canvas = document.getElementById('previewChart'); if (!canvas || !window.Chart) return; const ctx = canvas.getContext('2d'); if (canvas._chartInstance) canvas._chartInstance.destroy(); canvas._chartInstance = new Chart(ctx, { type: 'line', data: { labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], datasets: [{ label: 'Heart Rate', data: [72, 75, 68, 80, 73, 71, 76], borderColor: '#06b6d4', backgroundColor: 'rgba(6,182,212,0.1)', tension: 0.4, fill: true, pointRadius: 2, borderWidth: 2 }] }, options: { responsive: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { display: false, min: 60, max: 90 } } } }); } // ===================================================== // GEOFENCE // ===================================================== let geofenceMap = null; let geofenceZones = []; let geofenceWearerId = null; let geofenceDeviceId = null; let newZoneMarker = null; let newZoneCircle = null; let zoneCircles = []; function openGeofenceModal(wearerId) { const w = wearers.find(x => x.id === wearerId); if (!w) return; geofenceWearerId = wearerId; geofenceDeviceId = w.deviceId || w.license; document.getElementById('geofenceTitle').textContent = (t('geofence_title') || 'Geofence') + ' — ' + w.firstName + ' ' + w.lastName; document.getElementById('geofenceModal').classList.add('active'); document.getElementById('addZoneForm').style.display = 'none'; // Initialize Leaflet map — wait for the modal to be fully rendered // before creating the map, otherwise Leaflet can't calculate tile positions. setTimeout(() => { if (geofenceMap) { geofenceMap.remove(); geofenceMap = null; } geofenceMap = L.map('geofenceMap').setView([47.15, 27.59], 13); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap' }).addTo(geofenceMap); // Force Leaflet to recalculate container size (fixes blank/grey tiles // when the map is initialized inside a modal that was just shown) setTimeout(() => { geofenceMap.invalidateSize(); }, 100); // Click to place new zone geofenceMap.on('click', function(e) { if (document.getElementById('addZoneForm').style.display === 'none') return; placeZoneMarker(e.latlng); }); loadGeofenceData(); }, 300); } async function loadGeofenceData() { try { const token = getToken(); // Load zones const zonesRes = await fetch(`${CONFIG.API_BASE_URL}/geofence/zones/${geofenceWearerId}`, { headers: { 'Authorization': `Bearer ${token}` } }); if (zonesRes.ok) { const data = await zonesRes.json(); geofenceZones = data.zones || []; } else { geofenceZones = []; } // Load current location try { const locRes = await fetch(`${CONFIG.API_BASE_URL}/geofence/location/${geofenceWearerId}`, { headers: { 'Authorization': `Bearer ${token}` } }); if (locRes.ok) { const locData = await locRes.json(); if (locData.lat && locData.lng) { geofenceMap.setView([locData.lat, locData.lng], 14); L.marker([locData.lat, locData.lng], { icon: L.divIcon({ className: 'current-location-marker', html: '📍', iconSize: [24, 24], iconAnchor: [12, 24] }) }).addTo(geofenceMap).bindPopup(t('geofence_current_location') || 'Current Location'); } } } catch (e) { /* location optional */ } renderGeofenceZones(); // Re-validate tile positions after data load may have moved/resized the map if (geofenceMap) setTimeout(() => { geofenceMap.invalidateSize(); }, 100); } catch (err) { console.error('Geofence load error:', err); } } function renderGeofenceZones() { // Clear old circles zoneCircles.forEach(c => geofenceMap.removeLayer(c)); zoneCircles = []; // Draw zones on map geofenceZones.forEach(z => { const color = z.type === 'safe' ? '#059669' : '#dc2626'; const circle = L.circle([z.lat, z.lng], { radius: z.radius, color: color, fillColor: color, fillOpacity: 0.15, weight: 2 }).addTo(geofenceMap).bindPopup(`${z.name}
${z.type} · ${z.radius}m`); zoneCircles.push(circle); }); // Fit bounds if zones exist if (geofenceZones.length > 0 && zoneCircles.length > 0) { const group = L.featureGroup(zoneCircles); geofenceMap.fitBounds(group.getBounds().pad(0.3)); } // Render zones list const list = document.getElementById('geofenceZonesList'); if (geofenceZones.length === 0) { list.innerHTML = `
${t('geofence_no_zones') || 'No zones configured'}
`; return; } list.innerHTML = geofenceZones.map(z => `
${z.type === 'safe' ? (t('geofence_safe') || 'Safe') : (t('geofence_danger') || 'Danger')} ${z.name} ${z.radius}m
`).join(''); } function startAddZone() { document.getElementById('addZoneForm').style.display = ''; document.getElementById('newZoneName').value = ''; document.getElementById('newZoneType').value = 'safe'; document.getElementById('newZoneRadius').value = 200; document.getElementById('radiusDisplay').textContent = '200'; if (newZoneMarker) { geofenceMap.removeLayer(newZoneMarker); newZoneMarker = null; } if (newZoneCircle) { geofenceMap.removeLayer(newZoneCircle); newZoneCircle = null; } } function cancelAddZone() { document.getElementById('addZoneForm').style.display = 'none'; if (newZoneMarker) { geofenceMap.removeLayer(newZoneMarker); newZoneMarker = null; } if (newZoneCircle) { geofenceMap.removeLayer(newZoneCircle); newZoneCircle = null; } } function placeZoneMarker(latlng) { if (newZoneMarker) geofenceMap.removeLayer(newZoneMarker); if (newZoneCircle) geofenceMap.removeLayer(newZoneCircle); newZoneMarker = L.marker(latlng).addTo(geofenceMap); const radius = parseInt(document.getElementById('newZoneRadius').value) || 200; const type = document.getElementById('newZoneType').value; const color = type === 'safe' ? '#059669' : '#dc2626'; newZoneCircle = L.circle(latlng, { radius, color, fillColor: color, fillOpacity: 0.15, weight: 2 }).addTo(geofenceMap); } function updatePreviewCircle() { if (!newZoneMarker || !newZoneCircle) return; const radius = parseInt(document.getElementById('newZoneRadius').value) || 200; newZoneCircle.setRadius(radius); } async function confirmAddZone() { const name = document.getElementById('newZoneName').value.trim(); if (!name) { alert('Please enter a zone name'); return; } if (!newZoneMarker) { alert(t('geofence_click_map') || 'Click on map to place zone center'); return; } const latlng = newZoneMarker.getLatLng(); const radius = parseInt(document.getElementById('newZoneRadius').value) || 200; const type = document.getElementById('newZoneType').value; try { const token = getToken(); const res = await fetch(`${CONFIG.API_BASE_URL}/geofence/zones/${geofenceWearerId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ name, type, lat: latlng.lat, lng: latlng.lng, radius }) }); if (!res.ok) throw new Error('Failed to save zone'); const data = await res.json(); geofenceZones.push({ id: data.id || ('z-' + Date.now()), name, type, lat: latlng.lat, lng: latlng.lng, radius }); cancelAddZone(); renderGeofenceZones(); } catch (err) { console.error('Add zone error:', err); alert('Failed to save zone'); } } async function deleteZone(zoneId) { if (!confirm(t('geofence_delete_zone') || 'Delete this zone?')) return; try { const token = getToken(); await fetch(`${CONFIG.API_BASE_URL}/geofence/zones/${zoneId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); geofenceZones = geofenceZones.filter(z => z.id !== zoneId); renderGeofenceZones(); } catch (err) { console.error('Delete zone error:', err); alert('Failed to delete zone'); } } // ===================================================== // MODALS // ===================================================== function closeModal(id) { document.getElementById(id).classList.remove('active'); } async function openEditModal(wearerId) { const w = wearers.find(x => x.id === wearerId); if (!w) return; // Refresh modules inventory so Edit reflects the current allocation (source of truth). await loadModulesInventory(); // Grace period warning in modal const gpWarning = document.getElementById('editGracePeriodWarning'); if (w.subscriptionEndDate) { const endDate = new Date(w.subscriptionEndDate); const now = new Date(); const graceEnd = new Date(endDate); graceEnd.setDate(graceEnd.getDate() + 30); if (now > endDate && Math.ceil((graceEnd - now) / 86400000) > 0) { gpWarning.style.display = 'flex'; document.getElementById('editGracePeriodText').textContent = t('grace_period_warning'); document.getElementById('editRenewBtn').textContent = t('renew_now'); } else { gpWarning.style.display = 'none'; } } else { gpWarning.style.display = 'none'; } document.getElementById('editWearerId').value = w.id; document.getElementById('editLicense').value = w.license; const dtBadge = document.getElementById('editDeviceTypeBadge'); const dt = (w.deviceType || 'core').toUpperCase(); dtBadge.textContent = dt; dtBadge.style.background = w.deviceType === 'gateway' ? 'rgba(6,182,212,0.12)' : 'rgba(124,58,237,0.12)'; dtBadge.style.color = w.deviceType === 'gateway' ? '#0891b2' : '#7c3aed'; document.getElementById('editFirstName').value = w.firstName; document.getElementById('editLastName').value = w.lastName; document.getElementById('editWearerEmail').value = w.email || ''; document.getElementById('editSex').value = w.sex || ''; document.getElementById('editAge').value = w.age || ''; // Populate phone prefix dropdowns and number fields document.getElementById('editPrefix1').innerHTML = getPhonePrefixOptions(w.phone1); document.getElementById('editPhone1').value = extractPhoneNumber(w.phone1); document.getElementById('editEmail1').value = w.email1 || ''; document.getElementById('editPrefix2').innerHTML = getPhonePrefixOptions(w.phone2); document.getElementById('editPhone2').value = extractPhoneNumber(w.phone2); document.getElementById('editEmail2').value = w.email2 || ''; document.getElementById('editPrefix3').innerHTML = getPhonePrefixOptions(w.phone3); document.getElementById('editPhone3').value = extractPhoneNumber(w.phone3); document.getElementById('editEmail3').value = w.email3 || ''; // Consent section for 18+ const editConsentSection = document.getElementById('editConsentSection'); const editConsentText = document.getElementById('editConsentText'); const editConsentCb = document.getElementById('editConsentCheckbox'); editConsentText.innerHTML = t('consent_text') + ' ' + t('consent_terms') + ', ' + t('consent_privacy') + ' ' + t('consent_and') + ' ' + t('consent_gdpr') + '.'; if (parseInt(w.age) >= 18) { editConsentSection.style.display = ''; editConsentCb.checked = false; } else { editConsentSection.style.display = 'none'; editConsentCb.checked = false; } // Listen for age changes in edit modal const editAgeInput = document.getElementById('editAge'); editAgeInput.onchange = editAgeInput.oninput = function() { const age = parseInt(this.value); if (age >= 18) { editConsentSection.style.display = ''; } else { editConsentSection.style.display = 'none'; editConsentCb.checked = false; } }; // Wire up Change Plan / Add Modules links with the current wearer ID, // so the shop knows which wearer to re-assign the new service to after payment. const changePlanLink = document.getElementById('editChangePlanLink'); const addModulesLink = document.getElementById('editAddModulesLink'); if (changePlanLink) changePlanLink.href = `/?changePlan=1&wearer=${encodeURIComponent(w.id)}#packages`; if (addModulesLink) addModulesLink.href = `/?addModules=1&wearer=${encodeURIComponent(w.id)}#modules`; // Display all payer modules as checkboxes — checked if assigned to this wearer. const svcContainer = document.getElementById('editServicesContainer'); const assignedToThis = (Array.isArray(_modulesAssigned) ? _modulesAssigned : []) .filter(m => String(m.wearerId) === String(w.id)); const assignedIds = new Set(assignedToThis.map(m => String(m.id))); // All modules (assigned + unassigned) belong to this payer const allModules = [].concat( (_modulesData || []), (Array.isArray(_modulesAssigned) ? _modulesAssigned : []) ); // Deduplicate by id const seen = new Set(); const uniqueModules = []; allModules.forEach(m => { if (!seen.has(String(m.id))) { seen.add(String(m.id)); uniqueModules.push(m); } }); if (uniqueModules.length === 0) { svcContainer.innerHTML = '
' + (t('no_modules_purchased') || 'No modules purchased yet.') + ' ' + (t('buy_modules') || 'Buy modules') + '
'; } else { svcContainer.innerHTML = uniqueModules.map(m => { const isChecked = assignedIds.has(String(m.id)); const assignedTo = m.wearerId && !assignedIds.has(String(m.id)) ? m.wearerName : null; const disabledAttr = assignedTo ? 'disabled' : ''; const labelColor = assignedTo ? 'color:var(--text-muted)' : 'color:var(--text-primary)'; const note = assignedTo ? ' (' + assignedTo + ')' : ''; return ''; }).join(''); } // Populate monitoring bar const monBar = document.getElementById('editMonitoringBar'); const isPaused = w.monitoringPaused; const hasSchedule = w.monitorStartHour != null && w.monitorEndHour != null; const fromVal = hasSchedule ? String(w.monitorStartHour).padStart(2,'0') + ':' + String(w.monitorStartMin||0).padStart(2,'0') : ''; const toVal = hasSchedule ? String(w.monitorEndHour).padStart(2,'0') + ':' + String(w.monitorEndMin||0).padStart(2,'0') : ''; monBar.innerHTML = ` |
${t('monitoring_schedule')}
`; // Wire up cancel subscription button const cancelBtn = document.getElementById('editCancelSubBtn'); cancelBtn.onclick = () => cancelSubscription(w.id); if (w.subscriptionStatus === 'cancelling') { cancelBtn.style.display = 'none'; } else { cancelBtn.style.display = ''; } // Wire up unbind device button (visible only if device is bound) const unbindBtn = document.getElementById('editUnbindBtn'); unbindBtn.onclick = () => unbindDevice(w.id, w.firstName + ' ' + w.lastName); unbindBtn.style.display = w.deviceId ? '' : 'none'; document.getElementById('editWearerModal').classList.add('active'); } async function saveWearer() { const id = document.getElementById('editWearerId').value; const w = wearers.find(x => x.id === id); if (!w) return; const firstName = document.getElementById('editFirstName').value.trim(); const lastName = document.getElementById('editLastName').value.trim(); const email = document.getElementById('editWearerEmail').value.trim(); const sex = document.getElementById('editSex').value; const age = document.getElementById('editAge').value; const phone1 = buildFullPhone('editPrefix1', 'editPhone1'); const phone2 = buildFullPhone('editPrefix2', 'editPhone2'); const phone3 = buildFullPhone('editPrefix3', 'editPhone3'); const email1 = document.getElementById('editEmail1').value.trim(); const email2 = document.getElementById('editEmail2').value.trim(); const email3 = document.getElementById('editEmail3').value.trim(); // Validation if (!firstName || !lastName) { alert('Please enter first and last name.'); return; } if (!sex) { alert(t('sex_required')); return; } if (!age || age < 1) { alert('Please enter a valid age.'); return; } // Validate phone prefix: if number is entered, prefix must be selected const phoneChecks = [ { num: 'editPhone1', prefix: 'editPrefix1', label: '1' }, { num: 'editPhone2', prefix: 'editPrefix2', label: '2' }, { num: 'editPhone3', prefix: 'editPrefix3', label: '3' } ]; for (const pc of phoneChecks) { const numVal = document.getElementById(pc.num)?.value?.trim(); const prefixVal = document.getElementById(pc.prefix)?.value; if (numVal && !prefixVal) { alert(t('phone_prefix_missing')); return; } } // Consent check for 18+ if (parseInt(age) >= 18 && !document.getElementById('editConsentCheckbox').checked) { alert(t('consent_required')); return; } // Collect checked module IDs from checkboxes const checkedModuleIds = Array.from( document.querySelectorAll('input[name="editModuleCheck"]:checked:not(:disabled)') ).map(cb => String(cb.value)); // Determine which modules were previously assigned to this wearer const prevAssigned = (Array.isArray(_modulesAssigned) ? _modulesAssigned : []) .filter(m => String(m.wearerId) === String(id)); const prevIds = new Set(prevAssigned.map(m => String(m.id))); // Modules to assign (newly checked) const toAssign = checkedModuleIds.filter(mid => !prevIds.has(mid)); // Modules to unassign (previously assigned but now unchecked) const toUnassign = prevAssigned.filter(m => !checkedModuleIds.includes(String(m.id))).map(m => m.id); // Prepare wearer profile data for server const wearerData = { firstName, lastName, email, sex, age: parseInt(age), phone1, phone2, phone3, email1, email2, email3 }; try { const token = getToken(); // Save wearer profile to server const res = await fetch(`${CONFIG.API_BASE_URL}/wearers/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(wearerData) }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.message || 'Failed to save wearer'); } // Update local state on success Object.assign(w, wearerData); w.isConfigured = true; w.status = 'active'; // Assign new modules for (const mid of toAssign) { try { const assignRes = await fetch('/api/wearers/modules/assign', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ moduleId: parseInt(mid), wearerId: parseInt(id) }) }); if (!assignRes.ok) { await fetch('/api/wearers/modules/move', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ moduleId: parseInt(mid), toWearerId: parseInt(id) }) }); } } catch(assignErr) { console.warn('Module assign note:', mid, assignErr.message); } } // Unassign removed modules (return to inventory) for (const mid of toUnassign) { try { await fetch('/api/wearers/modules/unassign', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ moduleId: parseInt(mid) }) }); } catch(unErr) { console.warn('Module unassign note:', mid, unErr.message); } } filteredWearers = [...wearers]; await loadModulesInventory(); renderAllWearers(); closeModal('editWearerModal'); } catch (err) { console.error('Save wearer error:', err); alert(err.message || 'Failed to save wearer. Please try again.'); } } // ===================================================== // LOAD SMS CREDITS BALANCE async function loadSmsCredits() { const token = sessionStorage.getItem('alvotrix_token') || localStorage.getItem('alvotrix_token'); if (!token) return; try { const res = await fetch(CONFIG.API_BASE_URL + '/payments/sms-credits', { headers: { 'Authorization': 'Bearer ' + token } }); const data = await res.json(); const el = document.getElementById('smsCreditsDisplay'); const walletEl = document.getElementById('smsWalletDisplay'); if (!el) return; const total = parseInt(data.credits ?? data.credits_remaining ?? data.total ?? 0); el.style.display = 'inline-flex'; if (total > 0) { el.textContent = total + ' SMS'; el.style.background = 'rgba(5,150,105,0.15)'; el.style.borderColor = 'rgba(5,150,105,0.3)'; el.style.color = '#059669'; } else { el.textContent = '0 SMS'; el.style.background = 'rgba(220,38,38,0.15)'; el.style.borderColor = 'rgba(220,38,38,0.3)'; el.style.color = '#dc2626'; } // Update wallet display (remaining purchased credits) if (walletEl) { walletEl.style.display = 'inline-flex'; walletEl.style.alignItems = 'center'; walletEl.style.gap = '4px'; const label = t('sms_available'); if (total > 0) { walletEl.textContent = '📨 ' + label + ': ' + total + ' SMS'; walletEl.style.background = 'rgba(5,150,105,0.12)'; walletEl.style.border = '1.5px solid rgba(5,150,105,0.35)'; walletEl.style.color = '#059669'; } else { walletEl.textContent = '📨 ' + label + ': 0 SMS'; walletEl.style.background = 'rgba(220,38,38,0.12)'; walletEl.style.border = '1.5px solid rgba(220,38,38,0.35)'; walletEl.style.color = '#dc2626'; } } } catch (e) { /* silently ignore */ } } // BUY SMS CREDITS // ===================================================== function buySmsFromDropdown() { const select = document.getElementById('smsPackSelect'); buySmsCreditsCommon(parseInt(select.value)); } // Update SMS dropdown options with prices function updateSmsDropdownPrices() { const select = document.getElementById('smsPackSelect'); if (!select) return; const prices = {10: 1.90, 30: 4.90, 100: 12.90}; select.innerHTML = Object.entries(prices).map(([qty, price]) => `` ).join(''); } function buySmsCreditsCommon(packSize) { const priceStr = C.symbol + toLocal({10: 1.90, 30: 4.90, 100: 12.90}[packSize] || 1.90).toFixed(2); const msg = { en: `Buy ${packSize} SMS credits?\nPrice: ${priceStr}\n\nYou will be redirected to payment.`, de: `${packSize} SMS-Guthaben kaufen?\nPreis: ${priceStr}\n\nSie werden zur Zahlung weitergeleitet.`, fr: `Acheter ${packSize} crédits SMS ?\nPrix : ${priceStr}\n\nVous serez redirigé vers le paiement.`, ro: `Cumperi ${packSize} credite SMS?\nPreț: ${priceStr}\n\nVei fi redirecționat la plată.`, es: `¿Comprar ${packSize} créditos SMS?\nPrecio: ${priceStr}\n\nSerá redirigido al pago.`, it: `Acquistare ${packSize} crediti SMS?\nPrezzo: ${priceStr}\n\nVerrai reindirizzato al pagamento.`, nl: `${packSize} SMS-tegoed kopen?\nPrijs: ${priceStr}\n\nU wordt doorgestuurd naar de betaling.`, sv: `Köp ${packSize} SMS-krediter?\nPris: ${priceStr}\n\nDu kommer att omdirigeras till betalning.`, pl: `Kupić ${packSize} kredytów SMS?\nCena: ${priceStr}\n\nZostaniesz przekierowany do płatności.`, pt: `Comprar ${packSize} créditos SMS?\nPreço: ${priceStr}\n\nSerá redirecionado para o pagamento.` }; if (confirm(msg[currentLang] || msg.en)) { const token = sessionStorage.getItem('alvotrix_token') || localStorage.getItem('alvotrix_token'); const buyBtn = document.querySelector('.btn-buy-sms-pack'); if (buyBtn) { buyBtn.disabled = true; buyBtn.textContent = '...'; } fetch(CONFIG.API_BASE_URL + '/payments/create-sms-checkout', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ quantity: packSize }) }) .then(r => r.json()) .then(data => { if (data.url) { window.location.href = data.url; } else { alert(t('error_prefix') + (data.error || '')); if (buyBtn) { buyBtn.disabled = false; buyBtn.textContent = t('buy'); } } }) .catch(err => { alert(t('purchase_connection_error') + err.message); if (buyBtn) { buyBtn.disabled = false; buyBtn.textContent = t('buy'); } }); } } // ===================================================== // CANCEL SUBSCRIPTION // ===================================================== async function cancelSubscription(wearerId) { const wearer = wearers.find(w => w.id === wearerId); if (!wearer) return; const cancelMsg = { en: `Are you sure you want to cancel the subscription for ${wearer.firstName} ${wearer.lastName} (${wearer.planName})?\n\nThe service will remain active until the end of your current billing period.`, de: `Möchten Sie das Abonnement für ${wearer.firstName} ${wearer.lastName} (${wearer.planName}) wirklich kündigen?\n\nDer Service bleibt bis zum Ende des aktuellen Abrechnungszeitraums aktiv.`, fr: `Êtes-vous sûr de vouloir annuler l'abonnement pour ${wearer.firstName} ${wearer.lastName} (${wearer.planName}) ?\n\nLe service restera actif jusqu'à la fin de la période de facturation.`, ro: `Sigur doriți să anulați abonamentul pentru ${wearer.firstName} ${wearer.lastName} (${wearer.planName})?\n\nServiciul rămâne activ până la sfârșitul perioadei curente de facturare.`, es: `¿Está seguro de cancelar la suscripción de ${wearer.firstName} ${wearer.lastName} (${wearer.planName})?\n\nEl servicio permanecerá activo hasta el final del período de facturación.`, it: `Sei sicuro di voler annullare l'abbonamento per ${wearer.firstName} ${wearer.lastName} (${wearer.planName})?\n\nIl servizio rimarrà attivo fino alla fine del periodo di fatturazione.`, nl: `Weet u zeker dat u het abonnement voor ${wearer.firstName} ${wearer.lastName} (${wearer.planName}) wilt opzeggen?\n\nDe service blijft actief tot het einde van de factureringsperiode.`, sv: `Är du säker på att du vill avsluta prenumerationen för ${wearer.firstName} ${wearer.lastName} (${wearer.planName})?\n\nTjänsten förblir aktiv till slutet av faktureringsperioden.`, pl: `Czy na pewno chcesz anulować subskrypcję dla ${wearer.firstName} ${wearer.lastName} (${wearer.planName})?\n\nUsługa pozostanie aktywna do końca bieżącego okresu rozliczeniowego.`, pt: `Tem certeza de que deseja cancelar a assinatura de ${wearer.firstName} ${wearer.lastName} (${wearer.planName})?\n\nO serviço permanecerá ativo até o final do período de faturamento.` }; if (!confirm(cancelMsg[currentLang] || cancelMsg.en)) return; // Double confirm for safety const doubleConfirm = { en: '⚠️ FINAL CONFIRMATION: This will cancel the recurring payment. Type "CANCEL" to confirm.', de: '⚠️ LETZTE BESTÄTIGUNG: Dies kündigt die wiederkehrende Zahlung. Geben Sie "CANCEL" ein.', fr: '⚠️ CONFIRMATION FINALE: Ceci annulera le paiement récurrent. Tapez "CANCEL" pour confirmer.', ro: '⚠️ CONFIRMARE FINALĂ: Aceasta va anula plata recurentă. Tastați "CANCEL" pentru confirmare.', es: '⚠️ CONFIRMACIÓN FINAL: Esto cancelará el pago recurrente. Escriba "CANCEL" para confirmar.', it: '⚠️ CONFERMA FINALE: Questo annullerà il pagamento ricorrente. Digita "CANCEL" per confermare.', nl: '⚠️ LAATSTE BEVESTIGING: Dit annuleert de terugkerende betaling. Typ "CANCEL" om te bevestigen.', sv: '⚠️ SLUTGILTIG BEKRÄFTELSE: Detta avbryter den återkommande betalningen. Skriv "CANCEL" för att bekräfta.', pl: '⚠️ OSTATECZNE POTWIERDZENIE: To anuluje płatność cykliczną. Wpisz "CANCEL" aby potwierdzić.', pt: '⚠️ CONFIRMAÇÃO FINAL: Isso cancelará o pagamento recorrente. Digite "CANCEL" para confirmar.' }; const typed = prompt(doubleConfirm[currentLang] || doubleConfirm.en); if (!typed || typed.toUpperCase() !== 'CANCEL') { return; } try { const token = getToken(); const res = await fetch(`${CONFIG.API_BASE_URL}/subscriptions/cancel`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ wearer_id: wearerId, service_id: wearer.assignedServiceId }) }); const data = await res.json(); if (!res.ok) { throw new Error(data.message || 'Failed to cancel subscription'); } // Update local state const successMsg = { en: `✅ Subscription cancelled for ${wearer.firstName}. Service remains active until ${data.period_end || 'end of billing period'}.`, de: `✅ Abonnement für ${wearer.firstName} gekündigt. Service aktiv bis ${data.period_end || 'Ende des Abrechnungszeitraums'}.`, fr: `✅ Abonnement annulé pour ${wearer.firstName}. Service actif jusqu'au ${data.period_end || 'fin de la période'}.`, ro: `✅ Abonament anulat pentru ${wearer.firstName}. Serviciul rămâne activ până la ${data.period_end || 'sfârșitul perioadei'}.`, es: `✅ Suscripción cancelada para ${wearer.firstName}. Servicio activo hasta ${data.period_end || 'fin del período'}.`, it: `✅ Abbonamento annullato per ${wearer.firstName}. Servizio attivo fino al ${data.period_end || 'fine del periodo'}.`, nl: `✅ Abonnement opgezegd voor ${wearer.firstName}. Service actief tot ${data.period_end || 'einde van de periode'}.`, sv: `✅ Prenumeration avslutad för ${wearer.firstName}. Tjänsten aktiv till ${data.period_end || 'slutet av perioden'}.`, pl: `✅ Subskrypcja anulowana dla ${wearer.firstName}. Usługa aktywna do ${data.period_end || 'końca okresu'}.`, pt: `✅ Assinatura cancelada para ${wearer.firstName}. Serviço ativo até ${data.period_end || 'fim do período'}.` }; alert(successMsg[currentLang] || successMsg.en); // Mark as cancelling in UI wearer.subscriptionStatus = 'cancelling'; wearer.cancelAt = data.period_end || null; filteredWearers = [...wearers]; renderAllWearers(); } catch (err) { console.error('Cancel subscription error:', err); const errorMsg = { en: 'Failed to cancel subscription. Please try again or contact support.', de: 'Kündigung fehlgeschlagen. Bitte erneut versuchen oder Support kontaktieren.', fr: 'Échec de l\'annulation. Veuillez réessayer ou contacter le support.', ro: 'Anularea a eșuat. Încercați din nou sau contactați suportul.', es: 'Error al cancelar. Inténtalo de nuevo o contacta con soporte.', it: 'Annullamento fallito. Riprova o contatta il supporto.', nl: 'Opzeggen mislukt. Probeer opnieuw of neem contact op met support.', sv: 'Avslutning misslyckades. Försök igen eller kontakta support.', pl: 'Anulowanie nie powiodło się. Spróbuj ponownie lub skontaktuj się z pomocą.', pt: 'Falha ao cancelar. Tente novamente ou entre em contato com o suporte.' }; alert(errorMsg[currentLang] || errorMsg.en); } } // ===================================================== // UNBIND DEVICE (New Version) // ===================================================== async function unbindDevice(wearerId, wearerName) { const msgs = { en: `Disconnect device for ${wearerName}?\n\nUse this when installing a new app version. The device will reconnect automatically on next activation.`, ro: `Deconectați dispozitivul pentru ${wearerName}?\n\nFolosiți această opțiune la instalarea unei versiuni noi. Dispozitivul se va reconecta automat la următoarea activare.` }; if (!confirm(msgs[currentLang] || msgs.en)) return; try { const token = getToken(); const res = await fetch(`${CONFIG.API_BASE_URL}/wearers/${wearerId}/unbind-device`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); const data = await res.json(); if (!res.ok) throw new Error(data.message || 'Failed'); const wearer = wearers.find(w => w.id === wearerId); if (wearer) { wearer.deviceId = null; wearer.isOnline = false; } renderAllWearers(); const ok = { en: '✅ Device disconnected. Activate the new version on the device.', ro: '✅ Dispozitiv deconectat. Activați noua versiune pe dispozitiv.' }; alert(ok[currentLang] || ok.en); } catch (err) { console.error('Unbind error:', err); const fail = { en: 'Failed to disconnect device.', ro: 'Deconectarea a eșuat.' }; alert(fail[currentLang] || fail.en); } } // ===================================================== // MONITORING CONTROLS // ===================================================== async function toggleMonitoring(wearerId) { const wearer = wearers.find(w => w.id == wearerId); if (!wearer) return; const newState = !wearer.monitoringPaused; const msg = newState ? t('pause_confirm').replace('{name}', wearer.firstName) : t('resume_confirm').replace('{name}', wearer.firstName); if (!confirm(msg)) return; try { const token = getToken(); const res = await fetch(`${CONFIG.API_BASE_URL}/wearers/${wearerId}/monitoring-pause`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ paused: newState }) }); if (!res.ok) throw new Error('Failed'); wearer.monitoringPaused = newState; filteredWearers = [...wearers]; renderAllWearers(); // Refresh monitoring bar in edit modal if open if (document.getElementById('editWearerModal').classList.contains('active')) { openEditModal(wearerId); } } catch (err) { console.error('Toggle monitoring error:', err); alert('Error updating monitoring status'); } } async function saveSchedule(wearerId) { const fromEl = document.getElementById(`monFrom-${wearerId}`); const toEl = document.getElementById(`monTo-${wearerId}`); if (!fromEl || !toEl || !fromEl.value || !toEl.value) { alert('Please set both From and To times'); return; } const [startH, startM] = fromEl.value.split(':').map(Number); const [endH, endM] = toEl.value.split(':').map(Number); try { const token = getToken(); const res = await fetch(`${CONFIG.API_BASE_URL}/wearers/${wearerId}/monitoring-schedule`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ start_hour: startH, start_minute: startM, end_hour: endH, end_minute: endM }) }); if (!res.ok) throw new Error('Failed'); const wearer = wearers.find(w => w.id == wearerId); if (wearer) { wearer.monitorStartHour = startH; wearer.monitorStartMin = startM; wearer.monitorEndHour = endH; wearer.monitorEndMin = endM; } filteredWearers = [...wearers]; renderAllWearers(); alert(t('schedule_saved')); } catch (err) { console.error('Save schedule error:', err); alert('Error saving schedule'); } } async function clearSchedule(wearerId) { try { const token = getToken(); const res = await fetch(`${CONFIG.API_BASE_URL}/wearers/${wearerId}/monitoring-schedule`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ start_hour: null, start_minute: null, end_hour: null, end_minute: null }) }); if (!res.ok) throw new Error('Failed'); const wearer = wearers.find(w => w.id == wearerId); if (wearer) { wearer.monitorStartHour = null; wearer.monitorStartMin = 0; wearer.monitorEndHour = null; wearer.monitorEndMin = 0; } filteredWearers = [...wearers]; renderAllWearers(); alert(t('schedule_cleared')); } catch (err) { console.error('Clear schedule error:', err); alert('Error clearing schedule'); } } // Modal-based schedule helpers (use editMonFrom/editMonTo instead of per-wearer IDs) async function saveScheduleFromModal(wearerId) { const fromEl = document.getElementById('editMonFrom'); const toEl = document.getElementById('editMonTo'); if (!fromEl || !toEl || !fromEl.value || !toEl.value) { alert('Please set both From and To times'); return; } const [startH, startM] = fromEl.value.split(':').map(Number); const [endH, endM] = toEl.value.split(':').map(Number); try { const token = getToken(); const res = await fetch(`${CONFIG.API_BASE_URL}/wearers/${wearerId}/monitoring-schedule`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ start_hour: startH, start_minute: startM, end_hour: endH, end_minute: endM }) }); if (!res.ok) throw new Error('Failed'); const wearer = wearers.find(w => w.id == wearerId); if (wearer) { wearer.monitorStartHour = startH; wearer.monitorStartMin = startM; wearer.monitorEndHour = endH; wearer.monitorEndMin = endM; } filteredWearers = [...wearers]; renderAllWearers(); alert(t('schedule_saved')); } catch (err) { console.error('Save schedule error:', err); alert('Error saving schedule'); } } async function clearScheduleFromModal(wearerId) { try { const token = getToken(); const res = await fetch(`${CONFIG.API_BASE_URL}/wearers/${wearerId}/monitoring-schedule`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ start_hour: null, start_minute: null, end_hour: null, end_minute: null }) }); if (!res.ok) throw new Error('Failed'); const wearer = wearers.find(w => w.id == wearerId); if (wearer) { wearer.monitorStartHour = null; wearer.monitorStartMin = 0; wearer.monitorEndHour = null; wearer.monitorEndMin = 0; } // Update the modal inputs document.getElementById('editMonFrom').value = ''; document.getElementById('editMonTo').value = ''; filteredWearers = [...wearers]; renderAllWearers(); alert(t('schedule_cleared')); } catch (err) { console.error('Clear schedule error:', err); alert('Error clearing schedule'); } } // Click outside modal to close document.querySelectorAll('.modal-overlay').forEach(o => { o.addEventListener('click', e => { if (e.target === o) o.classList.remove('active'); }); }); // ===================================================== // FORMS - Account Settings // ===================================================== function initForms() { // Profile Form - Save to server document.getElementById('profileForm').addEventListener('submit', async (e) => { e.preventDefault(); const firstName = document.getElementById('profileFirstName').value.trim(); const lastName = document.getElementById('profileLastName').value.trim(); if (!firstName || !lastName) { alert('Please fill in all fields.'); return; } const btn = e.target.querySelector('button[type="submit"]'); const originalText = btn.textContent; btn.textContent = 'Saving...'; btn.disabled = true; try { const token = getToken(); const res = await fetch(`${CONFIG.API_BASE_URL}/user/profile`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ firstName, lastName }) }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.message || 'Failed to save profile'); } // Update local state currentUser.firstName = firstName; currentUser.lastName = lastName; sessionStorage.setItem('alvotrix_user', JSON.stringify(currentUser)); if (localStorage.getItem('alvotrix_remember') === 'true') { localStorage.setItem('alvotrix_user', JSON.stringify(currentUser)); } document.getElementById('userNameDisplay').textContent = firstName; alert('Profile saved successfully!'); } catch (err) { console.error('Profile save error:', err); alert(err.message || 'Failed to save profile. Please try again.'); } btn.textContent = originalText; btn.disabled = false; }); // Password Form - Save to server document.getElementById('passwordForm').addEventListener('submit', async (e) => { e.preventDefault(); const currentPw = document.getElementById('currentPassword').value; const newPw = document.getElementById('newPassword').value; const confirmPw = document.getElementById('confirmPassword').value; if (!currentPw || !newPw || !confirmPw) { alert('Please fill in all password fields.'); return; } if (newPw !== confirmPw) { alert('New passwords do not match!'); return; } if (newPw.length < 8) { alert('New password must be at least 8 characters.'); return; } const btn = e.target.querySelector('button[type="submit"]'); const originalText = btn.textContent; btn.textContent = 'Updating...'; btn.disabled = true; try { const token = getToken(); const res = await fetch(`${CONFIG.API_BASE_URL}/user/password`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ currentPassword: currentPw, newPassword: newPw }) }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.message || 'Failed to update password'); } alert('Password updated successfully!'); e.target.reset(); } catch (err) { console.error('Password update error:', err); alert(err.message || 'Failed to update password. Please try again.'); } btn.textContent = originalText; btn.disabled = false; }); // Logout button document.getElementById('logoutBtn').addEventListener('click', handleLogout); } // Delete Account async function confirmDeleteWearer() { const id = document.getElementById('editWearerId').value; const w = wearers.find(x => x.id === id); if (!w) return; const confirmMsg = { en: `Are you sure you want to delete wearer "${w.firstName} ${w.lastName}"? Type DELETE to confirm:`, de: `Möchten Sie den Träger "${w.firstName} ${w.lastName}" wirklich löschen? Geben Sie DELETE ein, um zu bestätigen:`, fr: `Êtes-vous sûr de vouloir supprimer le porteur "${w.firstName} ${w.lastName}" ? Tapez DELETE pour confirmer :`, ro: `Sigur doriți să ștergeți purtătorul "${w.firstName} ${w.lastName}"? Tastați DELETE pentru a confirma:`, es: `¿Está seguro de que desea eliminar al portador "${w.firstName} ${w.lastName}"? Escriba DELETE para confirmar:`, it: `Sei sicuro di voler eliminare il portatore "${w.firstName} ${w.lastName}"? Digita DELETE per confermare:`, nl: `Weet u zeker dat u drager "${w.firstName} ${w.lastName}" wilt verwijderen? Typ DELETE om te bevestigen:`, sv: `Är du säker på att du vill ta bort bäraren "${w.firstName} ${w.lastName}"? Skriv DELETE för att bekräfta:`, pl: `Czy na pewno chcesz usunąć nosiciela "${w.firstName} ${w.lastName}"? Wpisz DELETE, aby potwierdzić:`, pt: `Tem certeza de que deseja excluir o portador "${w.firstName} ${w.lastName}"? Digite DELETE para confirmar:` }; if (prompt(confirmMsg[currentLang] || confirmMsg.en) !== 'DELETE') { return; } try { const token = getToken(); const res = await fetch(`${CONFIG.API_BASE_URL}/wearers/${id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); if (!res.ok) { const errData = await res.json(); throw new Error(errData.message || 'Failed to delete wearer'); } // Remove from local state wearers = wearers.filter(x => x.id !== id); filteredWearers = [...wearers]; if (currentWearerIndex >= wearers.length) { currentWearerIndex = Math.max(0, wearers.length - 1); } renderAllWearers(); closeModal('editWearerModal'); const successMsg = { en: 'Wearer deleted successfully.', de: 'Träger erfolgreich gelöscht.', fr: 'Porteur supprimé avec succès.', ro: 'Purtătorul a fost șters cu succes.', es: 'Portador eliminado con éxito.', it: 'Portatore eliminato con successo.', nl: 'Drager succesvol verwijderd.', sv: 'Bäraren har tagits bort.', pl: 'Nosiciel został usunięty.', pt: 'Portador excluído com sucesso.' }; alert(successMsg[currentLang] || successMsg.en); } catch (err) { console.error('Delete wearer error:', err); alert(err.message || 'Failed to delete wearer. Please try again.'); } } async function confirmDeleteAccount() { if (prompt('Type DELETE to confirm account deletion:') !== 'DELETE') { return; } try { const token = getToken(); const res = await fetch(`${CONFIG.API_BASE_URL}/user/account`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); if (!res.ok) { throw new Error('Failed to delete account'); } alert('Account deleted successfully.'); handleLogout(); } catch (err) { console.error('Delete account error:', err); alert(err.message || 'Failed to delete account. Please try again.'); } } // ===================================================== // UTILITIES // ===================================================== function scrollToTop() { window.scrollTo({ top: 0, behavior: 'smooth' }); }