mirror of
https://github.com/AmanTahiliani/FHIR-Sandbox.git
synced 2026-08-07 11:53:56 -04:00
1275 lines
62 KiB
HTML
1275 lines
62 KiB
HTML
{{template "base.html" .}}
|
||
|
||
{{define "nav"}}
|
||
<div class="flex items-center gap-4">
|
||
<a href="/patients" class="nav-link">All Patients</a>
|
||
<a href="/" class="nav-link">Home</a>
|
||
{{if .Practitioner}}
|
||
<div class="practitioner-mini-card">
|
||
<div class="avatar avatar-sm">
|
||
{{if .Practitioner.FirstName}}{{slice .Practitioner.FirstName 0 1}}{{end}}{{if .Practitioner.LastName}}{{slice .Practitioner.LastName 0 1}}{{end}}
|
||
</div>
|
||
<span class="text-xs font-medium">Dr. {{.Practitioner.LastName}}</span>
|
||
</div>
|
||
{{end}}
|
||
<form action="/logout" method="POST" class="flex items-center">
|
||
<button class="btn btn-danger btn-sm" type="submit">Logout</button>
|
||
</form>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{define "content"}}
|
||
|
||
{{/* ---- Flash Messages ---- */}}
|
||
{{if .Synced}}
|
||
<div class="flash-message mb-6">
|
||
<div class="flex items-center gap-2">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||
<div>
|
||
<strong class="font-medium text-main">Sync complete</strong>
|
||
<span class="text-sm text-muted">Data has been refreshed from the EHR.</span>
|
||
</div>
|
||
</div>
|
||
<button onclick="this.parentElement.style.display='none';" class="btn btn-icon text-muted">×</button>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .Patient}}
|
||
<div class="flex flex-col gap-6 pt-4">
|
||
{{/* ---- Top Header: Patient Banner ---- */}}
|
||
<div class="panel panel-padded flex items-center justify-between border-l-brand">
|
||
<div class="flex items-center gap-6 w-full">
|
||
<div class="avatar avatar-lg">
|
||
{{if .Patient.FirstName}}{{slice .Patient.FirstName 0 1}}{{end}}{{if .Patient.LastName}}{{slice .Patient.LastName 0 1}}{{end}}
|
||
</div>
|
||
<div class="flex flex-col gap-1 w-full">
|
||
<div class="flex items-center gap-3">
|
||
<h1 class="page-title m-0">
|
||
{{.Patient.LastName}}, {{.Patient.FirstName}} {{.Patient.MiddleName}}
|
||
</h1>
|
||
<span class="mrn-badge">MRN: {{orDash .Patient.MRN}}</span>
|
||
</div>
|
||
<div class="patient-meta-strip">
|
||
<span class="meta-item"><strong>DOB:</strong> {{formatDate .Patient.DOB}} ({{calculateAge .Patient.DOB}} yrs)</span>
|
||
<span class="meta-item"><strong>Gender:</strong> {{titleCase .Patient.Gender}}</span>
|
||
{{with .Patient.Email}}<span class="meta-item"><strong>Email:</strong> {{.}}</span>{{end}}
|
||
{{with primaryPhone .RawPatient}}<span class="meta-item"><strong>Phone:</strong> {{.}}</span>{{end}}
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-col items-end gap-2 shrink-0">
|
||
<form action="/dashboard/sync?patient_id={{.Patient.FHIRID}}" method="POST" class="flex items-center">
|
||
<button class="btn btn-primary" type="submit" id="syncBtn" onclick="onSyncClick()">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||
Refresh Chart
|
||
</button>
|
||
</form>
|
||
{{if .RimidiMatch}}
|
||
{{if .RimidiPatientKey}}
|
||
<a href="http://localhost:2222/providers/v2/diab/patient/labs/{{.RimidiPatientKey}}/" target="_blank" class="btn" style="background-color:#e91e63;color:white;border:none;" title="Launch patient in Rimidi">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||
Launch in Rimidi
|
||
</a>
|
||
{{else}}
|
||
<button class="btn" style="background-color:#e91e63;color:white;border:none;opacity:0.6;cursor:not-allowed;" title="Loading patient key..." disabled>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||
Launch in Rimidi
|
||
</button>
|
||
{{end}}
|
||
<button class="btn btn-outline" type="button" onclick="unlinkPatient('{{.Patient.FHIRID}}')" title="Unlink this patient from Rimidi" style="color:var(--color-danger);border-color:var(--color-danger);">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18"/><path d="M6 6l12 12"/></svg>
|
||
Unlink
|
||
</button>
|
||
{{else}}
|
||
<button class="btn btn-outline" type="button" onclick="findInRimidi('{{.Patient.FHIRID}}')" title="Search for this patient in the Rimidi system">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||
Find in Rimidi
|
||
</button>
|
||
{{end}}
|
||
<span class="text-xs text-muted">
|
||
Last Synced: {{if .LatestSync}}{{formatDateTime .LatestSync.SyncedAt}}{{else}}Never{{end}}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="dashboard-layout">
|
||
{{/* ---- Main Content Column ---- */}}
|
||
<div class="dashboard-main">
|
||
|
||
{{/* Clinical Summary Stats */}}
|
||
<div class="grid grid-cols-3 gap-4 lg:grid-cols-1">
|
||
<div class="panel panel-padded stat-card">
|
||
<div class="stat-value text-blue">{{.Summary.ActiveCondCount}}</div>
|
||
<div class="stat-label">Active Conditions</div>
|
||
</div>
|
||
<div class="panel panel-padded stat-card">
|
||
<div class="stat-value text-success">{{.Summary.ActiveMedCount}}</div>
|
||
<div class="stat-label">Active Medications</div>
|
||
</div>
|
||
<div class="panel panel-padded stat-card {{if gt .Summary.AbnormalLabCount 0}}danger{{end}}">
|
||
<div class="stat-value {{if gt .Summary.AbnormalLabCount 0}}text-danger{{end}}">{{.Summary.AbnormalLabCount}}</div>
|
||
<div class="stat-label">Abnormal Labs (30d)</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{/* Clinical Data Tabs */}}
|
||
<div class="panel p-0 overflow-hidden">
|
||
<div class="bg-panel px-4 pt-4 border-b border-hard">
|
||
<div class="tabs">
|
||
<button class="tab-btn active" onclick="openTab(event, 'summary')">Summary</button>
|
||
{{if .RimidiMatch}}
|
||
<button class="tab-btn cgm-tab" onclick="openTab(event, 'cgm')">CGM</button>
|
||
{{end}}
|
||
<button class="tab-btn" onclick="openTab(event, 'vitals')">Vitals ({{len (latestObPerCode (filterObsByCategory .Observations "vital-signs"))}})</button>
|
||
<button class="tab-btn" onclick="openTab(event, 'labs')">Labs ({{len (filterObsByCategory .Observations "laboratory")}})</button>
|
||
<button class="tab-btn" onclick="openTab(event, 'conditions')">Conditions ({{len .Conditions}})</button>
|
||
<button class="tab-btn" onclick="openTab(event, 'medications')">Meds ({{len .Medications}})</button>
|
||
<button class="tab-btn" onclick="openTab(event, 'allergies')">Allergies ({{len .Allergies}})</button>
|
||
<button class="tab-btn" onclick="openTab(event, 'notes')">Notes ({{len .DocumentReferences}})</button>
|
||
<button class="tab-btn" onclick="openTab(event, 'more')" title="More Data">...</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel-padded min-h-[400px]">
|
||
{{/* ---- Summary Tab ---- */}}
|
||
<div id="summary" class="tab-content active">
|
||
<div class="flex flex-col gap-8">
|
||
<div class="flex flex-col gap-4">
|
||
<h3 class="text-xs text-muted font-medium uppercase tracking-wider">Latest Vital Signs</h3>
|
||
{{if .Summary.LatestVitals}}
|
||
<div class="vitals-strip">
|
||
{{range .Summary.LatestVitals}}
|
||
<div class="vital-item {{if isAbnormal .Interpretation}}vital-abnormal{{end}}">
|
||
<span class="vital-label" title="{{.CodeText}}">{{orDash .CodeText}}</span>
|
||
<span class="vital-value">
|
||
{{if .ValueQuantity}}{{printf "%.4g" (derefFloat64 .ValueQuantity)}} <small>{{.ValueUnit}}</small>
|
||
{{else if .ValueString}}{{.ValueString}}
|
||
{{else}}—{{end}}
|
||
</span>
|
||
<span class="vital-date">{{formatDate .EffectiveDate}}</span>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
{{else}}
|
||
<p class="empty-state">No vital signs recorded.</p>
|
||
{{end}}
|
||
</div>
|
||
|
||
<div class="flex flex-col gap-4">
|
||
<h3 class="text-xs text-muted font-medium uppercase tracking-wider">Recent Abnormal Labs</h3>
|
||
{{if .Summary.AbnormalLabsRecent}}
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Date</th><th>Test</th><th>Result</th><th>Flag</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{{range .Summary.AbnormalLabsRecent}}
|
||
<tr class="row-abnormal">
|
||
<td>{{formatDate .EffectiveDate}}</td>
|
||
<td class="font-medium">{{.CodeText}}</td>
|
||
<td>{{if .ValueQuantity}}{{printf "%.4g" (derefFloat64 .ValueQuantity)}} {{.ValueUnit}}{{else}}{{.ValueString}}{{end}}</td>
|
||
<td><span class="badge badge-danger">{{.Interpretation}}</span></td>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{{else}}
|
||
<p class="empty-state">No abnormal labs in the last 30 days.</p>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{/* ---- CGM Tab ---- */}}
|
||
{{if .RimidiMatch}}
|
||
<div id="cgm" class="tab-content">
|
||
<div id="cgmTabContent" style="min-height:400px;"></div>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{/* ---- Vitals Tab ---- */}}
|
||
<div id="vitals" class="tab-content">
|
||
{{$vitals := latestObPerCode (filterObsByCategory .Observations "vital-signs")}}
|
||
{{if $vitals}}
|
||
<div class="vitals-grid">
|
||
{{range $vitals}}
|
||
<div class="panel panel-padded p-5 {{if isAbnormal .Interpretation}}vital-abnormal{{end}}">
|
||
<div class="flex justify-between items-start mb-2">
|
||
<span class="text-xs font-semibold text-muted">{{orDash .CodeText}}</span>
|
||
{{if isAbnormal .Interpretation}}<span class="badge badge-danger">{{.Interpretation}}</span>{{end}}
|
||
</div>
|
||
<div class="text-2xl font-semibold mb-4">
|
||
{{if .ValueQuantity}}{{printf "%.4g" (derefFloat64 .ValueQuantity)}} <span class="text-sm font-medium text-muted">{{.ValueUnit}}</span>
|
||
{{else if .ValueString}}{{.ValueString}}
|
||
{{else}}—{{end}}
|
||
</div>
|
||
<div class="flex flex-col gap-1 text-xs text-muted">
|
||
<span>{{formatDate .EffectiveDate}}</span>
|
||
{{if or .ReferenceRangeLow .ReferenceRangeHigh}}
|
||
<span>Ref: {{if .ReferenceRangeLow}}{{printf "%.4g" (derefFloat64 .ReferenceRangeLow)}}{{else}}—{{end}}-{{if .ReferenceRangeHigh}}{{printf "%.4g" (derefFloat64 .ReferenceRangeHigh)}}{{else}}—{{end}}</span>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
{{else}}
|
||
<p class="empty-state">No vital signs found.</p>
|
||
{{end}}
|
||
</div>
|
||
|
||
{{/* ---- Labs Tab ---- */}}
|
||
<div id="labs" class="tab-content">
|
||
{{$labs := filterObsByCategory .Observations "laboratory"}}
|
||
{{if $labs}}
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Date</th><th>Test</th><th>Result</th><th>Ref Range</th><th>Flag</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{{range $labs}}
|
||
<tr {{if isAbnormal .Interpretation}}class="row-abnormal"{{end}}>
|
||
<td>{{formatDate .EffectiveDate}}</td>
|
||
<td class="font-medium">{{orDash .CodeText}}</td>
|
||
<td>
|
||
{{if .ValueQuantity}}<strong>{{printf "%.4g" (derefFloat64 .ValueQuantity)}}</strong> {{.ValueUnit}}
|
||
{{else if .ValueString}}{{.ValueString}}
|
||
{{else}}—{{end}}
|
||
</td>
|
||
<td class="text-xs text-muted">
|
||
{{if or .ReferenceRangeLow .ReferenceRangeHigh}}
|
||
[{{if .ReferenceRangeLow}}{{printf "%.4g" (derefFloat64 .ReferenceRangeLow)}}{{else}}—{{end}} – {{if .ReferenceRangeHigh}}{{printf "%.4g" (derefFloat64 .ReferenceRangeHigh)}}{{else}}—{{end}}]
|
||
{{else}}—{{end}}
|
||
</td>
|
||
<td>{{if isAbnormal .Interpretation}}<span class="badge badge-danger">{{.Interpretation}}</span>{{else if .Interpretation}}<span class="badge badge-neutral">{{.Interpretation}}</span>{{end}}</td>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{{else}}
|
||
<p class="empty-state">No lab results found.</p>
|
||
{{end}}
|
||
</div>
|
||
|
||
{{/* ---- Conditions Tab ---- */}}
|
||
<div id="conditions" class="tab-content">
|
||
{{if .Conditions}}
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Onset</th><th>Condition</th><th>Status</th><th>Verification</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{{range .Conditions}}
|
||
<tr>
|
||
<td>{{formatDate (or .OnsetDate .RecordedDate)}}</td>
|
||
<td class="font-medium">{{orDash .CodeText}}</td>
|
||
<td>{{if eq .ClinicalStatus "active"}}<span class="badge badge-success">Active</span>{{else}}<span class="badge badge-neutral">{{.ClinicalStatus}}</span>{{end}}</td>
|
||
<td>{{titleCase .VerificationStatus}}</td>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{{else}}
|
||
<p class="empty-state">No conditions found.</p>
|
||
{{end}}
|
||
</div>
|
||
|
||
{{/* ---- Medications Tab ---- */}}
|
||
<div id="medications" class="tab-content">
|
||
{{if .Medications}}
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Authored</th><th>Medication</th><th>Dosage</th><th>Status</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{{range .Medications}}
|
||
<tr>
|
||
<td>{{formatDate .AuthoredOn}}</td>
|
||
<td class="font-medium">{{orDash .MedCodeText}}</td>
|
||
<td>{{orDash .DosageText}}</td>
|
||
<td>{{if eq .Status "active"}}<span class="badge badge-success">Active</span>{{else}}<span class="badge badge-neutral">{{.Status}}</span>{{end}}</td>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{{else}}
|
||
<p class="empty-state">No medications found.</p>
|
||
{{end}}
|
||
</div>
|
||
|
||
{{/* ---- Allergies Tab ---- */}}
|
||
<div id="allergies" class="tab-content">
|
||
{{if .Allergies}}
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Allergen</th><th>Reaction</th><th>Severity</th><th>Status</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{{range .Allergies}}
|
||
<tr>
|
||
<td class="font-medium">{{orDash .CodeText}}</td>
|
||
<td>{{orDash .ReactionManifestation}}</td>
|
||
<td>{{if eq .ReactionSeverity "severe"}}<span class="badge badge-danger">Severe</span>{{else if .ReactionSeverity}}<span class="badge badge-warning">{{.ReactionSeverity}}</span>{{else}}—{{end}}</td>
|
||
<td>{{titleCase .ClinicalStatus}}</td>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{{else}}
|
||
<p class="empty-state">No allergies found.</p>
|
||
{{end}}
|
||
</div>
|
||
|
||
{{/* ---- Notes Tab ---- */}}
|
||
<div id="notes" class="tab-content">
|
||
{{if .DocumentReferences}}
|
||
<div class="notes-list">
|
||
{{range .DocumentReferences}}
|
||
<div class="panel panel-padded note-item">
|
||
<div class="note-header">
|
||
<span class="note-date">{{formatDate .Date}}</span>
|
||
<span class="note-type">{{orDash .TypeText}}</span>
|
||
{{if .ContentURL}}<a href="{{.ContentURL}}" target="_blank" class="btn btn-outline btn-sm">View Document</a>{{end}}
|
||
</div>
|
||
<p class="note-desc">{{orDash .Description}}</p>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
{{else}}
|
||
<p class="empty-state">No clinical notes found.</p>
|
||
{{end}}
|
||
</div>
|
||
|
||
{{/* ---- More Tab (Procedures, Immunizations, Encounters) ---- */}}
|
||
<div id="more" class="tab-content">
|
||
<div class="flex flex-col gap-6">
|
||
<div class="flex flex-col gap-2">
|
||
<h4 class="font-semibold text-sm text-main">Recent Procedures</h4>
|
||
{{if .Procedures}}
|
||
<ul class="flex flex-col gap-1 text-sm text-muted">
|
||
{{range (slice .Procedures 0 (min (len .Procedures) 5))}}
|
||
<li><strong class="text-main">{{formatDate .PerformedDate}}:</strong> {{.CodeText}}</li>
|
||
{{end}}
|
||
</ul>
|
||
{{else}}<p class="text-muted text-xs">None recorded</p>{{end}}
|
||
</div>
|
||
<div class="flex flex-col gap-2">
|
||
<h4 class="font-semibold text-sm text-main">Recent Immunizations</h4>
|
||
{{if .Immunizations}}
|
||
<ul class="flex flex-col gap-1 text-sm text-muted">
|
||
{{range (slice .Immunizations 0 (min (len .Immunizations) 5))}}
|
||
<li><strong class="text-main">{{formatDate .OccurrenceDate}}:</strong> {{.VaccineText}}</li>
|
||
{{end}}
|
||
</ul>
|
||
{{else}}<p class="text-muted text-xs">None recorded</p>{{end}}
|
||
</div>
|
||
<div class="flex flex-col gap-2">
|
||
<h4 class="font-semibold text-sm text-main">Recent Encounters</h4>
|
||
{{if .Encounters}}
|
||
<ul class="flex flex-col gap-1 text-sm text-muted">
|
||
{{range (slice .Encounters 0 (min (len .Encounters) 5))}}
|
||
<li><strong class="text-main">{{formatDate .PeriodStart}}:</strong> {{or .TypeText "Visit"}}</li>
|
||
{{end}}
|
||
</ul>
|
||
{{else}}<p class="text-muted text-xs">None recorded</p>{{end}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{/* ---- Sidebar Column ---- */}}
|
||
<div class="dashboard-sidebar">
|
||
|
||
{{/* High Priority: Alerts */}}
|
||
{{if hasCriticalAllergies .Allergies}}
|
||
<div class="panel panel-padded alert-card">
|
||
<div class="flex items-center gap-2 mb-4">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-danger"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
|
||
<h3 class="font-semibold text-danger">Critical Alerts</h3>
|
||
</div>
|
||
<div class="flex flex-col gap-3">
|
||
{{range .Allergies}}
|
||
{{if eq .Criticality "high"}}
|
||
<div class="alert-item">
|
||
<span class="alert-code">{{.CodeText}} Allergy</span>
|
||
{{with .ReactionManifestation}}<span class="alert-detail">{{.}}{{with $.ReactionSeverity}} ({{.}}){{end}}</span>{{end}}
|
||
</div>
|
||
{{end}}
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{/* Patient Details (Expanded) */}}
|
||
<div class="panel panel-padded">
|
||
<div class="mb-4">
|
||
<h3 class="font-semibold text-main">Patient Information</h3>
|
||
</div>
|
||
<div class="kv-list">
|
||
<div class="kv-row">
|
||
<span class="kv-label">Full Name</span>
|
||
<span class="kv-value font-medium">{{.Patient.FirstName}} {{.Patient.MiddleName}} {{.Patient.LastName}}</span>
|
||
</div>
|
||
<div class="kv-row">
|
||
<span class="kv-label">FHIR ID</span>
|
||
<span class="kv-value text-xs font-mono">{{.Patient.FHIRID}}</span>
|
||
</div>
|
||
{{with primaryAddress .RawPatient}}
|
||
<div class="kv-row">
|
||
<span class="kv-label">Address</span>
|
||
<span class="kv-value">{{.}}</span>
|
||
</div>
|
||
{{end}}
|
||
{{with usRace .RawPatient}}
|
||
<div class="kv-row">
|
||
<span class="kv-label">Race</span>
|
||
<span class="kv-value">{{.}}</span>
|
||
</div>
|
||
{{end}}
|
||
{{with usEthnicity .RawPatient}}
|
||
<div class="kv-row">
|
||
<span class="kv-label">Ethnicity</span>
|
||
<span class="kv-value">{{.}}</span>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
{{/* Low Priority: SMART Inspector */}}
|
||
<div class="panel panel-padded inspector-card">
|
||
<details>
|
||
<summary class="text-xs text-muted font-medium uppercase tracking-wider cursor-pointer outline-none">System Inspector</summary>
|
||
<div class="inspector-content mt-4">
|
||
<div class="inspect-item">
|
||
<span class="label">EHR Server</span>
|
||
<code class="text-xs text-muted break-all" title="{{.Session.EHRURL}}">{{.Session.EHRURL}}</code>
|
||
</div>
|
||
<div class="inspect-item">
|
||
<span class="label">Granted Scopes</span>
|
||
<div class="scopes-cloud">
|
||
{{range (split .Session.Scope " ")}}
|
||
<span class="scope-tag">{{.}}</span>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{end}}
|
||
|
||
{{define "scripts"}}
|
||
<script>
|
||
function openTab(evt, tabName) {
|
||
var tabcontent = document.getElementsByClassName("tab-content");
|
||
for (var i = 0; i < tabcontent.length; i++) {
|
||
tabcontent[i].classList.remove("active");
|
||
}
|
||
var tablinks = document.getElementsByClassName("tab-btn");
|
||
for (var i = 0; i < tablinks.length; i++) {
|
||
tablinks[i].classList.remove("active");
|
||
}
|
||
var target = document.getElementById(tabName);
|
||
setTimeout(() => target.classList.add("active"), 10);
|
||
evt.currentTarget.classList.add("active");
|
||
|
||
// Load CGM data when CGM tab is opened
|
||
if (tabName === 'cgm') {
|
||
var cgmContent = document.getElementById('cgmTabContent');
|
||
if (cgmContent && !cgmContent.dataset.loaded) {
|
||
var patientFHIRID = '{{.Patient.FHIRID}}';
|
||
loadCGMData(patientFHIRID);
|
||
}
|
||
}
|
||
}
|
||
|
||
function onSyncClick() {
|
||
var btn = document.getElementById("syncBtn");
|
||
setTimeout(function() {
|
||
btn.disabled = true;
|
||
btn.innerHTML = `<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> Syncing...`;
|
||
}, 50);
|
||
}
|
||
|
||
// Helper for splitting scopes string
|
||
function split(s, sep) { return s.split(sep); }
|
||
|
||
/* ── "Find in Rimidi" match modal ────────────────────────────────── */
|
||
|
||
function findInRimidi(patientFHIRID) {
|
||
var overlay = document.getElementById('rimidiMatchOverlay');
|
||
var body = document.getElementById('rimidiMatchBody');
|
||
overlay.style.display = 'flex';
|
||
body.innerHTML =
|
||
'<div style="text-align:center;padding:40px;">' +
|
||
'<div class="animate-spin" style="width:24px;height:24px;border:3px solid #ddd;border-top-color:#0d6efd;border-radius:50%;display:inline-block;"></div>' +
|
||
'<p style="margin-top:12px;color:var(--color-text-muted);">Searching Rimidi…</p></div>';
|
||
|
||
var url = '/api/patient-match-proxy';
|
||
if (patientFHIRID) {
|
||
url += '?patient_id=' + encodeURIComponent(patientFHIRID);
|
||
}
|
||
|
||
fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
})
|
||
.then(function(resp) {
|
||
if (!resp.ok) {
|
||
return resp.json().then(function(d) { throw new Error(d.error || 'HTTP ' + resp.status); });
|
||
}
|
||
return resp.json();
|
||
})
|
||
.then(function(data) {
|
||
renderRimidiMatchResults(body, data, patientFHIRID);
|
||
})
|
||
.catch(function(err) {
|
||
body.innerHTML =
|
||
'<div style="padding:20px;color:var(--color-danger);background:var(--color-danger-bg);border-radius:8px;margin:20px;">' +
|
||
'<strong>Error:</strong> ' + escapeHtml(err.message) + '</div>';
|
||
});
|
||
}
|
||
|
||
function closeRimidiMatch() {
|
||
document.getElementById('rimidiMatchOverlay').style.display = 'none';
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
var d = document.createElement('div');
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function renderRimidiMatchResults(container, data, patientFHIRID) {
|
||
var matches = data.matches || [];
|
||
var local = data.local_patient || {};
|
||
patientFHIRID = patientFHIRID || '';
|
||
|
||
if (matches.length === 0) {
|
||
container.innerHTML =
|
||
'<div style="padding:40px;text-align:center;color:var(--text-muted);">' +
|
||
'<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin:0 auto 16px;opacity:0.5;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>' +
|
||
'<p style="margin:0;font-size:16px;">No matching patients found in the Rimidi system.</p></div>';
|
||
return;
|
||
}
|
||
|
||
var html = '<div style="padding:24px;">';
|
||
html += '<div style="margin-bottom:24px;padding:16px;background:var(--accent-blue-bg);border-radius:var(--radius-md);border:1px solid #BAE6FD;">';
|
||
html += '<p style="margin:0;color:var(--accent-blue);font-size:14px;font-weight:500;">' +
|
||
'Found <strong style="font-weight:600;">' + matches.length + '</strong> potential match' +
|
||
(matches.length > 1 ? 'es' : '') + ' in Rimidi.</p>';
|
||
html += '</div>';
|
||
|
||
var fieldLabels = {
|
||
first_name: 'First Name', last_name: 'Last Name',
|
||
email: 'Email', dob: 'Date of Birth', sex: 'Sex'
|
||
};
|
||
var fieldOrder = ['first_name', 'last_name', 'email', 'dob', 'sex'];
|
||
|
||
for (var i = 0; i < matches.length; i++) {
|
||
var m = matches[i];
|
||
var f = m.fields || {};
|
||
var borderColor = m.score >= 4 ? 'var(--success)' : (m.score >= 3 ? 'var(--warning)' : 'var(--accent-blue)');
|
||
var badgeClass = m.score >= 4 ? 'badge-success' : (m.score >= 3 ? 'badge-warning' : 'badge-blue');
|
||
var matchQuality = m.score >= 4 ? 'High' : (m.score >= 3 ? 'Medium' : 'Low');
|
||
|
||
html += '<div class="panel" style="margin-bottom:20px;border-left:5px solid ' + borderColor + ';padding:0;overflow:hidden;box-shadow:var(--shadow-subtle);">';
|
||
html += '<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border-hard);background:var(--bg-panel);">';
|
||
html += '<div style="display:flex;align-items:center;gap:12px;">';
|
||
html += '<strong style="font-size:16px;font-weight:600;color:var(--text-main);">Match #' + (i + 1) + '</strong>';
|
||
html += '<span class="badge ' + badgeClass + '" style="font-size:12px;padding:4px 10px;">' + m.score + '/5 fields match</span>';
|
||
html += '<span style="font-size:12px;color:var(--text-muted);">(' + matchQuality + ' confidence)</span>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
|
||
html += '<div style="overflow-x:auto;">';
|
||
html += '<table style="width:100%;border-collapse:separate;border-spacing:0;">';
|
||
html += '<thead><tr style="background:var(--bg-hover);">';
|
||
html += '<th style="padding:12px 16px;text-align:left;width:20%;font-size:11px;text-transform:uppercase;color:var(--text-muted);font-weight:600;letter-spacing:0.05em;border-bottom:2px solid var(--border-hard);">Field</th>';
|
||
html += '<th style="padding:12px 16px;text-align:left;width:32%;font-size:11px;text-transform:uppercase;color:var(--text-muted);font-weight:600;letter-spacing:0.05em;border-bottom:2px solid var(--border-hard);">HRS (Local)</th>';
|
||
html += '<th style="padding:12px 16px;text-align:left;width:32%;font-size:11px;text-transform:uppercase;color:var(--text-muted);font-weight:600;letter-spacing:0.05em;border-bottom:2px solid var(--border-hard);">Rimidi (Remote)</th>';
|
||
html += '<th style="padding:12px 16px;text-align:center;width:16%;font-size:11px;text-transform:uppercase;color:var(--text-muted);font-weight:600;letter-spacing:0.05em;border-bottom:2px solid var(--border-hard);">Status</th>';
|
||
html += '</tr></thead><tbody>';
|
||
|
||
for (var j = 0; j < fieldOrder.length; j++) {
|
||
var key = fieldOrder[j];
|
||
var fld = f[key] || {};
|
||
var localVal = local[key] || '—';
|
||
var remoteVal = fld.value || '—';
|
||
var isMatch = fld.match === true;
|
||
|
||
// Enhanced visual indicators
|
||
var matchIcon = isMatch
|
||
? '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:middle;"><polyline points="20 6 9 17 4 12"/></svg>'
|
||
: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:middle;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||
|
||
var rowStyle = isMatch
|
||
? 'background:var(--success-bg);border-left:3px solid var(--success);'
|
||
: 'background:var(--warning-bg);border-left:3px solid var(--warning);';
|
||
|
||
var matchBadge = isMatch
|
||
? '<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 8px;background:var(--success-bg);color:var(--success);border:1px solid #A7F3D0;border-radius:var(--radius-sm);font-size:11px;font-weight:600;">Match</span>'
|
||
: '<span style="display:inline-flex;align-items:center;gap:4px;padding:4px 8px;background:var(--danger-bg);color:var(--danger);border:1px solid #FECDD3;border-radius:var(--radius-sm);font-size:11px;font-weight:600;">Mismatch</span>';
|
||
|
||
html += '<tr style="' + rowStyle + 'border-top:1px solid var(--border-soft);transition:background 0.2s;">';
|
||
html += '<td style="padding:14px 16px;font-weight:600;color:var(--text-main);font-size:13px;">' + fieldLabels[key] + '</td>';
|
||
html += '<td style="padding:14px 16px;color:var(--text-main);font-size:13px;font-family:var(--font-mono);background:rgba(255,255,255,0.5);">' + escapeHtml(localVal) + '</td>';
|
||
html += '<td style="padding:14px 16px;color:var(--text-main);font-size:13px;font-family:var(--font-mono);background:rgba(255,255,255,0.5);">' + escapeHtml(remoteVal) + '</td>';
|
||
html += '<td style="padding:14px 16px;text-align:center;vertical-align:middle;">' + matchBadge + '</td>';
|
||
html += '</tr>';
|
||
}
|
||
|
||
html += '</tbody></table>';
|
||
html += '</div>';
|
||
|
||
// Add Confirm Match button
|
||
var patientPK = m.patient_pk || m.patient_ref;
|
||
html += '<div style="padding:16px 20px;border-top:1px solid var(--border-hard);text-align:right;background:var(--bg-panel);">';
|
||
html += '<button class="btn btn-primary" id="confirm-btn-' + i + '" onclick="confirmMatch(\'' + escapeHtml(patientFHIRID) + '\', \'' + escapeHtml(m.patient_ref) + '\', \'' + escapeHtml(patientPK) + '\', ' + i + ')" style="font-weight:600;">';
|
||
html += '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:text-bottom;margin-right:6px;"><polyline points="20 6 9 17 4 12"/></svg>';
|
||
html += 'Confirm Match';
|
||
html += '</button>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function confirmMatch(patientFHIRID, rimidiPatientRef, rimidiPatientPK, matchIndex) {
|
||
var btn = document.getElementById('confirm-btn-' + matchIndex);
|
||
if (!btn) return;
|
||
|
||
var originalText = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span style="display:inline-block;width:14px;height:14px;border:2px solid #fff;border-top-color:transparent;border-radius:50%;animation:spin 0.6s linear infinite;margin-right:6px;"></span>Confirming...';
|
||
|
||
fetch('/api/patient-match/confirm?patient_id=' + encodeURIComponent(patientFHIRID), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
rimidi_patient_ref: rimidiPatientRef,
|
||
rimidi_patient_pk: rimidiPatientPK
|
||
})
|
||
})
|
||
.then(function(resp) {
|
||
if (!resp.ok) {
|
||
return resp.json().then(function(d) { throw new Error(d.error || 'HTTP ' + resp.status); });
|
||
}
|
||
return resp.json();
|
||
})
|
||
.then(function(data) {
|
||
if (data.success) {
|
||
// Show success message
|
||
var body = document.getElementById('rimidiMatchBody');
|
||
body.innerHTML =
|
||
'<div style="padding:40px;text-align:center;">' +
|
||
'<div style="color:var(--color-success);font-size:48px;margin-bottom:16px;">✓</div>' +
|
||
'<h3 style="margin:0 0 8px 0;">Match Confirmed!</h3>' +
|
||
'<p style="color:var(--color-text-muted);margin-bottom:24px;">The patient match has been successfully confirmed.</p>' +
|
||
'<button class="btn btn-primary" onclick="closeRimidiMatch(); window.location.reload();">Close & Refresh</button>' +
|
||
'</div>';
|
||
} else {
|
||
throw new Error(data.message || 'Confirmation failed');
|
||
}
|
||
})
|
||
.catch(function(err) {
|
||
btn.disabled = false;
|
||
btn.innerHTML = originalText;
|
||
alert('Error confirming match: ' + err.message);
|
||
});
|
||
}
|
||
</script>
|
||
|
||
<!-- Rimidi Patient Match Modal Overlay -->
|
||
<div id="rimidiMatchOverlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.65);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);align-items:center;justify-content:center;">
|
||
<div style="background:#FFFFFF;border-radius:var(--radius-md);width:90%;max-width:800px;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 25px 80px rgba(0,0,0,0.4),0 8px 16px rgba(0,0,0,0.2);border:1px solid var(--border-soft);overflow:hidden;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;padding:20px 24px;border-bottom:1px solid var(--border-hard);background:var(--bg-panel);">
|
||
<h3 style="margin:0;font-size:18px;font-weight:600;color:var(--text-main);display:flex;align-items:center;gap:8px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||
Find Patient in Rimidi
|
||
</h3>
|
||
<button onclick="closeRimidiMatch()" style="background:none;border:none;font-size:28px;cursor:pointer;color:var(--text-muted);padding:0;width:32px;height:32px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);transition:all 0.2s;" onmouseover="this.style.background='var(--bg-hover)';this.style.color='var(--text-main)';" onmouseout="this.style.background='none';this.style.color='var(--text-muted)';" title="Close">×</button>
|
||
</div>
|
||
<div id="rimidiMatchBody" style="overflow-y:auto;flex:1;background:var(--bg-panel);">
|
||
</div>
|
||
<div style="padding:16px 24px;border-top:1px solid var(--border-hard);text-align:right;background:var(--bg-panel);">
|
||
<button class="btn btn-secondary" onclick="closeRimidiMatch()">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CGM Preview Modal Overlay -->
|
||
<div id="cgmPreviewOverlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;">
|
||
<div style="background:var(--color-bg-main);border-radius:12px;width:95%;max-width:1200px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--color-border-soft);">
|
||
<h3 style="margin:0;font-size:18px;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:text-bottom;margin-right:6px;"><path d="M3 3v18h18"/><path d="M7 12h10M7 8h10M7 16h4"/></svg>
|
||
CGM Data from Rimidi
|
||
</h3>
|
||
<button onclick="closeCGMPreview()" style="background:none;border:none;font-size:24px;cursor:pointer;color:var(--color-text-muted);padding:0 4px;">×</button>
|
||
</div>
|
||
<div id="cgmPreviewBody" style="overflow-y:auto;flex:1;padding:20px;">
|
||
</div>
|
||
<div style="padding:12px 20px;border-top:1px solid var(--color-border-soft);text-align:right;">
|
||
<button class="btn btn-secondary" onclick="closeCGMPreview()">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||
|
||
<script>
|
||
/* ── CGM Preview Functions ──────────────────────────────────────── */
|
||
|
||
var cgmChart = null;
|
||
|
||
function loadCGMData(patientFHIRID) {
|
||
var container = document.getElementById('cgmTabContent');
|
||
if (!container) return;
|
||
|
||
// Mark as loading to prevent duplicate loads
|
||
if (container.dataset.loaded === 'loading') return;
|
||
container.dataset.loaded = 'loading';
|
||
|
||
container.innerHTML =
|
||
'<div style="text-align:center;padding:40px;">' +
|
||
'<div class="animate-spin" style="width:24px;height:24px;border:3px solid #ddd;border-top-color:#0d6efd;border-radius:50%;display:inline-block;"></div>' +
|
||
'<p style="margin-top:12px;color:var(--color-text-muted);">Loading CGM data…</p></div>';
|
||
|
||
var url = '/api/cgm-preview?patient_id=' + encodeURIComponent(patientFHIRID);
|
||
|
||
fetch(url, {
|
||
method: 'GET',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
})
|
||
.then(function(resp) {
|
||
if (!resp.ok) {
|
||
return resp.json().then(function(d) { throw new Error(d.error || 'HTTP ' + resp.status); });
|
||
}
|
||
return resp.json();
|
||
})
|
||
.then(function(data) {
|
||
container.dataset.loaded = 'true';
|
||
renderCGMPreview(container, data);
|
||
})
|
||
.catch(function(err) {
|
||
container.dataset.loaded = 'error';
|
||
container.innerHTML =
|
||
'<div style="padding:20px;color:var(--color-danger);background:var(--color-danger-bg);border-radius:8px;margin:20px;">' +
|
||
'<strong>Error:</strong> ' + escapeHtml(err.message) + '</div>';
|
||
});
|
||
}
|
||
|
||
// Keep the old function for backward compatibility (in case it's called from elsewhere)
|
||
function loadCGMPreview(patientFHIRID) {
|
||
loadCGMData(patientFHIRID);
|
||
}
|
||
|
||
function closeCGMPreview() {
|
||
document.getElementById('cgmPreviewOverlay').style.display = 'none';
|
||
if (cgmChart) {
|
||
cgmChart.destroy();
|
||
cgmChart = null;
|
||
}
|
||
}
|
||
|
||
function unlinkPatient(patientFHIRID) {
|
||
if (!confirm('Are you sure you want to unlink this patient from Rimidi? This will remove the connection and you will need to match again to view CGM data.')) {
|
||
return;
|
||
}
|
||
|
||
var url = '/api/patient-match/unlink?patient_id=' + encodeURIComponent(patientFHIRID);
|
||
|
||
fetch(url, {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
})
|
||
.then(function(resp) {
|
||
if (!resp.ok) {
|
||
return resp.json().then(function(d) { throw new Error(d.error || 'HTTP ' + resp.status); });
|
||
}
|
||
return resp.json();
|
||
})
|
||
.then(function(data) {
|
||
if (data.success) {
|
||
// Reload the page to update the UI
|
||
window.location.reload();
|
||
} else {
|
||
throw new Error(data.message || 'Unlink failed');
|
||
}
|
||
})
|
||
.catch(function(err) {
|
||
alert('Error unlinking patient: ' + err.message);
|
||
});
|
||
}
|
||
|
||
function renderCGMPreview(container, data) {
|
||
var html = '<div style="display:flex;flex-direction:column;gap:32px;">';
|
||
|
||
// Key Metrics - Modern Grid Layout
|
||
if (data.metrics) {
|
||
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:20px;margin-bottom:8px;">';
|
||
|
||
if (data.metrics.average_glucose !== undefined) {
|
||
html += '<div style="padding:20px;background:linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);border-radius:12px;color:white;box-shadow:0 4px 12px rgba(129,140,248,0.2);">';
|
||
html += '<div style="font-size:12px;opacity:0.9;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px;">Average Glucose</div>';
|
||
html += '<div style="font-size:32px;font-weight:700;line-height:1.2;">' + escapeHtml(data.metrics.average_glucose.toFixed(1)) + '<span style="font-size:18px;font-weight:400;opacity:0.9;"> mg/dL</span></div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
if (data.metrics.gmi !== undefined) {
|
||
html += '<div style="padding:20px;background:linear-gradient(135deg, #f9a8d4 0%, #fb7185 100%);border-radius:12px;color:white;box-shadow:0 4px 12px rgba(249,168,212,0.2);">';
|
||
html += '<div style="font-size:12px;opacity:0.9;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px;">GMI</div>';
|
||
html += '<div style="font-size:32px;font-weight:700;line-height:1.2;">' + escapeHtml(data.metrics.gmi.toFixed(1)) + '<span style="font-size:18px;font-weight:400;opacity:0.9;">%</span></div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
if (data.metrics.time_in_range) {
|
||
var tir = data.metrics.time_in_range;
|
||
html += '<div style="padding:20px;background:linear-gradient(135deg, #60a5fa 0%, #38bdf8 100%);border-radius:12px;color:white;box-shadow:0 4px 12px rgba(96,165,250,0.2);">';
|
||
html += '<div style="font-size:12px;opacity:0.9;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px;">Time in Range</div>';
|
||
html += '<div style="font-size:32px;font-weight:700;line-height:1.2;">' + escapeHtml((tir.target || 0).toFixed(1)) + '<span style="font-size:18px;font-weight:400;opacity:0.9;">%</span></div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
if (data.gri_score && data.gri_score !== '-' && data.gri_score !== 'NA') {
|
||
html += '<div style="padding:20px;background:linear-gradient(135deg, #fb9ab8 0%, #fde68a 100%);border-radius:12px;color:white;box-shadow:0 4px 12px rgba(251,154,184,0.2);">';
|
||
html += '<div style="font-size:12px;opacity:0.9;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px;">GRI Score</div>';
|
||
html += '<div style="font-size:32px;font-weight:700;line-height:1.2;">' + escapeHtml(data.gri_score) + '</div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
|
||
// Device Info - Inline with subtle styling
|
||
if (data.device_info) {
|
||
html += '<div style="display:flex;align-items:center;gap:16px;padding:16px 20px;background:var(--bg-panel);border-radius:8px;border:1px solid var(--color-border-soft);">';
|
||
html += '<div style="flex:1;">';
|
||
html += '<div style="font-size:12px;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Device</div>';
|
||
html += '<div style="font-size:16px;font-weight:600;color:var(--text-main);">' + escapeHtml(data.device_info.type || 'Unknown') + '</div>';
|
||
html += '</div>';
|
||
html += '<div style="padding-left:20px;border-left:1px solid var(--color-border-soft);">';
|
||
html += '<div style="font-size:12px;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Status</div>';
|
||
html += '<div style="display:flex;align-items:center;gap:6px;">';
|
||
var statusColor = data.device_info.enabled ? 'var(--color-success)' : 'var(--color-text-muted)';
|
||
html += '<span style="width:8px;height:8px;border-radius:50%;background:' + statusColor + ';"></span>';
|
||
html += '<span style="font-size:14px;font-weight:500;color:var(--text-main);">' + (data.device_info.enabled ? 'Active' : 'Inactive') + '</span>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
// Time in Range Breakdown - Modern Visual Bars
|
||
if (data.metrics.time_in_range) {
|
||
var tir = data.metrics.time_in_range;
|
||
html += '<div style="padding:24px;background:var(--bg-panel);border-radius:12px;border:1px solid var(--color-border-soft);">';
|
||
html += '<h3 style="margin:0 0 20px 0;font-size:16px;font-weight:600;color:var(--text-main);">Time in Range Breakdown</h3>';
|
||
html += '<div style="display:flex;flex-direction:column;gap:12px;">';
|
||
|
||
// Very Low
|
||
if (tir.very_low !== undefined && tir.very_low > 0) {
|
||
html += '<div>';
|
||
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
|
||
html += '<span style="font-size:13px;font-weight:500;color:#dc2626;">Very Low (<54)</span>';
|
||
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.very_low || 0).toFixed(1) + '%</span>';
|
||
html += '</div>';
|
||
html += '<div style="height:8px;background:#fee2e2;border-radius:4px;overflow:hidden;">';
|
||
html += '<div style="height:100%;width:' + (tir.very_low || 0) + '%;background:#dc2626;transition:width 0.3s ease;"></div>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
// Low
|
||
if (tir.low !== undefined && tir.low > 0) {
|
||
html += '<div>';
|
||
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
|
||
html += '<span style="font-size:13px;font-weight:500;color:#f59e0b;">Low (54-70)</span>';
|
||
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.low || 0).toFixed(1) + '%</span>';
|
||
html += '</div>';
|
||
html += '<div style="height:8px;background:#fef3c7;border-radius:4px;overflow:hidden;">';
|
||
html += '<div style="height:100%;width:' + (tir.low || 0) + '%;background:#f59e0b;transition:width 0.3s ease;"></div>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
// Target
|
||
if (tir.target !== undefined && tir.target > 0) {
|
||
html += '<div>';
|
||
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
|
||
html += '<span style="font-size:13px;font-weight:500;color:#10b981;">Target (70-180)</span>';
|
||
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.target || 0).toFixed(1) + '%</span>';
|
||
html += '</div>';
|
||
html += '<div style="height:8px;background:#d1fae5;border-radius:4px;overflow:hidden;">';
|
||
html += '<div style="height:100%;width:' + (tir.target || 0) + '%;background:#10b981;transition:width 0.3s ease;"></div>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
// High
|
||
if (tir.high !== undefined && tir.high > 0) {
|
||
html += '<div>';
|
||
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
|
||
html += '<span style="font-size:13px;font-weight:500;color:#f59e0b;">High (180-250)</span>';
|
||
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.high || 0).toFixed(1) + '%</span>';
|
||
html += '</div>';
|
||
html += '<div style="height:8px;background:#fef3c7;border-radius:4px;overflow:hidden;">';
|
||
html += '<div style="height:100%;width:' + (tir.high || 0) + '%;background:#f59e0b;transition:width 0.3s ease;"></div>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
// Very High
|
||
if (tir.very_high !== undefined && tir.very_high > 0) {
|
||
html += '<div>';
|
||
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;">';
|
||
html += '<span style="font-size:13px;font-weight:500;color:#dc2626;">Very High (>250)</span>';
|
||
html += '<span style="font-size:13px;font-weight:600;color:var(--text-main);">' + (tir.very_high || 0).toFixed(1) + '%</span>';
|
||
html += '</div>';
|
||
html += '<div style="height:8px;background:#fee2e2;border-radius:4px;overflow:hidden;">';
|
||
html += '<div style="height:100%;width:' + (tir.very_high || 0) + '%;background:#dc2626;transition:width 0.3s ease;"></div>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
}
|
||
|
||
// Chart
|
||
if (data.chart_data && data.chart_data.length > 0) {
|
||
html += '<div style="padding:24px;background:var(--bg-panel);border-radius:12px;border:1px solid var(--color-border-soft);">';
|
||
html += '<h3 style="margin:0 0 20px 0;font-size:16px;font-weight:600;color:var(--text-main);">Glucose Trends (Last 14 Days)</h3>';
|
||
html += '<div style="position:relative;height:350px;">';
|
||
html += '<canvas id="cgmChart"></canvas>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
// Reports - Carousel Format
|
||
if (data.reports && data.reports.length > 0) {
|
||
html += '<div style="padding:24px;background:var(--bg-panel);border-radius:12px;border:1px solid var(--color-border-soft);">';
|
||
html += '<h3 style="margin:0 0 24px 0;font-size:16px;font-weight:600;color:var(--text-main);">Reports (' + data.reports.length + ')</h3>';
|
||
|
||
// Carousel Container
|
||
html += '<div id="cgmReportsCarousel" style="position:relative;width:100%;overflow:hidden;">';
|
||
|
||
// Carousel Wrapper
|
||
html += '<div id="cgmReportsCarouselWrapper" style="display:flex;width:' + (data.reports.length * 100) + '%;transition:transform 0.3s ease-in-out;">';
|
||
|
||
// Generate report slides
|
||
for (var i = 0; i < data.reports.length; i++) {
|
||
var report = data.reports[i];
|
||
var reportType = report.type || 'Unknown';
|
||
var reportTypeLabel = reportType.charAt(0).toUpperCase() + reportType.slice(1);
|
||
var dateRange = '';
|
||
if (report.start_date && report.end_date) {
|
||
dateRange = report.start_date + ' - ' + report.end_date;
|
||
} else if (report.start_date) {
|
||
dateRange = report.start_date;
|
||
} else if (report.end_date) {
|
||
dateRange = report.end_date;
|
||
}
|
||
|
||
// Report Slide - each slide is 100% of container width
|
||
var slideWidthPercent = 100 / data.reports.length;
|
||
html += '<div class="cgm-report-slide" data-slide-index="' + i + '" style="width:' + slideWidthPercent + '%;flex-shrink:0;display:flex;flex-direction:column;padding:24px;box-sizing:border-box;">';
|
||
|
||
// Report Header
|
||
html += '<div style="margin-bottom:16px;">';
|
||
html += '<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">';
|
||
html += '<span style="font-weight:600;font-size:18px;">' + escapeHtml(reportTypeLabel) + ' Report</span>';
|
||
html += '</div>';
|
||
if (dateRange) {
|
||
html += '<span style="color:var(--color-text-muted);font-size:14px;">' + escapeHtml(dateRange) + '</span>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// Preview Image
|
||
if (report.image_url) {
|
||
html += '<div style="flex:1;display:flex;align-items:center;justify-content:center;margin-bottom:20px;background:#f8f9fa;border-radius:8px;padding:16px;min-height:400px;">';
|
||
html += '<img src="' + escapeHtml(report.image_url) + '" alt="' + escapeHtml(reportTypeLabel) + ' Report Preview" style="max-width:100%;max-height:500px;object-fit:contain;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,0.1);">';
|
||
html += '</div>';
|
||
} else {
|
||
html += '<div style="flex:1;display:flex;align-items:center;justify-content:center;margin-bottom:20px;background:#f8f9fa;border-radius:8px;padding:16px;min-height:400px;color:var(--color-text-muted);">';
|
||
html += '<p>No preview image available</p>';
|
||
html += '</div>';
|
||
}
|
||
|
||
// PDF Download Link
|
||
if (report.pdf_url) {
|
||
html += '<div style="text-align:center;padding-top:12px;border-top:1px solid var(--color-border-soft);">';
|
||
html += '<a href="' + escapeHtml(report.pdf_url) + '" target="_blank" class="btn btn-primary" style="text-decoration:none;display:inline-block;">';
|
||
html += '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:text-bottom;margin-right:6px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||
html += 'Download PDF';
|
||
html += '</a>';
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '</div>'; // End report slide
|
||
}
|
||
|
||
html += '</div>'; // End carousel wrapper
|
||
|
||
// Navigation Arrows
|
||
if (data.reports.length > 1) {
|
||
html += '<button id="cgmCarouselPrev" onclick="navigateCGMCarousel(-1)" style="position:absolute;left:10px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.9);border:1px solid var(--color-border-soft);border-radius:50%;width:40px;height:40px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:10;transition:all 0.2s;" onmouseover="this.style.background=\'white\';this.style.boxShadow=\'0 4px 12px rgba(0,0,0,0.15)\';" onmouseout="this.style.background=\'rgba(255,255,255,0.9)\';this.style.boxShadow=\'0 2px 8px rgba(0,0,0,0.1)\';">';
|
||
html += '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>';
|
||
html += '</button>';
|
||
|
||
html += '<button id="cgmCarouselNext" onclick="navigateCGMCarousel(1)" style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.9);border:1px solid var(--color-border-soft);border-radius:50%;width:40px;height:40px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:10;transition:all 0.2s;" onmouseover="this.style.background=\'white\';this.style.boxShadow=\'0 4px 12px rgba(0,0,0,0.15)\';" onmouseout="this.style.background=\'rgba(255,255,255,0.9)\';this.style.boxShadow=\'0 2px 8px rgba(0,0,0,0.1)\';">';
|
||
html += '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
|
||
html += '</button>';
|
||
}
|
||
|
||
// Pagination Dots
|
||
if (data.reports.length > 1) {
|
||
html += '<div style="display:flex;justify-content:center;gap:8px;margin-top:16px;">';
|
||
for (var i = 0; i < data.reports.length; i++) {
|
||
var activeClass = i === 0 ? 'active' : '';
|
||
html += '<button class="cgm-carousel-dot ' + activeClass + '" onclick="goToCGMReport(' + i + ')" data-dot-index="' + i + '" style="width:10px;height:10px;border-radius:50%;border:none;background:' + (i === 0 ? 'var(--color-brand)' : 'var(--color-border-soft)') + ';cursor:pointer;transition:all 0.2s;padding:0;"></button>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '</div>'; // End carousel container
|
||
html += '</div>'; // End panel
|
||
}
|
||
|
||
html += '</div>';
|
||
container.innerHTML = html;
|
||
|
||
// Initialize carousel after rendering (with delay to ensure DOM is ready)
|
||
setTimeout(function() {
|
||
initCGMCarousel();
|
||
// Handle window resize to recalculate carousel
|
||
var resizeTimeout;
|
||
window.addEventListener('resize', function() {
|
||
clearTimeout(resizeTimeout);
|
||
resizeTimeout = setTimeout(function() {
|
||
updateCGMCarouselPosition();
|
||
}, 250);
|
||
});
|
||
}, 300);
|
||
|
||
// Render chart if data exists
|
||
if (data.chart_data && data.chart_data.length > 0 && typeof Chart !== 'undefined') {
|
||
setTimeout(function() {
|
||
renderCGMChart(data.chart_data, data.thresholds);
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
function renderCGMChart(chartData, thresholds) {
|
||
var ctx = document.getElementById('cgmChart');
|
||
if (!ctx) return;
|
||
|
||
// Destroy existing chart
|
||
if (cgmChart) {
|
||
cgmChart.destroy();
|
||
}
|
||
|
||
// Prepare data
|
||
var labels = [];
|
||
var values = [];
|
||
for (var i = 0; i < chartData.length; i++) {
|
||
var point = chartData[i];
|
||
labels.push(point.timestamp);
|
||
values.push(point.value);
|
||
}
|
||
|
||
// Thresholds
|
||
var lowThreshold = (thresholds && thresholds.low) || 70;
|
||
var highThreshold = (thresholds && thresholds.high) || 180;
|
||
|
||
cgmChart = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: labels,
|
||
datasets: [{
|
||
label: 'Glucose (mg/dL)',
|
||
data: values,
|
||
borderColor: 'rgb(59, 130, 246)',
|
||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||
tension: 0.1,
|
||
fill: true
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
y: {
|
||
beginAtZero: false,
|
||
suggestedMin: 50,
|
||
suggestedMax: 300
|
||
},
|
||
x: {
|
||
type: 'time',
|
||
time: {
|
||
unit: 'day'
|
||
}
|
||
}
|
||
},
|
||
plugins: {
|
||
annotation: {
|
||
annotations: {
|
||
lowLine: {
|
||
type: 'line',
|
||
yMin: lowThreshold,
|
||
yMax: lowThreshold,
|
||
borderColor: 'rgb(239, 68, 68)',
|
||
borderWidth: 2,
|
||
borderDash: [5, 5],
|
||
label: {
|
||
content: 'Low: ' + lowThreshold + ' mg/dL',
|
||
enabled: true
|
||
}
|
||
},
|
||
highLine: {
|
||
type: 'line',
|
||
yMin: highThreshold,
|
||
yMax: highThreshold,
|
||
borderColor: 'rgb(239, 68, 68)',
|
||
borderWidth: 2,
|
||
borderDash: [5, 5],
|
||
label: {
|
||
content: 'High: ' + highThreshold + ' mg/dL',
|
||
enabled: true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ── CGM Reports Carousel Navigation ──────────────────────────────────────── */
|
||
|
||
var cgmCarouselCurrentIndex = 0;
|
||
var cgmCarouselTotalSlides = 0;
|
||
|
||
function initCGMCarousel() {
|
||
var wrapper = document.getElementById('cgmReportsCarouselWrapper');
|
||
if (!wrapper) {
|
||
console.log('CGM Carousel: Wrapper not found');
|
||
return;
|
||
}
|
||
|
||
var slides = wrapper.querySelectorAll('.cgm-report-slide');
|
||
cgmCarouselTotalSlides = slides.length;
|
||
cgmCarouselCurrentIndex = 0;
|
||
|
||
console.log('CGM Carousel: Initialized with', cgmCarouselTotalSlides, 'slides');
|
||
|
||
if (cgmCarouselTotalSlides > 0) {
|
||
updateCGMCarouselPosition();
|
||
updateCGMCarouselDots();
|
||
}
|
||
}
|
||
|
||
function navigateCGMCarousel(direction) {
|
||
var wrapper = document.getElementById('cgmReportsCarouselWrapper');
|
||
if (!wrapper) return;
|
||
|
||
var slides = wrapper.querySelectorAll('.cgm-report-slide');
|
||
if (slides.length === 0) return;
|
||
|
||
cgmCarouselCurrentIndex += direction;
|
||
|
||
// Wrap around
|
||
if (cgmCarouselCurrentIndex < 0) {
|
||
cgmCarouselCurrentIndex = slides.length - 1;
|
||
} else if (cgmCarouselCurrentIndex >= slides.length) {
|
||
cgmCarouselCurrentIndex = 0;
|
||
}
|
||
|
||
updateCGMCarouselPosition();
|
||
updateCGMCarouselDots();
|
||
}
|
||
|
||
function goToCGMReport(index) {
|
||
var wrapper = document.getElementById('cgmReportsCarouselWrapper');
|
||
if (!wrapper) return;
|
||
|
||
var slides = wrapper.querySelectorAll('.cgm-report-slide');
|
||
if (index < 0 || index >= slides.length) return;
|
||
|
||
cgmCarouselCurrentIndex = index;
|
||
updateCGMCarouselPosition();
|
||
updateCGMCarouselDots();
|
||
}
|
||
|
||
function updateCGMCarouselPosition() {
|
||
var wrapper = document.getElementById('cgmReportsCarouselWrapper');
|
||
if (!wrapper) {
|
||
console.log('CGM Carousel: Wrapper not found in updateCGMCarouselPosition');
|
||
return;
|
||
}
|
||
|
||
var slides = wrapper.querySelectorAll('.cgm-report-slide');
|
||
if (slides.length === 0) {
|
||
console.log('CGM Carousel: No slides found');
|
||
return;
|
||
}
|
||
|
||
// Get the container (parent of wrapper) to calculate pixel offset
|
||
var container = wrapper.parentElement;
|
||
if (!container) {
|
||
console.log('CGM Carousel: Container not found');
|
||
return;
|
||
}
|
||
|
||
var containerWidth = container.offsetWidth || container.clientWidth;
|
||
if (containerWidth === 0) {
|
||
console.log('CGM Carousel: Container width is 0, retrying...');
|
||
setTimeout(function() {
|
||
updateCGMCarouselPosition();
|
||
}, 100);
|
||
return;
|
||
}
|
||
|
||
// Calculate offset in pixels: move by container width * index
|
||
// Since each slide is 100% of container width, we move by container width for each slide
|
||
var offsetPixels = -cgmCarouselCurrentIndex * containerWidth;
|
||
wrapper.style.transform = 'translateX(' + offsetPixels + 'px)';
|
||
|
||
console.log('CGM Carousel: Moved to slide', cgmCarouselCurrentIndex, 'offset:', offsetPixels + 'px', 'container width:', containerWidth);
|
||
}
|
||
|
||
function updateCGMCarouselDots() {
|
||
var dots = document.querySelectorAll('.cgm-carousel-dot');
|
||
for (var i = 0; i < dots.length; i++) {
|
||
if (i === cgmCarouselCurrentIndex) {
|
||
dots[i].style.background = 'var(--color-brand)';
|
||
dots[i].classList.add('active');
|
||
} else {
|
||
dots[i].style.background = 'var(--color-border-soft)';
|
||
dots[i].classList.remove('active');
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
{{end}}
|