mirror of
https://github.com/AmanTahiliani/FHIR-Sandbox.git
synced 2026-08-07 11:53:56 -04:00
UI/UX enhancements
This commit is contained in:
61
README.md
61
README.md
@@ -1,25 +1,34 @@
|
|||||||
# FHIR-Sandbox: SMART on FHIR Healthcare Platform
|
# FHIR-Sandbox: SMART on FHIR Healthcare Platform
|
||||||
|
|
||||||
A production-quality Go-based platform for integrating with Electronic Health Record (EHR) systems using the SMART on FHIR protocol. This sandbox demonstrates authentication, persistence, and dashboarding for patient and practitioner data.
|
A production-quality Go-based platform for integrating with Electronic Health Record (EHR) systems using the SMART on FHIR protocol. This sandbox demonstrates authentication, persistence, and dashboarding for patient and practitioner data, serving as a robust starting point for healthcare applications.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **SMART on FHIR Launch:** Supports the full SMART App Launch flow (EHR launch and standalone).
|
- **SMART on FHIR Launch:** Supports the full SMART App Launch flow (EHR launch and standalone) with OAuth2 code exchange.
|
||||||
- **Identity Resolution:** Correctly handles practitioner identification from both `practitioner` and `user` (Practitioner/ID) fields in OAuth2 token responses.
|
- **Identity Resolution:** Correctly handles practitioner identification from both `practitioner` and `user` (Practitioner/ID) fields in OAuth2 token responses.
|
||||||
- **SQLite Persistence:** Persists patient and practitioner data upon successful launch using a pure-Go SQLite driver (no CGO required).
|
- **Comprehensive FHIR Sync:** Automatically synchronizes and persists key clinical data:
|
||||||
- **Session Management:** Server-side sessions stored in SQLite with secure, HttpOnly cookies.
|
- Patient Demographics
|
||||||
- **Responsive Dashboard:** A modern UI built with Go `html/template` that displays patient demographics and practitioner details.
|
- Observations (Vitals, Labs)
|
||||||
- **Extensible Architecture:** Clean package separation (`handlers`, `db`, `fhir`, `models`, `middleware`, `config`) designed for growth.
|
- Conditions (Problems)
|
||||||
|
- DocumentReferences
|
||||||
|
- MedicationRequests
|
||||||
|
- AllergyIntolerances
|
||||||
|
- **SQLite Persistence:** Persists all synced data using a pure-Go SQLite driver (`modernc.org/sqlite`), requiring no CGO or external database server.
|
||||||
|
- **Secure Session Management:** Server-side sessions stored in SQLite with secure, HttpOnly cookies.
|
||||||
|
- **Responsive Dashboard:** A modern UI built with Go `html/template` that displays patient demographics, clinical data, and practitioner details.
|
||||||
|
- **Extensible Architecture:** Modular design with clean separation of concerns (`handlers`, `db`, `fhir`, `models`, `middleware`, `config`).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The project is structured into modular packages under `/app`:
|
The project is structured into modular packages under `/app`:
|
||||||
- `/db`: Database schema, migrations, and CRUD operations using `modernc.org/sqlite`.
|
|
||||||
- `/fhir`: FHIR R4 resource definitions and SMART discovery/client logic.
|
- `/config`: Configuration structures and URL normalization.
|
||||||
- `/handlers`: HTTP request handlers and template rendering.
|
- `/db`: Database schema, versioned migrations, and CRUD operations using `modernc.org/sqlite`.
|
||||||
- `/middleware`: Session loading and authentication guards.
|
- `/fhir`: FHIR R4 resource definitions, SMART discovery, and FHIR client logic.
|
||||||
- `/models`: Shared data structures.
|
- `/handlers`: HTTP request handlers (launch, auth, dashboard, sync) and template rendering.
|
||||||
- `/templates`: HTML templates with layout inheritance.
|
- `/middleware`: Session management middleware (loading and hard-gate protection).
|
||||||
|
- `/models`: Core domain models and context keys.
|
||||||
|
- `/templates`: Embedded HTML templates with layout inheritance.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -44,15 +53,20 @@ The project is structured into modular packages under `/app`:
|
|||||||
Use the [SMART Health IT Sandbox](https://launch.smarthealthit.org/):
|
Use the [SMART Health IT Sandbox](https://launch.smarthealthit.org/):
|
||||||
- **App Launch URL:** `http://localhost:8080/launch`
|
- **App Launch URL:** `http://localhost:8080/launch`
|
||||||
- **Redirect URL:** `http://localhost:8080/auth-redirect`
|
- **Redirect URL:** `http://localhost:8080/auth-redirect`
|
||||||
- The default configuration in `main.go` is pre-set to work with the SmartHealthIT sandbox.
|
|
||||||
|
The default configuration in `main.go` is pre-set to work with the SmartHealthIT sandbox.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Configuration is currently managed in `app/main.go` via `config.AppConfig`. You can define multiple EHRs, set your redirect URI, and required scopes.
|
Configuration is currently managed in `app/main.go` via the `config.AppConfig` struct. You can define multiple EHRs, set your redirect URI, and required scopes directly in the code.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
// Example configuration in app/main.go
|
||||||
cfg := &config.AppConfig{
|
cfg := &config.AppConfig{
|
||||||
DBPath: "fhir_sandbox.db",
|
DBPath: "fhir_sandbox.db",
|
||||||
|
Server: config.ServerConfig{
|
||||||
|
Port: 8080,
|
||||||
|
},
|
||||||
SMART: config.SMARTConfig{
|
SMART: config.SMARTConfig{
|
||||||
RedirectURL: "http://localhost:8080/auth-redirect",
|
RedirectURL: "http://localhost:8080/auth-redirect",
|
||||||
Scopes: []string{"openid", "profile", "launch", "patient/*.read", "user/*.read"},
|
Scopes: []string{"openid", "profile", "launch", "patient/*.read", "user/*.read"},
|
||||||
@@ -62,6 +76,7 @@ cfg := &config.AppConfig{
|
|||||||
Name: "SmartHealthIT Sandbox (R4)",
|
Name: "SmartHealthIT Sandbox (R4)",
|
||||||
FHIRURL: "https://launch.smarthealthit.org/v/r4/fhir",
|
FHIRURL: "https://launch.smarthealthit.org/v/r4/fhir",
|
||||||
ClientID: "your-client-id",
|
ClientID: "your-client-id",
|
||||||
|
ClientSecret: "your-client-secret", // Optional, depending on EHR
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -69,15 +84,23 @@ cfg := &config.AppConfig{
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
The project includes unit tests for database logic and FHIR parsing.
|
The project includes comprehensive unit tests for database logic, FHIR parsing, and clinical data handling.
|
||||||
|
|
||||||
|
- **Run all tests:**
|
||||||
```bash
|
```bash
|
||||||
go test ./...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **Run tests with coverage:**
|
||||||
|
```bash
|
||||||
|
go test -cover ./...
|
||||||
|
```
|
||||||
|
|
||||||
## Future Improvements
|
## Future Improvements
|
||||||
|
|
||||||
- [ ] Support for Observations, Conditions, and Encounters.
|
- [ ] **Configuration Loading:** Implement a robust configuration loader (e.g., `spf13/viper`) to load settings from files or environment variables.
|
||||||
- [ ] Move configuration to a YAML/TOML file.
|
- [ ] **Structured Logging:** Migrate to Go 1.21's `log/slog` for structured, leveled logging.
|
||||||
- [ ] Add structured logging (slog).
|
- [ ] **Refresh Tokens:** Implement OAuth2 refresh token logic to maintain long-lived sessions.
|
||||||
- [ ] Implement Refresh Token handling.
|
- [ ] **Additional Resources:** Add support for Encounters, Procedures, Immunizations, etc.
|
||||||
|
- [ ] **Frontend Enhancement:** Evolve the UI with HTMX or a modern JS framework for better interactivity.
|
||||||
|
- [ ] **FHIR Type Safety:** Adopt a comprehensive FHIR library (e.g., `google/fhir/go`) for stricter type safety.
|
||||||
|
|||||||
@@ -172,10 +172,5 @@ func TemplateFuncs() template.FuncMap {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
"last": func(slice interface{}) interface{} {
|
|
||||||
// Helper function to get the last element of a slice
|
|
||||||
// Used in template logic
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ func main() {
|
|||||||
mux.HandleFunc("/", h.HandleRoot)
|
mux.HandleFunc("/", h.HandleRoot)
|
||||||
mux.HandleFunc("/launch", h.HandleLaunch)
|
mux.HandleFunc("/launch", h.HandleLaunch)
|
||||||
mux.HandleFunc("/auth-redirect", h.HandleAuthRedirect)
|
mux.HandleFunc("/auth-redirect", h.HandleAuthRedirect)
|
||||||
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("app/static"))))
|
||||||
|
|
||||||
// Session-required routes — wrapped with the hard-gate middleware.
|
// Session-required routes — wrapped with the hard-gate middleware.
|
||||||
mux.Handle("/dashboard", sessionMW.RequireSession(http.HandlerFunc(h.HandleDashboard)))
|
mux.Handle("/dashboard", sessionMW.RequireSession(http.HandlerFunc(h.HandleDashboard)))
|
||||||
|
|||||||
453
app/static/css/styles.css
Normal file
453
app/static/css/styles.css
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
:root {
|
||||||
|
/* Brand Colors */
|
||||||
|
--primary-color: #2563EB;
|
||||||
|
--primary-hover: #1D4ED8;
|
||||||
|
--secondary-color: #0D9488;
|
||||||
|
--secondary-hover: #0F766E;
|
||||||
|
|
||||||
|
/* State Colors */
|
||||||
|
--success-color: #10B981;
|
||||||
|
--success-bg: #D1FAE5;
|
||||||
|
--warning-color: #F59E0B;
|
||||||
|
--warning-bg: #FEF3C7;
|
||||||
|
--danger-color: #EF4444;
|
||||||
|
--danger-bg: #FEE2E2;
|
||||||
|
--info-color: #3B82F6;
|
||||||
|
--info-bg: #DBEAFE;
|
||||||
|
|
||||||
|
/* Neutral Colors */
|
||||||
|
--background-color: #F3F4F6;
|
||||||
|
--surface-color: #FFFFFF;
|
||||||
|
--text-primary: #111827;
|
||||||
|
--text-secondary: #4B5563;
|
||||||
|
--text-muted: #9CA3AF;
|
||||||
|
--border-color: #E5E7EB;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
|
||||||
|
/* Effects */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset & Base */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--primary-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--spacing-xl) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar */
|
||||||
|
.navbar {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: var(--spacing-md) 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: var(--transition);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #DC2626;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover, .btn-outline.active {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
padding-bottom: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-subtitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid System */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
|
||||||
|
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid-cols-2, .grid-cols-3, .grid-cols-4 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Details List */
|
||||||
|
.details-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table-container {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background-color: #F9FAFB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success { background-color: var(--success-bg); color: var(--success-color); }
|
||||||
|
.badge-warning { background-color: var(--warning-bg); color: var(--warning-color); }
|
||||||
|
.badge-danger { background-color: var(--danger-bg); color: var(--danger-color); }
|
||||||
|
.badge-info { background-color: var(--info-bg); color: var(--info-color); }
|
||||||
|
.badge-neutral { background-color: var(--background-color); color: var(--text-secondary); border: 1px solid var(--border-color); }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
animation: fadeIn 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
.avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-practitioner { background-color: var(--secondary-color); }
|
||||||
|
.avatar-patient { background-color: var(--primary-color); }
|
||||||
|
|
||||||
|
/* Utility */
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.mt-4 { margin-top: var(--spacing-md); }
|
||||||
|
.mb-4 { margin-bottom: var(--spacing-md); }
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.gap-2 { gap: var(--spacing-sm); }
|
||||||
|
.gap-4 { gap: var(--spacing-md); }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.code-block {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
word-break: break-all;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flash Message */
|
||||||
|
.flash-message {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-success {
|
||||||
|
background-color: var(--success-bg);
|
||||||
|
border: 1px solid var(--success-color);
|
||||||
|
color: #065F46;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accordion/Details */
|
||||||
|
details > summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
details > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
details > summary::before {
|
||||||
|
content: '▶';
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] > summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
@@ -4,233 +4,31 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>FHIR Health Platform</title>
|
<title>FHIR Health Platform</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/styles.css">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
/* ---- Reset & base ---- */
|
body { font-family: 'Inter', sans-serif; }
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
:root {
|
|
||||||
--color-primary: #0062cc;
|
|
||||||
--color-primary-h: #004fa3;
|
|
||||||
--color-bg: #f4f6f9;
|
|
||||||
--color-surface: #ffffff;
|
|
||||||
--color-border: #dde3ec;
|
|
||||||
--color-text: #1a2533;
|
|
||||||
--color-muted: #6b7a99;
|
|
||||||
--color-patient: #0a7c59;
|
|
||||||
--color-hcp: #1a5fb4;
|
|
||||||
--radius: 8px;
|
|
||||||
--shadow: 0 1px 4px rgba(0,0,0,.08);
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Nav ---- */
|
|
||||||
header {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
padding: 0 24px;
|
|
||||||
height: 56px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.brand { font-size: 1.1rem; font-weight: 700; color: var(--color-primary); letter-spacing: -.2px; }
|
|
||||||
.brand span { color: var(--color-text); font-weight: 400; }
|
|
||||||
nav a, nav button {
|
|
||||||
font-size: .875rem;
|
|
||||||
color: var(--color-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: background .15s, color .15s;
|
|
||||||
}
|
|
||||||
nav a:hover, nav button:hover { background: var(--color-bg); color: var(--color-text); }
|
|
||||||
|
|
||||||
/* ---- Main layout ---- */
|
|
||||||
main { flex: 1; padding: 32px 24px; max-width: 960px; margin: 0 auto; width: 100%; }
|
|
||||||
footer {
|
|
||||||
text-align: center;
|
|
||||||
font-size: .75rem;
|
|
||||||
color: var(--color-muted);
|
|
||||||
padding: 16px;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Cards ---- */
|
|
||||||
.card {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
padding: 24px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.card-title {
|
|
||||||
font-size: .65rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: .08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-muted);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Badge ---- */
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: .7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: .04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
.badge-patient { background: #d1fae5; color: var(--color-patient); }
|
|
||||||
.badge-practitioner { background: #dbeafe; color: var(--color-hcp); }
|
|
||||||
|
|
||||||
/* ---- Detail grid ---- */
|
|
||||||
.detail-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: 12px 20px;
|
|
||||||
}
|
|
||||||
.detail-item label {
|
|
||||||
display: block;
|
|
||||||
font-size: .72rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .06em;
|
|
||||||
color: var(--color-muted);
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
.detail-item p { font-size: .95rem; color: var(--color-text); }
|
|
||||||
|
|
||||||
/* ---- User row (header block) ---- */
|
|
||||||
.user-header { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
|
|
||||||
.avatar {
|
|
||||||
width: 52px; height: 52px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 1.3rem; font-weight: 700; color: #fff;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.avatar-patient { background: var(--color-patient); }
|
|
||||||
.avatar-practitioner { background: var(--color-hcp); }
|
|
||||||
.user-header-info h2 { font-size: 1.15rem; font-weight: 700; margin-bottom: 4px; }
|
|
||||||
|
|
||||||
/* ---- Buttons ---- */
|
|
||||||
.btn {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 9px 18px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: .875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
transition: background .15s, box-shadow .15s;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.btn-primary { background: var(--color-primary); color: #fff; }
|
|
||||||
.btn-primary:hover { background: var(--color-primary-h); }
|
|
||||||
.btn-outline { background: transparent; color: var(--color-primary); border: 1.5px solid var(--color-primary); }
|
|
||||||
.btn-outline:hover { background: #e8f0ff; }
|
|
||||||
.btn-danger { background: #dc2626; color: #fff; }
|
|
||||||
.btn-danger:hover { background: #b91c1c; }
|
|
||||||
|
|
||||||
/* ---- Empty / hero ---- */
|
|
||||||
.hero {
|
|
||||||
text-align: center;
|
|
||||||
padding: 72px 24px;
|
|
||||||
}
|
|
||||||
.hero h1 { font-size: 2rem; font-weight: 800; margin-bottom: 12px; }
|
|
||||||
.hero p { font-size: 1rem; color: var(--color-muted); max-width: 480px; margin: 0 auto 28px; }
|
|
||||||
|
|
||||||
/* ---- Divider ---- */
|
|
||||||
hr { border: none; border-top: 1px solid var(--color-border); margin: 20px 0; }
|
|
||||||
|
|
||||||
/* ---- Utility ---- */
|
|
||||||
.text-muted { color: var(--color-muted); font-size: .85rem; }
|
|
||||||
.mt-4 { margin-top: 16px; }
|
|
||||||
.mb-4 { margin-bottom: 16px; }
|
|
||||||
.flex { display: flex; }
|
|
||||||
.gap-2 { gap: 8px; }
|
|
||||||
.items-center { align-items: center; }
|
|
||||||
.justify-between { justify-content: space-between; }
|
|
||||||
|
|
||||||
/* ---- FHIR Table ---- */
|
|
||||||
.fhir-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: .9rem;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
.fhir-table th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-bottom: 2px solid var(--color-border);
|
|
||||||
color: var(--color-muted);
|
|
||||||
font-size: .7rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .06em;
|
|
||||||
}
|
|
||||||
.fhir-table td {
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
.badge-outline {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
color: var(--color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Tabs ---- */
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.tab-link {
|
|
||||||
padding: 10px 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
font-size: .875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-muted);
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition: all .2s;
|
|
||||||
}
|
|
||||||
.tab-link:hover {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
.tab-link.active {
|
|
||||||
color: var(--color-primary);
|
|
||||||
border-bottom: 2px solid var(--color-primary);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header class="navbar">
|
||||||
<div class="brand">FHIR <span>Health Platform</span></div>
|
<div class="container navbar-content">
|
||||||
<nav>
|
<div class="brand">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||||
|
FHIR <span>Sandbox</span>
|
||||||
|
</div>
|
||||||
|
<nav class="nav-links">
|
||||||
{{block "nav" .}}{{end}}
|
{{block "nav" .}}{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main class="container">
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer style="text-align: center; padding: 2rem; color: var(--text-muted); font-size: 0.875rem;">
|
||||||
FHIR Health Platform — SMART on FHIR R4
|
<p>FHIR Health Platform © 2026 — SMART on FHIR R4 Sandbox</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{{block "scripts" .}}{{end}}
|
{{block "scripts" .}}{{end}}
|
||||||
|
|||||||
@@ -1,180 +1,181 @@
|
|||||||
{{template "base.html" .}}
|
{{template "base.html" .}}
|
||||||
|
|
||||||
{{define "nav"}}
|
{{define "nav"}}
|
||||||
<a href="/patients">All Patients</a>
|
<a href="/patients" class="nav-link">All Patients</a>
|
||||||
<a href="/">Home</a>
|
<a href="/" class="nav-link">Home</a>
|
||||||
<form action="/logout" method="POST" style="display:inline">
|
<form action="/logout" method="POST" style="display:inline">
|
||||||
<button class="btn btn-danger" type="submit" style="padding:6px 12px;font-size:.875rem;">Logout</button>
|
<button class="btn btn-danger" type="submit">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
|
||||||
{{/* ---- Success Flash Message ---- */}}
|
{{/* ---- Flash Messages ---- */}}
|
||||||
{{if .Synced}}
|
{{if .Synced}}
|
||||||
<div class="card" style="background-color:#d4edda;border-left:4px solid #28a745;margin-bottom:16px;">
|
<div class="flash-message flash-success">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
<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>
|
<div>
|
||||||
<strong>✓ Sync complete</strong>
|
<strong>Sync complete</strong>
|
||||||
<span style="font-size:.875rem;color:#555;">Data has been refreshed from the EHR.</span>
|
<span class="text-sm">Data has been refreshed from the EHR.</span>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="this.parentElement.style.display='none';" style="background:none;border:none;cursor:pointer;font-size:1.2rem;color:#666;">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button onclick="this.parentElement.style.display='none';" style="background:none;border:none;cursor:pointer;font-size:1.2rem;color:inherit;">×</button>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* ---- Practitioner block ---- */}}
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
|
||||||
|
{{/* ---- Left Column: Patient Context ---- */}}
|
||||||
|
<div class="md:col-span-1">
|
||||||
|
|
||||||
|
{{/* Practitioner Card */}}
|
||||||
{{if .Practitioner}}
|
{{if .Practitioner}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">Logged-in Clinician</div>
|
<div class="card-header">
|
||||||
<div class="user-header">
|
<h3 class="card-title">Clinician</h3>
|
||||||
|
<span class="badge badge-neutral">Active Session</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 mb-4">
|
||||||
<div class="avatar avatar-practitioner">
|
<div class="avatar avatar-practitioner">
|
||||||
{{if .Practitioner.FirstName}}{{slice .Practitioner.FirstName 0 1}}{{end}}{{if .Practitioner.LastName}}{{slice .Practitioner.LastName 0 1}}{{end}}
|
{{if .Practitioner.FirstName}}{{slice .Practitioner.FirstName 0 1}}{{end}}{{if .Practitioner.LastName}}{{slice .Practitioner.LastName 0 1}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="user-header-info">
|
<div>
|
||||||
<h2>
|
<h4 class="font-bold">
|
||||||
{{if .Practitioner.FirstName}}{{.Practitioner.FirstName}} {{end}}
|
{{if .Practitioner.FirstName}}{{.Practitioner.FirstName}} {{end}}
|
||||||
{{if .Practitioner.MiddleName}}{{.Practitioner.MiddleName}} {{end}}
|
{{if .Practitioner.MiddleName}}{{.Practitioner.MiddleName}} {{end}}
|
||||||
{{if .Practitioner.LastName}}{{.Practitioner.LastName}}{{end}}
|
{{if .Practitioner.LastName}}{{.Practitioner.LastName}}{{end}}
|
||||||
</h2>
|
</h4>
|
||||||
{{if eq .Practitioner.Role "practitioner"}}
|
<span class="text-sm text-muted">
|
||||||
<span class="badge badge-practitioner">Health Care Practitioner</span>
|
{{if eq .Practitioner.Role "practitioner"}}Health Care Practitioner{{else}}Patient{{end}}
|
||||||
{{else}}
|
</span>
|
||||||
<span class="badge badge-patient">Patient (Self-Service)</span>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-grid">
|
|
||||||
<div class="detail-item">
|
|
||||||
<label>Date of Birth</label>
|
|
||||||
<p>{{formatDate .Practitioner.DOB}}</p>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<label>Gender</label>
|
|
||||||
<p>{{titleCase .Practitioner.Gender}}</p>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<label>Email</label>
|
|
||||||
<p>{{orDash .Practitioner.Email}}</p>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<label>FHIR ID</label>
|
|
||||||
<p class="text-muted">{{.Practitioner.FHIRID}}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{else}}
|
|
||||||
<div class="card">
|
|
||||||
<p class="text-muted">No practitioner context was returned by this EHR. Session not established.</p>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* ---- Patient block ---- */}}
|
{{/* Patient Card */}}
|
||||||
{{if .Patient}}
|
{{if .Patient}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">Active Patient</div>
|
<div class="card-header">
|
||||||
<div class="user-header">
|
<h3 class="card-title">Patient</h3>
|
||||||
|
<span class="badge badge-success">Active Context</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 mb-4">
|
||||||
<div class="avatar avatar-patient">
|
<div class="avatar avatar-patient">
|
||||||
{{if .Patient.FirstName}}{{slice .Patient.FirstName 0 1}}{{end}}{{if .Patient.LastName}}{{slice .Patient.LastName 0 1}}{{end}}
|
{{if .Patient.FirstName}}{{slice .Patient.FirstName 0 1}}{{end}}{{if .Patient.LastName}}{{slice .Patient.LastName 0 1}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="user-header-info">
|
<div>
|
||||||
<h2>
|
<h2 class="font-bold text-lg">
|
||||||
{{if .Patient.FirstName}}{{.Patient.FirstName}} {{end}}
|
{{if .Patient.FirstName}}{{.Patient.FirstName}} {{end}}
|
||||||
{{if .Patient.MiddleName}}{{.Patient.MiddleName}} {{end}}
|
{{if .Patient.MiddleName}}{{.Patient.MiddleName}} {{end}}
|
||||||
{{if .Patient.LastName}}{{.Patient.LastName}}{{end}}
|
{{if .Patient.LastName}}{{.Patient.LastName}}{{end}}
|
||||||
</h2>
|
</h2>
|
||||||
<span class="badge badge-patient">Patient</span>
|
<span class="text-sm text-muted">ID: {{.Patient.FHIRID}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr/>
|
|
||||||
<div class="detail-grid">
|
<div class="details-list" style="grid-template-columns: 1fr;">
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<label>Date of Birth</label>
|
<span class="detail-label">DOB</span>
|
||||||
<p>{{formatDate .Patient.DOB}}</p>
|
<span class="detail-value">{{formatDate .Patient.DOB}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<label>Gender</label>
|
<span class="detail-label">Gender</span>
|
||||||
<p>{{titleCase .Patient.Gender}}</p>
|
<span class="detail-value">{{titleCase .Patient.Gender}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<label>Email</label>
|
<span class="detail-label">Email</span>
|
||||||
<p>{{orDash .Patient.Email}}</p>
|
<span class="detail-value">{{orDash .Patient.Email}}</span>
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<label>FHIR ID</label>
|
|
||||||
<p class="text-muted">{{.Patient.FHIRID}}</p>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<label>EHR Server</label>
|
|
||||||
<p class="text-muted">{{.Patient.EHRURL}}</p>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<label>Internal ID</label>
|
|
||||||
<p class="text-muted">{{.Patient.ID}}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* ---- High-Criticality Allergy Warning Card ---- */}}
|
{{/* Alerts Card */}}
|
||||||
{{if hasCriticalAllergies .Allergies}}
|
{{if hasCriticalAllergies .Allergies}}
|
||||||
<div class="card" style="border-left:4px solid #dc3545;">
|
<div class="card" style="border-left: 4px solid var(--danger-color);">
|
||||||
<div style="display:flex;align-items:flex-start;gap:12px;">
|
<div class="card-header" style="border-bottom:none; padding-bottom:0; margin-bottom:0;">
|
||||||
<span style="font-size:1.5rem;">⚠</span>
|
<h3 class="card-title text-danger">Critical Allergies</h3>
|
||||||
<div>
|
</div>
|
||||||
<strong style="color:#dc3545;">High-Criticality Allergies Detected</strong>
|
<div class="mt-4">
|
||||||
<p style="margin-top:4px;margin-bottom:0;font-size:.875rem;color:#555;">
|
|
||||||
{{range .Allergies}}
|
{{range .Allergies}}
|
||||||
{{if eq .Criticality "high"}}
|
{{if eq .Criticality "high"}}
|
||||||
<strong>{{.CodeText}}</strong> ({{.ClinicalStatus}}){{if ne .CodeText (last .Allergies).CodeText}}<br/>{{end}}
|
<div class="flex justify-between items-center mb-2">
|
||||||
{{end}}
|
<span class="font-medium text-danger">{{.CodeText}}</span>
|
||||||
{{end}}
|
<span class="badge badge-danger">High</span>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* ---- Clinical Data block ---- */}}
|
{{/* SMART Inspector */}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
|
<details>
|
||||||
<div class="card-title" style="margin-bottom:0;">Clinical Data</div>
|
<summary>SMART Inspector</summary>
|
||||||
<div style="display:flex;align-items:center;gap:12px;">
|
<div class="mt-4">
|
||||||
{{if .LatestSync}}
|
<div class="detail-item mb-4">
|
||||||
<span class="text-muted" style="font-size:.8rem;">
|
<span class="detail-label">FHIR Base URL</span>
|
||||||
Last synced: {{formatDateTime .LatestSync.SyncedAt}}
|
<code class="code-block">{{.Session.EHRURL}}</code>
|
||||||
</span>
|
</div>
|
||||||
{{else}}
|
<div class="detail-item mb-4">
|
||||||
<span class="text-muted" style="font-size:.8rem;">Never synced</span>
|
<span class="detail-label">Scopes</span>
|
||||||
|
<code class="code-block">{{.Session.Scope}}</code>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Access Token</span>
|
||||||
|
<code class="code-block">{{.Session.AccessToken}}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ---- Right Column: Clinical Data ---- */}}
|
||||||
|
<div class="md:col-span-2" style="grid-column: span 2;"> <!-- Force span 2 if grid lines up -->
|
||||||
|
{{if .Patient}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Clinical Record</h3>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-sm text-muted">
|
||||||
|
{{if .LatestSync}}Synced: {{formatDateTime .LatestSync.SyncedAt}}{{else}}Never synced{{end}}
|
||||||
|
</span>
|
||||||
<form action="/dashboard/sync?patient_id={{.Patient.FHIRID}}" method="POST" style="display:inline;">
|
<form action="/dashboard/sync?patient_id={{.Patient.FHIRID}}" method="POST" style="display:inline;">
|
||||||
<button class="btn btn-primary" type="submit" style="padding:6px 14px;font-size:.875rem;" id="syncBtn" onclick="onSyncClick()">
|
<button class="btn btn-primary btn-sm" type="submit" id="syncBtn" onclick="onSyncClick()">
|
||||||
↻ Sync with EHR
|
<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"><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>
|
||||||
|
Sync
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab-link active" onclick="openTab(event, 'observations')">Vitals & Labs ({{len .Observations}})</button>
|
<button class="tab-btn active" onclick="openTab(event, 'observations')">Vitals & Labs <span class="badge badge-neutral ml-2">{{len .Observations}}</span></button>
|
||||||
<button class="tab-link" onclick="openTab(event, 'conditions')">Conditions ({{len .Conditions}})</button>
|
<button class="tab-btn" onclick="openTab(event, 'conditions')">Conditions <span class="badge badge-neutral ml-2">{{len .Conditions}}</span></button>
|
||||||
<button class="tab-link" onclick="openTab(event, 'medications')">Medications ({{len .Medications}})</button>
|
<button class="tab-btn" onclick="openTab(event, 'medications')">Meds <span class="badge badge-neutral ml-2">{{len .Medications}}</span></button>
|
||||||
<button class="tab-link" onclick="openTab(event, 'allergies')">Allergies ({{len .Allergies}})</button>
|
<button class="tab-btn" onclick="openTab(event, 'allergies')">Allergies <span class="badge badge-neutral ml-2">{{len .Allergies}}</span></button>
|
||||||
<button class="tab-link" onclick="openTab(event, 'notes')">Clinical Notes ({{len .DocumentReferences}})</button>
|
<button class="tab-btn" onclick="openTab(event, 'notes')">Notes <span class="badge badge-neutral ml-2">{{len .DocumentReferences}}</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* ---- Observations Tab (Grouped by Category) ---- */}}
|
{{/* Observations */}}
|
||||||
<div id="observations" class="tab-content" style="display:block;">
|
<div id="observations" class="tab-content active">
|
||||||
{{if .Observations}}
|
{{if .Observations}}
|
||||||
{{range $cat, $obs := groupByCategory .Observations}}
|
{{range $cat, $obs := groupByCategory .Observations}}
|
||||||
<div style="margin-bottom:20px;">
|
<div class="mb-4">
|
||||||
<h4 style="margin-bottom:12px;color:#333;">{{titleCase $cat}}</h4>
|
<h4 class="font-bold text-muted uppercase text-xs mb-2 tracking-wide">{{titleCase $cat}}</h4>
|
||||||
<table class="fhir-table">
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Code</th>
|
<th>Code</th>
|
||||||
<th>Value</th>
|
<th>Value</th>
|
||||||
<th style="width:60px;">Interpretation</th>
|
<th>Interpretation</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -182,54 +183,52 @@
|
|||||||
{{range $obs}}
|
{{range $obs}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{orDash .EffectiveDate}}</td>
|
<td>{{orDash .EffectiveDate}}</td>
|
||||||
<td>{{orDash .CodeText}}</td>
|
<td class="font-medium">{{orDash .CodeText}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{if .ValueQuantity}}
|
{{if .ValueQuantity}}
|
||||||
{{printf "%.4g" (derefFloat64 .ValueQuantity)}} {{.ValueUnit}}
|
<strong>{{printf "%.4g" (derefFloat64 .ValueQuantity)}}</strong> {{.ValueUnit}}
|
||||||
{{if or .ReferenceRangeLow .ReferenceRangeHigh}}
|
{{if or .ReferenceRangeLow .ReferenceRangeHigh}}
|
||||||
<br/><span style="font-size:.8rem;color:#666;">
|
<div class="text-xs text-muted">
|
||||||
[{{if .ReferenceRangeLow}}{{printf "%.4g" (derefFloat64 .ReferenceRangeLow)}}{{else}}—{{end}}–{{if .ReferenceRangeHigh}}{{printf "%.4g" (derefFloat64 .ReferenceRangeHigh)}}{{else}}—{{end}}]
|
Ref: [{{if .ReferenceRangeLow}}{{printf "%.4g" (derefFloat64 .ReferenceRangeLow)}}{{else}}—{{end}} – {{if .ReferenceRangeHigh}}{{printf "%.4g" (derefFloat64 .ReferenceRangeHigh)}}{{else}}—{{end}}]
|
||||||
</span>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else if .ValueString}}
|
{{else if .ValueString}}
|
||||||
{{.ValueString}}
|
{{.ValueString}}
|
||||||
{{else}}
|
{{else}}—{{end}}
|
||||||
—
|
|
||||||
{{end}}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{if .Interpretation}}
|
{{if .Interpretation}}
|
||||||
<span class="badge" style="font-size:.75rem;background:#e3f2fd;color:#1976d2;">{{.Interpretation}}</span>
|
<span class="badge badge-info">{{.Interpretation}}</span>
|
||||||
{{else}}
|
{{else}}—{{end}}
|
||||||
—
|
|
||||||
{{end}}
|
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge badge-outline">{{.Status}}</span></td>
|
<td><span class="badge badge-neutral">{{.Status}}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="text-muted mt-4">No observations found. Use "Sync with EHR" to pull data.</p>
|
<div class="text-center py-8 text-muted">No observations found.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* ---- Conditions Tab (with filter bar) ---- */}}
|
{{/* Conditions */}}
|
||||||
<div id="conditions" class="tab-content" style="display:none;">
|
<div id="conditions" class="tab-content">
|
||||||
{{if .Conditions}}
|
{{if .Conditions}}
|
||||||
<div style="margin-bottom:12px;display:flex;gap:8px;">
|
<div class="flex gap-2 mb-4">
|
||||||
<button onclick="filterConditions('all')" class="btn btn-outline" style="padding:6px 12px;font-size:.85rem;">All</button>
|
<button onclick="filterConditions('all')" class="btn btn-outline btn-sm active">All</button>
|
||||||
<button onclick="filterConditions('active')" class="btn btn-outline" style="padding:6px 12px;font-size:.85rem;">Active</button>
|
<button onclick="filterConditions('active')" class="btn btn-outline btn-sm">Active</button>
|
||||||
<button onclick="filterConditions('resolved')" class="btn btn-outline" style="padding:6px 12px;font-size:.85rem;">Resolved</button>
|
<button onclick="filterConditions('resolved')" class="btn btn-outline btn-sm">Resolved</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="fhir-table">
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Recorded Date</th>
|
<th>Date</th>
|
||||||
<th>Code</th>
|
<th>Condition</th>
|
||||||
<th>Clinical Status</th>
|
<th>Status</th>
|
||||||
<th>Verification</th>
|
<th>Verification</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -237,25 +236,35 @@
|
|||||||
{{range .Conditions}}
|
{{range .Conditions}}
|
||||||
<tr class="condition-row" data-status="{{.ClinicalStatus}}">
|
<tr class="condition-row" data-status="{{.ClinicalStatus}}">
|
||||||
<td>{{orDash .RecordedDate}}</td>
|
<td>{{orDash .RecordedDate}}</td>
|
||||||
<td>{{orDash .CodeText}}</td>
|
<td class="font-medium">{{orDash .CodeText}}</td>
|
||||||
<td>{{titleCase .ClinicalStatus}}</td>
|
<td>
|
||||||
|
{{if eq .ClinicalStatus "active"}}
|
||||||
|
<span class="badge badge-success">Active</span>
|
||||||
|
{{else if eq .ClinicalStatus "resolved"}}
|
||||||
|
<span class="badge badge-neutral">Resolved</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge badge-warning">{{.ClinicalStatus}}</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
<td>{{titleCase .VerificationStatus}}</td>
|
<td>{{titleCase .VerificationStatus}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="text-muted mt-4">No conditions found. Use "Sync with EHR" to pull data.</p>
|
<div class="text-center py-8 text-muted">No conditions found.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* ---- Medications Tab ---- */}}
|
{{/* Medications */}}
|
||||||
<div id="medications" class="tab-content" style="display:none;">
|
<div id="medications" class="tab-content">
|
||||||
{{if .Medications}}
|
{{if .Medications}}
|
||||||
<table class="fhir-table">
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Authored On</th>
|
<th>Authored</th>
|
||||||
<th>Medication</th>
|
<th>Medication</th>
|
||||||
<th>Dosage</th>
|
<th>Dosage</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
@@ -266,45 +275,53 @@
|
|||||||
{{range .Medications}}
|
{{range .Medications}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{orDash .AuthoredOn}}</td>
|
<td>{{orDash .AuthoredOn}}</td>
|
||||||
<td>{{orDash .MedCodeText}}</td>
|
<td class="font-medium">{{orDash .MedCodeText}}</td>
|
||||||
<td>{{orDash .DosageText}}</td>
|
<td>{{orDash .DosageText}}</td>
|
||||||
<td><span class="badge badge-outline">{{.Status}}</span></td>
|
<td>
|
||||||
|
{{if eq .Status "active"}}
|
||||||
|
<span class="badge badge-success">Active</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge badge-neutral">{{.Status}}</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
<td>{{orDash .RequesterDisplay}}</td>
|
<td>{{orDash .RequesterDisplay}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="text-muted mt-4">No medications found. Use "Sync with EHR" to pull data.</p>
|
<div class="text-center py-8 text-muted">No medications found.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* ---- Allergies Tab ---- */}}
|
{{/* Allergies */}}
|
||||||
<div id="allergies" class="tab-content" style="display:none;">
|
<div id="allergies" class="tab-content">
|
||||||
{{if .Allergies}}
|
{{if .Allergies}}
|
||||||
<table class="fhir-table">
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Recorded Date</th>
|
<th>Date</th>
|
||||||
<th>Allergen</th>
|
<th>Allergen</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Criticality</th>
|
<th>Criticality</th>
|
||||||
<th>Clinical Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Allergies}}
|
{{range .Allergies}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{orDash .RecordedDate}}</td>
|
<td>{{orDash .RecordedDate}}</td>
|
||||||
<td>{{orDash .CodeText}}</td>
|
<td class="font-medium">{{orDash .CodeText}}</td>
|
||||||
<td>{{titleCase .Type}}</td>
|
<td>{{titleCase .Type}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{if eq .Criticality "high"}}
|
{{if eq .Criticality "high"}}
|
||||||
<span class="badge" style="background:#dc3545;color:white;">{{.Criticality}}</span>
|
<span class="badge badge-danger">High</span>
|
||||||
{{else if eq .Criticality "medium"}}
|
{{else if eq .Criticality "medium"}}
|
||||||
<span class="badge" style="background:#ffc107;color:#000;">{{.Criticality}}</span>
|
<span class="badge badge-warning">Medium</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="badge badge-outline">{{orDash .Criticality}}</span>
|
<span class="badge badge-neutral">{{orDash .Criticality}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>{{titleCase .ClinicalStatus}}</td>
|
<td>{{titleCase .ClinicalStatus}}</td>
|
||||||
@@ -312,141 +329,56 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="text-muted mt-4">No allergies found. Use "Sync with EHR" to pull data.</p>
|
<div class="text-center py-8 text-muted">No allergies found.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* ---- Clinical Notes Tab ---- */}}
|
{{/* Notes */}}
|
||||||
<div id="notes" class="tab-content" style="display:none;">
|
<div id="notes" class="tab-content">
|
||||||
{{if .DocumentReferences}}
|
{{if .DocumentReferences}}
|
||||||
<table class="fhir-table">
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Content</th>
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .DocumentReferences}}
|
{{range .DocumentReferences}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{orDash .Date}}</td>
|
<td>{{orDash .Date}}</td>
|
||||||
<td>{{orDash .TypeText}}</td>
|
<td class="font-medium">{{orDash .TypeText}}</td>
|
||||||
<td>{{orDash .Description}}</td>
|
<td>{{orDash .Description}}</td>
|
||||||
<td><span class="badge badge-outline">{{orDash .Status}}</span></td>
|
<td><span class="badge badge-neutral">{{orDash .Status}}</span></td>
|
||||||
<td>
|
<td>
|
||||||
{{if .ContentURL}}
|
{{if .ContentURL}}
|
||||||
<a href="{{.ContentURL}}" target="_blank" rel="noopener noreferrer" style="font-size:.8rem;">View</a>
|
<a href="{{.ContentURL}}" target="_blank" class="btn btn-outline btn-sm" style="padding: 2px 8px; font-size: 0.75rem;">View</a>
|
||||||
{{else if .ContentData}}
|
|
||||||
<span class="text-muted" style="font-size:.8rem;">Inline ({{.ContentType}})</span>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
—
|
<span class="text-muted text-xs">Inline</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="text-muted mt-4">No clinical notes found. Use "Sync with EHR" to pull data.</p>
|
<div class="text-center py-8 text-muted">No clinical notes found.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}} {{/* End if .Patient */}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* ---- SMART Inspector Accordion ---- */}}
|
|
||||||
<details style="margin-bottom:20px;">
|
|
||||||
<summary style="cursor:pointer; font-size:.875rem; font-weight:600; color:var(--color-primary); padding:8px 0;">
|
|
||||||
SMART Inspector
|
|
||||||
</summary>
|
|
||||||
<div class="card" style="margin-top:8px;">
|
|
||||||
<div class="mt-4">
|
|
||||||
<div class="detail-item mb-4">
|
|
||||||
<label>FHIR Base URL (ISS)</label>
|
|
||||||
<p class="text-muted">{{.Session.EHRURL}}</p>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item mb-4">
|
|
||||||
<label>Granted Scopes</label>
|
|
||||||
<p class="text-muted">{{.Session.Scope}}</p>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item mb-4">
|
|
||||||
<label>Access Token</label>
|
|
||||||
<code style="word-break: break-all; font-size: 0.75rem; background: var(--color-bg); padding: 8px; border-radius: 4px; display: block;">{{.Session.AccessToken}}</code>
|
|
||||||
</div>
|
|
||||||
{{if .Session.IDToken}}
|
|
||||||
<div class="detail-item mt-4">
|
|
||||||
<label>ID Token</label>
|
|
||||||
<code style="word-break: break-all; font-size: 0.75rem; background: var(--color-bg); padding: 8px; border-radius: 4px; display: block;">{{.Session.IDToken}}</code>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
{{/* ---- Raw FHIR resource accordion ---- */}}
|
|
||||||
{{if .RawPatient}}
|
|
||||||
<details style="margin-bottom:20px;">
|
|
||||||
<summary style="cursor:pointer; font-size:.875rem; font-weight:600; color:var(--color-primary); padding:8px 0;">
|
|
||||||
View Raw FHIR Patient Resource
|
|
||||||
</summary>
|
|
||||||
<div class="card" style="margin-top:8px;">
|
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:.85rem;">
|
|
||||||
<thead>
|
|
||||||
<tr style="border-bottom:2px solid var(--color-border);">
|
|
||||||
<th style="text-align:left;padding:6px 12px 8px;color:var(--color-muted);font-size:.7rem;letter-spacing:.06em;text-transform:uppercase;">Field</th>
|
|
||||||
<th style="text-align:left;padding:6px 12px 8px;color:var(--color-muted);font-size:.7rem;letter-spacing:.06em;text-transform:uppercase;">Value</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr style="border-bottom:1px solid var(--color-border);">
|
|
||||||
<td style="padding:7px 12px;font-weight:600;">resourceType</td>
|
|
||||||
<td style="padding:7px 12px;">{{.RawPatient.ResourceTypeField}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom:1px solid var(--color-border);">
|
|
||||||
<td style="padding:7px 12px;font-weight:600;">id</td>
|
|
||||||
<td style="padding:7px 12px;">{{.RawPatient.ID}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom:1px solid var(--color-border);">
|
|
||||||
<td style="padding:7px 12px;font-weight:600;">gender</td>
|
|
||||||
<td style="padding:7px 12px;">{{.RawPatient.Gender}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom:1px solid var(--color-border);">
|
|
||||||
<td style="padding:7px 12px;font-weight:600;">birthDate</td>
|
|
||||||
<td style="padding:7px 12px;">{{.RawPatient.BirthDate}}</td>
|
|
||||||
</tr>
|
|
||||||
{{range .RawPatient.Name}}
|
|
||||||
<tr style="border-bottom:1px solid var(--color-border);">
|
|
||||||
<td style="padding:7px 12px;font-weight:600;">name ({{.Use}})</td>
|
|
||||||
<td style="padding:7px 12px;">
|
|
||||||
{{range .Given}}{{.}} {{end}}{{.Family}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
{{range .RawPatient.Telecom}}
|
|
||||||
<tr style="border-bottom:1px solid var(--color-border);">
|
|
||||||
<td style="padding:7px 12px;font-weight:600;">telecom ({{.System}})</td>
|
|
||||||
<td style="padding:7px 12px;">{{.Value}}</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
{{range .RawPatient.Address}}
|
|
||||||
<tr style="border-bottom:1px solid var(--color-border);">
|
|
||||||
<td style="padding:7px 12px;font-weight:600;">address</td>
|
|
||||||
<td style="padding:7px 12px;">
|
|
||||||
{{range .Line}}{{.}}, {{end}}{{.City}}{{if .State}}, {{.State}}{{end}} {{.PostalCode}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{end}}{{/* end if .Patient */}}
|
|
||||||
|
|
||||||
{{end}}{{/* end content */}}
|
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script>
|
<script>
|
||||||
@@ -454,18 +386,29 @@ function openTab(evt, tabName) {
|
|||||||
var i, tabcontent, tablinks;
|
var i, tabcontent, tablinks;
|
||||||
tabcontent = document.getElementsByClassName("tab-content");
|
tabcontent = document.getElementsByClassName("tab-content");
|
||||||
for (i = 0; i < tabcontent.length; i++) {
|
for (i = 0; i < tabcontent.length; i++) {
|
||||||
|
tabcontent[i].classList.remove("active");
|
||||||
tabcontent[i].style.display = "none";
|
tabcontent[i].style.display = "none";
|
||||||
}
|
}
|
||||||
tablinks = document.getElementsByClassName("tab-link");
|
tablinks = document.getElementsByClassName("tab-btn");
|
||||||
for (i = 0; i < tablinks.length; i++) {
|
for (i = 0; i < tablinks.length; i++) {
|
||||||
tablinks[i].className = tablinks[i].className.replace(" active", "");
|
tablinks[i].classList.remove("active");
|
||||||
}
|
}
|
||||||
document.getElementById(tabName).style.display = "block";
|
|
||||||
evt.currentTarget.className += " active";
|
var target = document.getElementById(tabName);
|
||||||
|
target.style.display = "block";
|
||||||
|
// Small timeout to allow display:block to apply before adding active class for animation
|
||||||
|
setTimeout(() => target.classList.add("active"), 10);
|
||||||
|
|
||||||
|
evt.currentTarget.classList.add("active");
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterConditions(status) {
|
function filterConditions(status) {
|
||||||
var rows = document.getElementsByClassName("condition-row");
|
var rows = document.getElementsByClassName("condition-row");
|
||||||
|
// Update buttons
|
||||||
|
var btns = document.querySelectorAll('#conditions .btn-outline');
|
||||||
|
btns.forEach(b => b.classList.remove('active'));
|
||||||
|
event.target.classList.add('active');
|
||||||
|
|
||||||
for (var i = 0; i < rows.length; i++) {
|
for (var i = 0; i < rows.length; i++) {
|
||||||
var rowStatus = rows[i].getAttribute("data-status");
|
var rowStatus = rows[i].getAttribute("data-status");
|
||||||
if (status === "all" || rowStatus === status) {
|
if (status === "all" || rowStatus === status) {
|
||||||
@@ -478,8 +421,15 @@ function filterConditions(status) {
|
|||||||
|
|
||||||
function onSyncClick() {
|
function onSyncClick() {
|
||||||
var btn = document.getElementById("syncBtn");
|
var btn = document.getElementById("syncBtn");
|
||||||
|
// Delay disabling to ensure form submission triggers
|
||||||
|
setTimeout(function() {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = "Syncing\u2026";
|
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);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
.animate-spin { animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,9 +5,16 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="hero">
|
<div class="card max-w-lg mx-auto mt-12 text-center p-8 shadow-md">
|
||||||
<h1>{{.Code}}</h1>
|
<div class="mb-6 text-danger" style="display:flex; justify-content:center;">
|
||||||
<p>{{.Message}}</p>
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl font-bold mb-2">{{.Code}}</h1>
|
||||||
|
<p class="text-lg text-muted mb-8">{{.Message}}</p>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-4">
|
||||||
<a class="btn btn-outline" href="/">Go Home</a>
|
<a class="btn btn-outline" href="/">Go Home</a>
|
||||||
|
<a class="btn btn-primary" href="javascript:history.back()">Try Again</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,15 +1,61 @@
|
|||||||
{{template "base.html" .}}
|
{{template "base.html" .}}
|
||||||
|
|
||||||
{{define "nav"}}
|
{{define "nav"}}
|
||||||
<a href="/">Home</a>
|
<a href="/" class="nav-link">Home</a>
|
||||||
|
<a href="https://github.com/smart-on-fhir" target="_blank" class="nav-link">About SMART</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="hero">
|
<div class="text-center py-12">
|
||||||
<h1>FHIR Health Platform</h1>
|
<h1 class="text-4xl font-extrabold mb-4 text-primary">FHIR Health Platform</h1>
|
||||||
<p>A SMART on FHIR-powered platform for secure, context-aware EHR integration. Launch from your EHR to get started.</p>
|
<p class="text-xl text-muted max-w-2xl mx-auto mb-8">
|
||||||
<a class="btn btn-primary" href="https://launch.smarthealthit.org/?launch_url=http%3A%2F%2Flocalhost%3A8080%2Flaunch" target="_blank" rel="noopener">
|
Secure, context-aware EHR integration powered by SMART on FHIR R4.
|
||||||
Launch with SmartHealthIT Sandbox
|
Seamlessly visualize patient data, sync records, and improve clinical workflows.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-4">
|
||||||
|
<a class="btn btn-primary btn-lg" style="padding: 12px 24px; font-size: 1.1rem;"
|
||||||
|
href="https://launch.smarthealthit.org/?launch_url=http%3A%2F%2Flocalhost%3A8080%2Flaunch&iss=https%3A%2F%2Flaunch.smarthealthit.org%2Fv%2Fr4%2Ffhir"
|
||||||
|
target="_blank" rel="noopener">
|
||||||
|
Launch Demo Environment
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-secondary" href="https://hl7.org/fhir/smart-app-launch/" target="_blank">
|
||||||
|
Read Documentation
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mt-12">
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="mb-4 text-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold mb-2">Secure Authentication</h3>
|
||||||
|
<p class="text-muted">
|
||||||
|
OAuth2 + OIDC integration ensures secure practitioner and patient context launch directly from the EHR.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="mb-4 text-secondary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold mb-2">Real-time Vitals</h3>
|
||||||
|
<p class="text-muted">
|
||||||
|
Instant synchronization of Observations, Conditions, and Medications using FHIR R4 standards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-6">
|
||||||
|
<div class="mb-4 text-info">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="card-header border-none p-0 mb-2">
|
||||||
|
<h3 class="text-lg font-bold">Clinical Notes</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">
|
||||||
|
Access DocumentReferences and clinical notes with built-in viewer support for PDF and text content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,12 +1,87 @@
|
|||||||
{{template "base.html" .}}
|
{{template "base.html" .}}
|
||||||
|
|
||||||
{{define "nav"}}
|
{{define "nav"}}
|
||||||
<a href="/dashboard">Dashboard</a>
|
<a href="/dashboard" class="nav-link">Dashboard</a>
|
||||||
<form action="/logout" method="POST" style="display:inline">
|
<form action="/logout" method="POST" style="display:inline">
|
||||||
<button class="btn btn-danger" type="submit" style="padding:6px 12px;font-size:.875rem;">Logout</button>
|
<button class="btn btn-danger" type="submit">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h1 class="text-2xl font-bold">Patient Directory</h1>
|
||||||
|
<div class="text-sm text-muted">
|
||||||
|
EHR: <code class="code-block inline-block">{{.Session.EHRURL}}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Synced Patients <span class="badge badge-neutral ml-2">{{len .Patients}}</span></h3>
|
||||||
|
<div class="card-subtitle mt-2">
|
||||||
|
Patients synced from the EHR during this or previous sessions.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" id="patientSearch"
|
||||||
|
placeholder="Search by name or FHIR ID..."
|
||||||
|
class="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
style="width: 100%; padding: 0.5rem 1rem; border: 1px solid var(--border-color); border-radius: var(--radius);"
|
||||||
|
onkeyup="filterPatients()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Patients}}
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Date of Birth</th>
|
||||||
|
<th>Gender</th>
|
||||||
|
<th>FHIR ID</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Patients}}
|
||||||
|
<tr class="patient-row" data-name="{{.FirstName}} {{.LastName}}" data-fhir-id="{{.FHIRID}}">
|
||||||
|
<td class="font-medium">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="avatar avatar-patient" style="width: 32px; height: 32px; font-size: 0.875rem;">
|
||||||
|
{{if .FirstName}}{{slice .FirstName 0 1}}{{end}}{{if .LastName}}{{slice .LastName 0 1}}{{end}}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{if .FirstName}}{{.FirstName}} {{end}}
|
||||||
|
{{if .LastName}}{{.LastName}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{formatDate .DOB}}</td>
|
||||||
|
<td>{{titleCase .Gender}}</td>
|
||||||
|
<td class="text-muted font-mono text-xs">{{.FHIRID}}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<a href="/dashboard?patient_id={{.FHIRID}}" class="btn btn-primary btn-sm" style="padding: 4px 12px; font-size: 0.75rem;">
|
||||||
|
View Dashboard
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="text-center py-12 text-muted bg-gray-50 rounded-lg">
|
||||||
|
<p>No patients have been synced yet.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script>
|
<script>
|
||||||
function filterPatients() {
|
function filterPatients() {
|
||||||
@@ -27,59 +102,3 @@ function filterPatients() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{define "content"}}
|
|
||||||
|
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
|
|
||||||
<h1 style="margin:0;">All Patients</h1>
|
|
||||||
<div class="text-muted" style="font-size:.9rem;">
|
|
||||||
EHR: <strong>{{.Session.EHRURL}}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-title">Synced Patients ({{len .Patients}})</div>
|
|
||||||
<p class="text-muted" style="margin-bottom:20px;">The following patients have been synced from the EHR during this or previous sessions.</p>
|
|
||||||
|
|
||||||
{{if .Patients}}
|
|
||||||
<div style="margin-bottom:16px;">
|
|
||||||
<input type="text" id="patientSearch" placeholder="Search by name or FHIR ID..."
|
|
||||||
style="padding:8px 12px;border:1px solid #ddd;border-radius:4px;width:100%;font-size:.9rem;"
|
|
||||||
onkeyup="filterPatients()">
|
|
||||||
</div>
|
|
||||||
<table class="fhir-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Date of Birth</th>
|
|
||||||
<th>Gender</th>
|
|
||||||
<th>FHIR ID</th>
|
|
||||||
<th style="text-align:right;">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Patients}}
|
|
||||||
<tr class="patient-row" data-name="{{.FirstName}} {{.LastName}}" data-fhir-id="{{.FHIRID}}">
|
|
||||||
<td style="font-weight:600;">
|
|
||||||
{{if .FirstName}}{{.FirstName}} {{end}}
|
|
||||||
{{if .LastName}}{{.LastName}}{{end}}
|
|
||||||
</td>
|
|
||||||
<td>{{formatDate .DOB}}</td>
|
|
||||||
<td>{{titleCase .Gender}}</td>
|
|
||||||
<td class="text-muted">{{.FHIRID}}</td>
|
|
||||||
<td style="text-align:right;">
|
|
||||||
<a href="/dashboard?patient_id={{.FHIRID}}" class="btn btn-primary" style="padding:4px 10px;font-size:.75rem;text-decoration:none;">
|
|
||||||
View Dashboard
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{else}}
|
|
||||||
<div style="text-align:center;padding:40px 0;">
|
|
||||||
<p class="text-muted">No patients have been synced yet.</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{end}}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user