UI Upgrades

This commit is contained in:
2026-02-20 23:59:46 -05:00
parent 2171991dc8
commit c25b6e0c80
15 changed files with 2494 additions and 892 deletions

View File

@@ -411,12 +411,15 @@ func (s *Store) UpsertAllergyIntolerance(a *models.AllergyIntolerance) (string,
code_system = ?,
code_code = ?,
recorded_date = ?,
reaction_severity = ?,
reaction_manifestation = ?,
synced_at = ?
WHERE id = ?`,
a.PatientFHIRID, a.ClinicalStatus, a.VerificationStatus,
a.Type, a.Category, a.Criticality,
a.CodeText, a.CodeSystem, a.CodeCode,
a.RecordedDate, now, existingID,
a.RecordedDate, a.ReactionSeverity, a.ReactionManifestation,
now, existingID,
)
if err != nil {
return "", fmt.Errorf("db: update allergy_intolerance %s: %w", existingID, err)
@@ -429,11 +432,13 @@ func (s *Store) UpsertAllergyIntolerance(a *models.AllergyIntolerance) (string,
INSERT INTO allergy_intolerances (
id, fhir_id, ehr_url, patient_fhir_id,
clinical_status, verification_status, type, category, criticality,
code_text, code_system, code_code, recorded_date, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
code_text, code_system, code_code, recorded_date,
reaction_severity, reaction_manifestation, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, a.FHIRID, a.EHRURL, a.PatientFHIRID,
a.ClinicalStatus, a.VerificationStatus, a.Type, a.Category, a.Criticality,
a.CodeText, a.CodeSystem, a.CodeCode, a.RecordedDate, now,
a.CodeText, a.CodeSystem, a.CodeCode, a.RecordedDate,
a.ReactionSeverity, a.ReactionManifestation, now,
)
if err != nil {
return "", fmt.Errorf("db: insert allergy_intolerance fhir_id=%s: %w", a.FHIRID, err)
@@ -446,7 +451,8 @@ func (s *Store) ListAllergyIntolerances(patientFHIRID, ehrURL string) ([]models.
rows, err := s.db.Query(`
SELECT id, fhir_id, ehr_url, patient_fhir_id,
clinical_status, verification_status, type, category, criticality,
code_text, code_system, code_code, recorded_date, synced_at
code_text, code_system, code_code, recorded_date,
reaction_severity, reaction_manifestation, synced_at
FROM allergy_intolerances
WHERE patient_fhir_id = ? AND ehr_url = ?
ORDER BY recorded_date DESC`,
@@ -463,7 +469,8 @@ func (s *Store) ListAllergyIntolerances(patientFHIRID, ehrURL string) ([]models.
if err := rows.Scan(
&a.ID, &a.FHIRID, &a.EHRURL, &a.PatientFHIRID,
&a.ClinicalStatus, &a.VerificationStatus, &a.Type, &a.Category, &a.Criticality,
&a.CodeText, &a.CodeSystem, &a.CodeCode, &a.RecordedDate, &a.SyncedAt,
&a.CodeText, &a.CodeSystem, &a.CodeCode, &a.RecordedDate,
&a.ReactionSeverity, &a.ReactionManifestation, &a.SyncedAt,
); err != nil {
return nil, fmt.Errorf("db: scan allergy_intolerance: %w", err)
}
@@ -472,6 +479,262 @@ func (s *Store) ListAllergyIntolerances(patientFHIRID, ehrURL string) ([]models.
return out, rows.Err()
}
// ---------------------------------------------------------------------------
// Immunization
// ---------------------------------------------------------------------------
// UpsertImmunization inserts or updates an Immunization record keyed on (fhir_id, ehr_url).
func (s *Store) UpsertImmunization(imm *models.Immunization) (string, error) {
now := time.Now().UTC()
var existingID string
err := s.db.QueryRow(
`SELECT id FROM immunizations WHERE fhir_id = ? AND ehr_url = ?`,
imm.FHIRID, imm.EHRURL,
).Scan(&existingID)
if err == nil {
_, err = s.db.Exec(`
UPDATE immunizations SET
patient_fhir_id = ?,
status = ?,
vaccine_text = ?,
vaccine_system = ?,
vaccine_code = ?,
occurrence_date = ?,
primary_source = ?,
lot_number = ?,
synced_at = ?
WHERE id = ?`,
imm.PatientFHIRID, imm.Status,
imm.VaccineText, imm.VaccineSystem, imm.VaccineCode,
imm.OccurrenceDate, imm.PrimarySource, imm.LotNumber,
now, existingID,
)
if err != nil {
return "", fmt.Errorf("db: update immunization %s: %w", existingID, err)
}
return existingID, nil
}
id := uuid.NewString()
_, err = s.db.Exec(`
INSERT INTO immunizations (
id, fhir_id, ehr_url, patient_fhir_id,
status, vaccine_text, vaccine_system, vaccine_code,
occurrence_date, primary_source, lot_number, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, imm.FHIRID, imm.EHRURL, imm.PatientFHIRID,
imm.Status, imm.VaccineText, imm.VaccineSystem, imm.VaccineCode,
imm.OccurrenceDate, imm.PrimarySource, imm.LotNumber, now,
)
if err != nil {
return "", fmt.Errorf("db: insert immunization fhir_id=%s: %w", imm.FHIRID, err)
}
return id, nil
}
// ListImmunizations returns all Immunizations for the given patient, newest first.
func (s *Store) ListImmunizations(patientFHIRID, ehrURL string) ([]models.Immunization, error) {
rows, err := s.db.Query(`
SELECT id, fhir_id, ehr_url, patient_fhir_id,
status, vaccine_text, vaccine_system, vaccine_code,
occurrence_date, primary_source, lot_number, synced_at
FROM immunizations
WHERE patient_fhir_id = ? AND ehr_url = ?
ORDER BY occurrence_date DESC`,
patientFHIRID, ehrURL,
)
if err != nil {
return nil, fmt.Errorf("db: list immunizations: %w", err)
}
defer rows.Close()
var out []models.Immunization
for rows.Next() {
var imm models.Immunization
if err := rows.Scan(
&imm.ID, &imm.FHIRID, &imm.EHRURL, &imm.PatientFHIRID,
&imm.Status, &imm.VaccineText, &imm.VaccineSystem, &imm.VaccineCode,
&imm.OccurrenceDate, &imm.PrimarySource, &imm.LotNumber, &imm.SyncedAt,
); err != nil {
return nil, fmt.Errorf("db: scan immunization: %w", err)
}
out = append(out, imm)
}
return out, rows.Err()
}
// ---------------------------------------------------------------------------
// Procedure
// ---------------------------------------------------------------------------
// UpsertProcedure inserts or updates a Procedure record keyed on (fhir_id, ehr_url).
func (s *Store) UpsertProcedure(p *models.Procedure) (string, error) {
now := time.Now().UTC()
var existingID string
err := s.db.QueryRow(
`SELECT id FROM procedures WHERE fhir_id = ? AND ehr_url = ?`,
p.FHIRID, p.EHRURL,
).Scan(&existingID)
if err == nil {
_, err = s.db.Exec(`
UPDATE procedures SET
patient_fhir_id = ?,
status = ?,
code_text = ?,
code_system = ?,
code_code = ?,
performed_date = ?,
reason_text = ?,
outcome = ?,
synced_at = ?
WHERE id = ?`,
p.PatientFHIRID, p.Status,
p.CodeText, p.CodeSystem, p.CodeCode,
p.PerformedDate, p.ReasonText, p.Outcome,
now, existingID,
)
if err != nil {
return "", fmt.Errorf("db: update procedure %s: %w", existingID, err)
}
return existingID, nil
}
id := uuid.NewString()
_, err = s.db.Exec(`
INSERT INTO procedures (
id, fhir_id, ehr_url, patient_fhir_id,
status, code_text, code_system, code_code,
performed_date, reason_text, outcome, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, p.FHIRID, p.EHRURL, p.PatientFHIRID,
p.Status, p.CodeText, p.CodeSystem, p.CodeCode,
p.PerformedDate, p.ReasonText, p.Outcome, now,
)
if err != nil {
return "", fmt.Errorf("db: insert procedure fhir_id=%s: %w", p.FHIRID, err)
}
return id, nil
}
// ListProcedures returns all Procedures for the given patient, newest first.
func (s *Store) ListProcedures(patientFHIRID, ehrURL string) ([]models.Procedure, error) {
rows, err := s.db.Query(`
SELECT id, fhir_id, ehr_url, patient_fhir_id,
status, code_text, code_system, code_code,
performed_date, reason_text, outcome, synced_at
FROM procedures
WHERE patient_fhir_id = ? AND ehr_url = ?
ORDER BY performed_date DESC`,
patientFHIRID, ehrURL,
)
if err != nil {
return nil, fmt.Errorf("db: list procedures: %w", err)
}
defer rows.Close()
var out []models.Procedure
for rows.Next() {
var p models.Procedure
if err := rows.Scan(
&p.ID, &p.FHIRID, &p.EHRURL, &p.PatientFHIRID,
&p.Status, &p.CodeText, &p.CodeSystem, &p.CodeCode,
&p.PerformedDate, &p.ReasonText, &p.Outcome, &p.SyncedAt,
); err != nil {
return nil, fmt.Errorf("db: scan procedure: %w", err)
}
out = append(out, p)
}
return out, rows.Err()
}
// ---------------------------------------------------------------------------
// Encounter
// ---------------------------------------------------------------------------
// UpsertEncounter inserts or updates an Encounter record keyed on (fhir_id, ehr_url).
func (s *Store) UpsertEncounter(e *models.Encounter) (string, error) {
now := time.Now().UTC()
var existingID string
err := s.db.QueryRow(
`SELECT id FROM encounters WHERE fhir_id = ? AND ehr_url = ?`,
e.FHIRID, e.EHRURL,
).Scan(&existingID)
if err == nil {
_, err = s.db.Exec(`
UPDATE encounters SET
patient_fhir_id = ?,
status = ?,
class = ?,
type_text = ?,
period_start = ?,
period_end = ?,
reason_text = ?,
synced_at = ?
WHERE id = ?`,
e.PatientFHIRID, e.Status, e.Class, e.TypeText,
e.PeriodStart, e.PeriodEnd, e.ReasonText,
now, existingID,
)
if err != nil {
return "", fmt.Errorf("db: update encounter %s: %w", existingID, err)
}
return existingID, nil
}
id := uuid.NewString()
_, err = s.db.Exec(`
INSERT INTO encounters (
id, fhir_id, ehr_url, patient_fhir_id,
status, class, type_text,
period_start, period_end, reason_text, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, e.FHIRID, e.EHRURL, e.PatientFHIRID,
e.Status, e.Class, e.TypeText,
e.PeriodStart, e.PeriodEnd, e.ReasonText, now,
)
if err != nil {
return "", fmt.Errorf("db: insert encounter fhir_id=%s: %w", e.FHIRID, err)
}
return id, nil
}
// ListEncounters returns all Encounters for the given patient, newest first.
func (s *Store) ListEncounters(patientFHIRID, ehrURL string) ([]models.Encounter, error) {
rows, err := s.db.Query(`
SELECT id, fhir_id, ehr_url, patient_fhir_id,
status, class, type_text,
period_start, period_end, reason_text, synced_at
FROM encounters
WHERE patient_fhir_id = ? AND ehr_url = ?
ORDER BY period_start DESC`,
patientFHIRID, ehrURL,
)
if err != nil {
return nil, fmt.Errorf("db: list encounters: %w", err)
}
defer rows.Close()
var out []models.Encounter
for rows.Next() {
var e models.Encounter
if err := rows.Scan(
&e.ID, &e.FHIRID, &e.EHRURL, &e.PatientFHIRID,
&e.Status, &e.Class, &e.TypeText,
&e.PeriodStart, &e.PeriodEnd, &e.ReasonText, &e.SyncedAt,
); err != nil {
return nil, fmt.Errorf("db: scan encounter: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// ---------------------------------------------------------------------------
// PatientSync
// ---------------------------------------------------------------------------

View File

@@ -271,6 +271,414 @@ func TestListDocumentReferences_Empty(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// MedicationRequest tests
// ---------------------------------------------------------------------------
func TestUpsertMedicationRequest_NewAndUpdate(t *testing.T) {
store := newTestStore(t)
med := &models.MedicationRequest{
FHIRID: "med-001",
EHRURL: testEHRURL,
PatientFHIRID: testPatientID,
Status: "active",
Intent: "order",
MedCodeText: "Metformin 500mg",
MedCodeSystem: "http://www.nlm.nih.gov/research/umls/rxnorm",
MedCodeCode: "860975",
AuthoredOn: "2024-01-10",
RequesterDisplay: "Dr. Smith",
DosageText: "1 tablet twice daily",
}
id1, err := store.UpsertMedicationRequest(med)
if err != nil {
t.Fatalf("initial UpsertMedicationRequest: %v", err)
}
if id1 == "" {
t.Fatal("expected non-empty ID")
}
med.Status = "stopped"
id2, err := store.UpsertMedicationRequest(med)
if err != nil {
t.Fatalf("update UpsertMedicationRequest: %v", err)
}
if id1 != id2 {
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
}
rows, err := store.ListMedicationRequests(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("ListMedicationRequests: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 med, got %d", len(rows))
}
if rows[0].Status != "stopped" {
t.Errorf("Status: got %q, want stopped", rows[0].Status)
}
}
func TestListMedicationRequests_Empty(t *testing.T) {
store := newTestStore(t)
rows, err := store.ListMedicationRequests("no-such-patient", testEHRURL)
if err != nil {
t.Fatalf("ListMedicationRequests: %v", err)
}
if len(rows) != 0 {
t.Errorf("expected 0, got %d", len(rows))
}
}
// ---------------------------------------------------------------------------
// AllergyIntolerance tests (including reaction fields)
// ---------------------------------------------------------------------------
func TestUpsertAllergyIntolerance_WithReaction(t *testing.T) {
store := newTestStore(t)
allergy := &models.AllergyIntolerance{
FHIRID: "allergy-001",
EHRURL: testEHRURL,
PatientFHIRID: testPatientID,
ClinicalStatus: "active",
VerificationStatus: "confirmed",
Type: "allergy",
Category: "medication",
Criticality: "high",
CodeText: "Penicillin",
CodeSystem: "http://www.nlm.nih.gov/research/umls/rxnorm",
CodeCode: "7980",
RecordedDate: "2018-05-01",
ReactionSeverity: "severe",
ReactionManifestation: "Anaphylaxis",
}
id1, err := store.UpsertAllergyIntolerance(allergy)
if err != nil {
t.Fatalf("initial UpsertAllergyIntolerance: %v", err)
}
if id1 == "" {
t.Fatal("expected non-empty ID")
}
// Update reaction.
allergy.ReactionSeverity = "moderate"
allergy.ReactionManifestation = "Rash"
id2, err := store.UpsertAllergyIntolerance(allergy)
if err != nil {
t.Fatalf("update UpsertAllergyIntolerance: %v", err)
}
if id1 != id2 {
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
}
rows, err := store.ListAllergyIntolerances(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("ListAllergyIntolerances: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 allergy, got %d", len(rows))
}
got := rows[0]
if got.ReactionSeverity != "moderate" {
t.Errorf("ReactionSeverity: got %q, want moderate", got.ReactionSeverity)
}
if got.ReactionManifestation != "Rash" {
t.Errorf("ReactionManifestation: got %q, want Rash", got.ReactionManifestation)
}
}
func TestUpsertAllergyIntolerance_NoReaction(t *testing.T) {
store := newTestStore(t)
allergy := &models.AllergyIntolerance{
FHIRID: "allergy-no-rxn",
EHRURL: testEHRURL,
PatientFHIRID: testPatientID,
ClinicalStatus: "active",
CodeText: "Latex",
}
_, err := store.UpsertAllergyIntolerance(allergy)
if err != nil {
t.Fatalf("UpsertAllergyIntolerance (no reaction): %v", err)
}
rows, err := store.ListAllergyIntolerances(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("ListAllergyIntolerances: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1, got %d", len(rows))
}
if rows[0].ReactionSeverity != "" {
t.Errorf("expected empty ReactionSeverity, got %q", rows[0].ReactionSeverity)
}
}
// ---------------------------------------------------------------------------
// Immunization tests
// ---------------------------------------------------------------------------
func TestUpsertImmunization_NewAndUpdate(t *testing.T) {
store := newTestStore(t)
imm := &models.Immunization{
FHIRID: "imm-001",
EHRURL: testEHRURL,
PatientFHIRID: testPatientID,
Status: "completed",
VaccineText: "Influenza, seasonal",
VaccineSystem: "http://hl7.org/fhir/sid/cvx",
VaccineCode: "141",
OccurrenceDate: "2023-10-01",
PrimarySource: true,
LotNumber: "LOT123",
}
id1, err := store.UpsertImmunization(imm)
if err != nil {
t.Fatalf("initial UpsertImmunization: %v", err)
}
if id1 == "" {
t.Fatal("expected non-empty ID")
}
imm.LotNumber = "LOT456"
id2, err := store.UpsertImmunization(imm)
if err != nil {
t.Fatalf("update UpsertImmunization: %v", err)
}
if id1 != id2 {
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
}
rows, err := store.ListImmunizations(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("ListImmunizations: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 immunization, got %d", len(rows))
}
if rows[0].LotNumber != "LOT456" {
t.Errorf("LotNumber: got %q, want LOT456", rows[0].LotNumber)
}
if !rows[0].PrimarySource {
t.Error("PrimarySource should be true")
}
}
func TestListImmunizations_Empty(t *testing.T) {
store := newTestStore(t)
rows, err := store.ListImmunizations("no-such-patient", testEHRURL)
if err != nil {
t.Fatalf("ListImmunizations: %v", err)
}
if len(rows) != 0 {
t.Errorf("expected 0, got %d", len(rows))
}
}
func TestListImmunizations_OrderedNewestFirst(t *testing.T) {
store := newTestStore(t)
for _, item := range []struct {
id string
date string
}{
{"imm-a", "2022-09-01"},
{"imm-b", "2023-10-15"},
{"imm-c", "2021-03-01"},
} {
_, err := store.UpsertImmunization(&models.Immunization{
FHIRID: item.id,
EHRURL: testEHRURL,
PatientFHIRID: testPatientID,
Status: "completed",
OccurrenceDate: item.date,
})
if err != nil {
t.Fatalf("UpsertImmunization %s: %v", item.id, err)
}
}
rows, err := store.ListImmunizations(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("ListImmunizations: %v", err)
}
if len(rows) != 3 {
t.Fatalf("expected 3, got %d", len(rows))
}
if rows[0].FHIRID != "imm-b" {
t.Errorf("first: got %q, want imm-b", rows[0].FHIRID)
}
if rows[2].FHIRID != "imm-c" {
t.Errorf("last: got %q, want imm-c", rows[2].FHIRID)
}
}
// ---------------------------------------------------------------------------
// Procedure tests
// ---------------------------------------------------------------------------
func TestUpsertProcedure_NewAndUpdate(t *testing.T) {
store := newTestStore(t)
proc := &models.Procedure{
FHIRID: "proc-001",
EHRURL: testEHRURL,
PatientFHIRID: testPatientID,
Status: "completed",
CodeText: "Appendectomy",
CodeSystem: "http://snomed.info/sct",
CodeCode: "80146002",
PerformedDate: "2019-06-15",
ReasonText: "Acute appendicitis",
Outcome: "Successful procedure",
}
id1, err := store.UpsertProcedure(proc)
if err != nil {
t.Fatalf("initial UpsertProcedure: %v", err)
}
if id1 == "" {
t.Fatal("expected non-empty ID")
}
proc.Outcome = "Procedure completed without complications"
id2, err := store.UpsertProcedure(proc)
if err != nil {
t.Fatalf("update UpsertProcedure: %v", err)
}
if id1 != id2 {
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
}
rows, err := store.ListProcedures(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("ListProcedures: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 procedure, got %d", len(rows))
}
if rows[0].Outcome != "Procedure completed without complications" {
t.Errorf("Outcome: got %q", rows[0].Outcome)
}
}
func TestListProcedures_Empty(t *testing.T) {
store := newTestStore(t)
rows, err := store.ListProcedures("no-such-patient", testEHRURL)
if err != nil {
t.Fatalf("ListProcedures: %v", err)
}
if len(rows) != 0 {
t.Errorf("expected 0, got %d", len(rows))
}
}
// ---------------------------------------------------------------------------
// Encounter tests
// ---------------------------------------------------------------------------
func TestUpsertEncounter_NewAndUpdate(t *testing.T) {
store := newTestStore(t)
enc := &models.Encounter{
FHIRID: "enc-001",
EHRURL: testEHRURL,
PatientFHIRID: testPatientID,
Status: "finished",
Class: "AMB",
TypeText: "Office visit",
PeriodStart: "2024-03-10",
PeriodEnd: "2024-03-10",
ReasonText: "Annual physical",
}
id1, err := store.UpsertEncounter(enc)
if err != nil {
t.Fatalf("initial UpsertEncounter: %v", err)
}
if id1 == "" {
t.Fatal("expected non-empty ID")
}
enc.Status = "cancelled"
id2, err := store.UpsertEncounter(enc)
if err != nil {
t.Fatalf("update UpsertEncounter: %v", err)
}
if id1 != id2 {
t.Errorf("ID changed on upsert: was %q, got %q", id1, id2)
}
rows, err := store.ListEncounters(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("ListEncounters: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 encounter, got %d", len(rows))
}
if rows[0].Status != "cancelled" {
t.Errorf("Status: got %q, want cancelled", rows[0].Status)
}
if rows[0].Class != "AMB" {
t.Errorf("Class: got %q, want AMB", rows[0].Class)
}
}
func TestListEncounters_Empty(t *testing.T) {
store := newTestStore(t)
rows, err := store.ListEncounters("no-such-patient", testEHRURL)
if err != nil {
t.Fatalf("ListEncounters: %v", err)
}
if len(rows) != 0 {
t.Errorf("expected 0, got %d", len(rows))
}
}
func TestListEncounters_OrderedNewestFirst(t *testing.T) {
store := newTestStore(t)
for _, item := range []struct {
id string
start string
}{
{"enc-a", "2023-01-01"},
{"enc-b", "2024-06-01"},
{"enc-c", "2022-12-01"},
} {
_, err := store.UpsertEncounter(&models.Encounter{
FHIRID: item.id,
EHRURL: testEHRURL,
PatientFHIRID: testPatientID,
Status: "finished",
PeriodStart: item.start,
})
if err != nil {
t.Fatalf("UpsertEncounter %s: %v", item.id, err)
}
}
rows, err := store.ListEncounters(testPatientID, testEHRURL)
if err != nil {
t.Fatalf("ListEncounters: %v", err)
}
if len(rows) != 3 {
t.Fatalf("expected 3, got %d", len(rows))
}
if rows[0].FHIRID != "enc-b" {
t.Errorf("first: got %q, want enc-b", rows[0].FHIRID)
}
if rows[2].FHIRID != "enc-c" {
t.Errorf("last: got %q, want enc-c", rows[2].FHIRID)
}
}
// ---------------------------------------------------------------------------
// PatientSync tests
// ---------------------------------------------------------------------------

View File

@@ -239,12 +239,81 @@ var migrations = []migration{
CREATE INDEX IF NOT EXISTS idx_allergy_intolerances_patient ON allergy_intolerances(patient_fhir_id, ehr_url);
`,
},
// Future migrations: append new entries here with incrementing version numbers.
// Example:
// {
// version: 2,
// sql: `ALTER TABLE users ADD COLUMN phone TEXT NOT NULL DEFAULT '';`,
// },
{
version: 5,
sql: `
ALTER TABLE allergy_intolerances ADD COLUMN reaction_severity TEXT NOT NULL DEFAULT '';
ALTER TABLE allergy_intolerances ADD COLUMN reaction_manifestation TEXT NOT NULL DEFAULT '';
`,
},
{
version: 6,
sql: `
CREATE TABLE IF NOT EXISTS immunizations (
id TEXT PRIMARY KEY,
fhir_id TEXT NOT NULL,
ehr_url TEXT NOT NULL,
patient_fhir_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '',
vaccine_text TEXT NOT NULL DEFAULT '',
vaccine_system TEXT NOT NULL DEFAULT '',
vaccine_code TEXT NOT NULL DEFAULT '',
occurrence_date TEXT NOT NULL DEFAULT '',
primary_source INTEGER NOT NULL DEFAULT 0,
lot_number TEXT NOT NULL DEFAULT '',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fhir_id, ehr_url)
);
CREATE INDEX IF NOT EXISTS idx_immunizations_patient ON immunizations(patient_fhir_id, ehr_url);
`,
},
{
version: 7,
sql: `
CREATE TABLE IF NOT EXISTS procedures (
id TEXT PRIMARY KEY,
fhir_id TEXT NOT NULL,
ehr_url TEXT NOT NULL,
patient_fhir_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '',
code_text TEXT NOT NULL DEFAULT '',
code_system TEXT NOT NULL DEFAULT '',
code_code TEXT NOT NULL DEFAULT '',
performed_date TEXT NOT NULL DEFAULT '',
reason_text TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT '',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fhir_id, ehr_url)
);
CREATE INDEX IF NOT EXISTS idx_procedures_patient ON procedures(patient_fhir_id, ehr_url);
`,
},
{
version: 8,
sql: `
CREATE TABLE IF NOT EXISTS encounters (
id TEXT PRIMARY KEY,
fhir_id TEXT NOT NULL,
ehr_url TEXT NOT NULL,
patient_fhir_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '',
class TEXT NOT NULL DEFAULT '',
type_text TEXT NOT NULL DEFAULT '',
period_start TEXT NOT NULL DEFAULT '',
period_end TEXT NOT NULL DEFAULT '',
reason_text TEXT NOT NULL DEFAULT '',
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fhir_id, ehr_url)
);
CREATE INDEX IF NOT EXISTS idx_encounters_patient ON encounters(patient_fhir_id, ehr_url);
`,
},
{
version: 9,
sql: `
ALTER TABLE users ADD COLUMN mrn TEXT NOT NULL DEFAULT '';
`,
},
}
// migrate applies any migrations that have not yet been run, in order.
@@ -315,6 +384,7 @@ func (s *Store) UpsertUser(u *models.User) (string, error) {
first_name = ?,
middle_name = ?,
last_name = ?,
mrn = ?,
dob = ?,
gender = ?,
email = ?,
@@ -322,7 +392,7 @@ func (s *Store) UpsertUser(u *models.User) (string, error) {
role = ?,
updated_at = ?
WHERE id = ?`,
u.FirstName, u.MiddleName, u.LastName,
u.FirstName, u.MiddleName, u.LastName, u.MRN,
u.DOB, u.Gender, u.Email,
u.FHIRResourceType, string(u.Role),
now, existingID,
@@ -342,11 +412,11 @@ func (s *Store) UpsertUser(u *models.User) (string, error) {
_, err = s.db.Exec(`
INSERT INTO users (
id, fhir_resource_type, fhir_id, ehr_url, role,
first_name, middle_name, last_name, dob, gender, email,
first_name, middle_name, last_name, mrn, dob, gender, email,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, u.FHIRResourceType, u.FHIRID, u.EHRURL, string(u.Role),
u.FirstName, u.MiddleName, u.LastName, u.DOB, u.Gender, u.Email,
u.FirstName, u.MiddleName, u.LastName, u.MRN, u.DOB, u.Gender, u.Email,
now, now,
)
if err != nil {
@@ -361,13 +431,13 @@ func (s *Store) GetUserByFHIRID(fhirID, ehrURL string) (*models.User, error) {
u := &models.User{}
err := s.db.QueryRow(`
SELECT id, fhir_resource_type, fhir_id, ehr_url, role,
first_name, middle_name, last_name, dob, gender, email,
first_name, middle_name, last_name, mrn, dob, gender, email,
created_at, updated_at
FROM users WHERE fhir_id = ? AND ehr_url = ?`,
fhirID, ehrURL,
).Scan(
&u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role,
&u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email,
&u.FirstName, &u.MiddleName, &u.LastName, &u.MRN, &u.DOB, &u.Gender, &u.Email,
&u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
@@ -381,12 +451,12 @@ func (s *Store) GetUserByID(id string) (*models.User, error) {
u := &models.User{}
err := s.db.QueryRow(`
SELECT id, fhir_resource_type, fhir_id, ehr_url, role,
first_name, middle_name, last_name, dob, gender, email,
first_name, middle_name, last_name, mrn, dob, gender, email,
created_at, updated_at
FROM users WHERE id = ?`, id,
).Scan(
&u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role,
&u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email,
&u.FirstName, &u.MiddleName, &u.LastName, &u.MRN, &u.DOB, &u.Gender, &u.Email,
&u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
@@ -399,7 +469,7 @@ func (s *Store) GetUserByID(id string) (*models.User, error) {
func (s *Store) ListUsersByRole(role models.Role, ehrURL string) ([]models.User, error) {
rows, err := s.db.Query(`
SELECT id, fhir_resource_type, fhir_id, ehr_url, role,
first_name, middle_name, last_name, dob, gender, email,
first_name, middle_name, last_name, mrn, dob, gender, email,
created_at, updated_at
FROM users WHERE role = ? AND ehr_url = ?
ORDER BY last_name ASC, first_name ASC`,
@@ -415,7 +485,7 @@ func (s *Store) ListUsersByRole(role models.Role, ehrURL string) ([]models.User,
var u models.User
if err := rows.Scan(
&u.ID, &u.FHIRResourceType, &u.FHIRID, &u.EHRURL, &u.Role,
&u.FirstName, &u.MiddleName, &u.LastName, &u.DOB, &u.Gender, &u.Email,
&u.FirstName, &u.MiddleName, &u.LastName, &u.MRN, &u.DOB, &u.Gender, &u.Email,
&u.CreatedAt, &u.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("db: scan user: %w", err)

View File

@@ -114,6 +114,16 @@ type Meta struct {
// https://www.hl7.org/fhir/patient.html
// ---------------------------------------------------------------------------
// Extension represents the FHIR Extension data type (R4).
// Extensions are used for US Core race/ethnicity and other modifiers.
type Extension struct {
URL string `json:"url"`
ValueCode string `json:"valueCode,omitempty"`
ValueString string `json:"valueString,omitempty"`
ValueCoding *Coding `json:"valueCoding,omitempty"`
Extension []Extension `json:"extension,omitempty"`
}
// Patient represents a FHIR R4 Patient resource.
// Fields are a curated subset of the full specification — add new fields
// here as the platform needs them, without breaking existing code.
@@ -129,6 +139,7 @@ type Patient struct {
BirthDate string `json:"birthDate"`
Address []Address `json:"address"`
MaritalStatus CodeableConcept `json:"maritalStatus"`
Extension []Extension `json:"extension"`
}
// ResourceType implements the Resource interface.
@@ -275,6 +286,12 @@ type MedicationRequest struct {
func (m *MedicationRequest) ResourceType() string { return "MedicationRequest" }
// AllergyReaction represents a reaction event in a FHIR AllergyIntolerance.
type AllergyReaction struct {
Manifestation []CodeableConcept `json:"manifestation"`
Severity string `json:"severity"` // mild | moderate | severe
}
// AllergyIntolerance represents a FHIR R4 AllergyIntolerance resource.
// https://www.hl7.org/fhir/allergyintolerance.html
type AllergyIntolerance struct {
@@ -288,10 +305,68 @@ type AllergyIntolerance struct {
Code CodeableConcept `json:"code"`
Patient Reference `json:"patient"`
RecordedDate string `json:"recordedDate"`
Reaction []AllergyReaction `json:"reaction"`
}
func (a *AllergyIntolerance) ResourceType() string { return "AllergyIntolerance" }
// Period represents the FHIR Period data type (R4).
type Period struct {
Start string `json:"start"`
End string `json:"end"`
}
// Immunization represents a FHIR R4 Immunization resource.
// https://www.hl7.org/fhir/immunization.html
type Immunization struct {
ResourceTypeField string `json:"resourceType"`
ID string `json:"id"`
Status string `json:"status"`
VaccineCode CodeableConcept `json:"vaccineCode"`
Patient Reference `json:"patient"`
OccurrenceDateTime string `json:"occurrenceDateTime"`
PrimarySource bool `json:"primarySource"`
LotNumber string `json:"lotNumber"`
}
func (i *Immunization) ResourceType() string { return "Immunization" }
// Procedure represents a FHIR R4 Procedure resource.
// https://www.hl7.org/fhir/procedure.html
type Procedure struct {
ResourceTypeField string `json:"resourceType"`
ID string `json:"id"`
Status string `json:"status"`
Code CodeableConcept `json:"code"`
Subject Reference `json:"subject"`
PerformedDateTime string `json:"performedDateTime"`
PerformedPeriod *Period `json:"performedPeriod,omitempty"`
ReasonCode []CodeableConcept `json:"reasonCode"`
Outcome CodeableConcept `json:"outcome"`
}
func (p *Procedure) ResourceType() string { return "Procedure" }
// EncounterClass represents the coded class of an Encounter (V3 ActCode).
type EncounterClass struct {
Code string `json:"code"`
}
// Encounter represents a FHIR R4 Encounter resource.
// https://www.hl7.org/fhir/encounter.html
type Encounter struct {
ResourceTypeField string `json:"resourceType"`
ID string `json:"id"`
Status string `json:"status"`
Class EncounterClass `json:"class"`
Type []CodeableConcept `json:"type"`
Subject Reference `json:"subject"`
Period *Period `json:"period,omitempty"`
ReasonCode []CodeableConcept `json:"reasonCode"`
}
func (e *Encounter) ResourceType() string { return "Encounter" }
// Quantity represents the FHIR Quantity data type.
type Quantity struct {
Value float64 `json:"value"`
@@ -565,7 +640,7 @@ func (c *Client) GetDocumentReferences(patientID, since string) ([]DocumentRefer
// If since is non-empty, only fetches requests modified after that timestamp (RFC3339).
func (c *Client) GetMedicationRequests(patientID, since string) ([]MedicationRequest, error) {
var bundle Bundle
path := fmt.Sprintf("MedicationRequest?patient=%s&status=active&_sort=-date", patientID)
path := fmt.Sprintf("MedicationRequest?patient=%s&_sort=-date", patientID)
if since != "" {
path += fmt.Sprintf("&_lastUpdated=ge%s", since)
}
@@ -615,6 +690,87 @@ func (c *Client) GetAllergyIntolerances(patientID, since string) ([]AllergyIntol
return allergies, nil
}
// GetImmunizations fetches Immunization resources for a specific patient.
// If since is non-empty, only fetches immunizations modified after that timestamp (RFC3339).
func (c *Client) GetImmunizations(patientID, since string) ([]Immunization, error) {
var bundle Bundle
path := fmt.Sprintf("Immunization?patient=%s&_sort=-date", patientID)
if since != "" {
path += fmt.Sprintf("&_lastUpdated=ge%s", since)
}
if err := c.get(path, &bundle); err != nil {
return nil, err
}
entries, err := c.fetchAllBundlePages(&bundle, 10)
if err != nil {
return nil, err
}
var immunizations []Immunization
for _, entry := range entries {
var imm Immunization
if err := json.Unmarshal(entry, &imm); err == nil {
immunizations = append(immunizations, imm)
}
}
return immunizations, nil
}
// GetProcedures fetches Procedure resources for a specific patient.
// If since is non-empty, only fetches procedures modified after that timestamp (RFC3339).
func (c *Client) GetProcedures(patientID, since string) ([]Procedure, error) {
var bundle Bundle
path := fmt.Sprintf("Procedure?patient=%s&_sort=-date", patientID)
if since != "" {
path += fmt.Sprintf("&_lastUpdated=ge%s", since)
}
if err := c.get(path, &bundle); err != nil {
return nil, err
}
entries, err := c.fetchAllBundlePages(&bundle, 10)
if err != nil {
return nil, err
}
var procedures []Procedure
for _, entry := range entries {
var p Procedure
if err := json.Unmarshal(entry, &p); err == nil {
procedures = append(procedures, p)
}
}
return procedures, nil
}
// GetEncounters fetches Encounter resources for a specific patient.
// If since is non-empty, only fetches encounters modified after that timestamp (RFC3339).
func (c *Client) GetEncounters(patientID, since string) ([]Encounter, error) {
var bundle Bundle
path := fmt.Sprintf("Encounter?patient=%s&_sort=-date", patientID)
if since != "" {
path += fmt.Sprintf("&_lastUpdated=ge%s", since)
}
if err := c.get(path, &bundle); err != nil {
return nil, err
}
entries, err := c.fetchAllBundlePages(&bundle, 10)
if err != nil {
return nil, err
}
var encounters []Encounter
for _, entry := range entries {
var e Encounter
if err := json.Unmarshal(entry, &e); err == nil {
encounters = append(encounters, e)
}
}
return encounters, nil
}
// GetSmartConfiguration fetches and parses the SMART discovery document
// for this FHIR server.
func GetSmartConfiguration(issURL string) (*SmartConfiguration, error) {
@@ -665,6 +821,30 @@ func primaryEmail(telecom []ContactPoint) string {
return ""
}
// extractMRN attempts to find a Medical Record Number in a FHIR Identifier slice.
// It looks for identifiers with a system containing "mrn" or the first identifier
// if none specifically match.
func extractMRN(identifiers []Identifier) string {
for _, id := range identifiers {
// Look for common MRN system patterns
system := strings.ToLower(id.System)
if strings.Contains(system, "mrn") || strings.Contains(system, "medical-record") {
return id.Value
}
// Also check the type code if present
for _, coding := range id.Type.Coding {
if strings.ToUpper(coding.Code) == "MR" {
return id.Value
}
}
}
// Fallback to the first identifier if we haven't found a definitive MRN
if len(identifiers) > 0 {
return identifiers[0].Value
}
return ""
}
// ExtractUserFromPatient converts a FHIR Patient resource into a platform
// models.User with Role=RolePatient. The ehrURL is the originating FHIR
// server base URL.
@@ -685,6 +865,7 @@ func ExtractUserFromPatient(p *Patient, ehrURL string) *models.User {
FirstName: first,
MiddleName: middle,
LastName: name.Family,
MRN: extractMRN(p.Identifier),
DOB: p.BirthDate,
Gender: p.Gender,
Email: primaryEmail(p.Telecom),
@@ -747,6 +928,20 @@ func firstCategoryText(cats []CodeableConcept) string {
return ""
}
// firstCategoryCode returns the first coding code of the first element in a
// []CodeableConcept. Preferred over firstCategoryText when machine-readable
// values (e.g. "problem-list-item", "vital-signs") are needed for filtering.
func firstCategoryCode(cats []CodeableConcept) string {
if len(cats) == 0 {
return ""
}
c := cats[0]
if len(c.Coding) > 0 && c.Coding[0].Code != "" {
return c.Coding[0].Code
}
return c.Text
}
// ExtractObservation maps a FHIR Observation to a models.Observation ready
// for upsert. patientFHIRID and ehrURL are injected by the caller because
// they are session-level context, not encoded inside the FHIR resource.
@@ -830,6 +1025,8 @@ func ExtractObservation(o *Observation, patientFHIRID, ehrURL string) *models.Ob
}
// ExtractCondition maps a FHIR Condition to a models.Condition ready for upsert.
// Category is stored as a machine-readable code (e.g. "problem-list-item",
// "encounter-diagnosis") for reliable client-side filtering.
func ExtractCondition(c *Condition, patientFHIRID, ehrURL string) *models.Condition {
coding := firstCoding(c.Code)
clinicalStatus := firstCoding(c.ClinicalStatus)
@@ -840,7 +1037,7 @@ func ExtractCondition(c *Condition, patientFHIRID, ehrURL string) *models.Condit
PatientFHIRID: patientFHIRID,
ClinicalStatus: clinicalStatus.Code,
VerificationStatus: verificationStatus.Code,
Category: firstCategoryText(c.Category),
Category: firstCategoryCode(c.Category),
CodeText: c.Code.Text,
CodeSystem: coding.System,
CodeCode: coding.Code,
@@ -913,6 +1110,26 @@ func ExtractAllergyIntolerance(a *AllergyIntolerance, patientFHIRID, ehrURL stri
if len(a.Category) > 0 {
category = a.Category[0]
}
// Extract first reaction severity and manifestation for safety display.
var reactionSeverity, reactionManifestation string
if len(a.Reaction) > 0 {
rxn := a.Reaction[0]
reactionSeverity = rxn.Severity
if len(rxn.Manifestation) > 0 {
m := rxn.Manifestation[0]
if m.Text != "" {
reactionManifestation = m.Text
} else if len(m.Coding) > 0 {
if m.Coding[0].Display != "" {
reactionManifestation = m.Coding[0].Display
} else {
reactionManifestation = m.Coding[0].Code
}
}
}
}
return &models.AllergyIntolerance{
FHIRID: a.ID,
EHRURL: strings.TrimRight(ehrURL, "/"),
@@ -926,9 +1143,147 @@ func ExtractAllergyIntolerance(a *AllergyIntolerance, patientFHIRID, ehrURL stri
CodeSystem: codeCoding.System,
CodeCode: codeCoding.Code,
RecordedDate: a.RecordedDate,
ReactionSeverity: reactionSeverity,
ReactionManifestation: reactionManifestation,
}
}
// ExtractImmunization maps a FHIR Immunization to a models.Immunization ready for upsert.
func ExtractImmunization(imm *Immunization, patientFHIRID, ehrURL string) *models.Immunization {
coding := firstCoding(imm.VaccineCode)
return &models.Immunization{
FHIRID: imm.ID,
EHRURL: strings.TrimRight(ehrURL, "/"),
PatientFHIRID: patientFHIRID,
Status: imm.Status,
VaccineText: imm.VaccineCode.Text,
VaccineSystem: coding.System,
VaccineCode: coding.Code,
OccurrenceDate: imm.OccurrenceDateTime,
PrimarySource: imm.PrimarySource,
LotNumber: imm.LotNumber,
}
}
// ExtractProcedure maps a FHIR Procedure to a models.Procedure ready for upsert.
func ExtractProcedure(p *Procedure, patientFHIRID, ehrURL string) *models.Procedure {
coding := firstCoding(p.Code)
// Prefer performedDateTime; fall back to period start.
performedDate := p.PerformedDateTime
if performedDate == "" && p.PerformedPeriod != nil {
performedDate = p.PerformedPeriod.Start
}
// First reason code text/display.
var reasonText string
if len(p.ReasonCode) > 0 {
rc := p.ReasonCode[0]
if rc.Text != "" {
reasonText = rc.Text
} else if len(rc.Coding) > 0 {
reasonText = rc.Coding[0].Display
}
}
// Outcome text.
var outcome string
if p.Outcome.Text != "" {
outcome = p.Outcome.Text
} else if len(p.Outcome.Coding) > 0 {
outcome = p.Outcome.Coding[0].Display
}
return &models.Procedure{
FHIRID: p.ID,
EHRURL: strings.TrimRight(ehrURL, "/"),
PatientFHIRID: patientFHIRID,
Status: p.Status,
CodeText: p.Code.Text,
CodeSystem: coding.System,
CodeCode: coding.Code,
PerformedDate: performedDate,
ReasonText: reasonText,
Outcome: outcome,
}
}
// ExtractEncounter maps a FHIR Encounter to a models.Encounter ready for upsert.
func ExtractEncounter(e *Encounter, patientFHIRID, ehrURL string) *models.Encounter {
// Type text from first type entry.
var typeText string
if len(e.Type) > 0 {
t := e.Type[0]
if t.Text != "" {
typeText = t.Text
} else if len(t.Coding) > 0 {
typeText = t.Coding[0].Display
}
}
var periodStart, periodEnd string
if e.Period != nil {
periodStart = e.Period.Start
periodEnd = e.Period.End
}
// First reason code text/display.
var reasonText string
if len(e.ReasonCode) > 0 {
rc := e.ReasonCode[0]
if rc.Text != "" {
reasonText = rc.Text
} else if len(rc.Coding) > 0 {
reasonText = rc.Coding[0].Display
}
}
return &models.Encounter{
FHIRID: e.ID,
EHRURL: strings.TrimRight(ehrURL, "/"),
PatientFHIRID: patientFHIRID,
Status: e.Status,
Class: e.Class.Code,
TypeText: typeText,
PeriodStart: periodStart,
PeriodEnd: periodEnd,
ReasonText: reasonText,
}
}
// ExtractUSCoreRaceText returns the US Core race text extension value from a Patient,
// or empty string if not present.
func ExtractUSCoreRaceText(p *Patient) string {
const raceURL = "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race"
for _, ext := range p.Extension {
if ext.URL == raceURL {
for _, nested := range ext.Extension {
if nested.URL == "text" {
return nested.ValueString
}
}
}
}
return ""
}
// ExtractUSCoreEthnicityText returns the US Core ethnicity text extension value from a Patient,
// or empty string if not present.
func ExtractUSCoreEthnicityText(p *Patient) string {
const ethnicityURL = "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity"
for _, ext := range p.Extension {
if ext.URL == ethnicityURL {
for _, nested := range ext.Extension {
if nested.URL == "text" {
return nested.ValueString
}
}
}
}
return ""
}
// ParseFHIRUserFromIDToken attempts to extract a FHIR resource reference
// (e.g. "Practitioner/123" or "Patient/abc") from the id_token's fhirUser claim.
// Returns an empty string if the claim is missing or invalid.

View File

@@ -119,6 +119,235 @@ func TestExtractUserFromPractitioner_FullRecord(t *testing.T) {
assertEqual(t, "Email", "dr.chen@hospital.org", u.Email)
}
// ---------------------------------------------------------------------------
// ExtractAllergyIntolerance tests
// ---------------------------------------------------------------------------
func TestExtractAllergyIntolerance_WithReaction(t *testing.T) {
a := &fhir.AllergyIntolerance{
ID: "allergy-001",
Code: fhir.CodeableConcept{
Text: "Penicillin",
Coding: []fhir.Coding{{System: "http://rxnorm", Code: "7980"}},
},
ClinicalStatus: fhir.CodeableConcept{Coding: []fhir.Coding{{Code: "active"}}},
VerificationStatus: fhir.CodeableConcept{Coding: []fhir.Coding{{Code: "confirmed"}}},
Criticality: "high",
RecordedDate: "2018-05-01",
Reaction: []fhir.AllergyReaction{
{
Severity: "severe",
Manifestation: []fhir.CodeableConcept{
{Text: "Anaphylaxis"},
},
},
},
}
m := fhir.ExtractAllergyIntolerance(a, "patient-001", "https://ehr.example.com/fhir")
assertEqual(t, "ReactionSeverity", "severe", m.ReactionSeverity)
assertEqual(t, "ReactionManifestation", "Anaphylaxis", m.ReactionManifestation)
assertEqual(t, "ClinicalStatus", "active", m.ClinicalStatus)
assertEqual(t, "Criticality", "high", m.Criticality)
}
func TestExtractAllergyIntolerance_NoReaction(t *testing.T) {
a := &fhir.AllergyIntolerance{
ID: "allergy-002",
Code: fhir.CodeableConcept{Text: "Latex"},
}
m := fhir.ExtractAllergyIntolerance(a, "patient-001", "https://ehr.example.com/fhir")
if m.ReactionSeverity != "" {
t.Errorf("ReactionSeverity: expected empty, got %q", m.ReactionSeverity)
}
if m.ReactionManifestation != "" {
t.Errorf("ReactionManifestation: expected empty, got %q", m.ReactionManifestation)
}
}
// ---------------------------------------------------------------------------
// ExtractImmunization tests
// ---------------------------------------------------------------------------
func TestExtractImmunization_Full(t *testing.T) {
imm := &fhir.Immunization{
ID: "imm-001",
Status: "completed",
VaccineCode: fhir.CodeableConcept{
Text: "Influenza, seasonal",
Coding: []fhir.Coding{{System: "http://hl7.org/fhir/sid/cvx", Code: "141"}},
},
OccurrenceDateTime: "2023-10-01",
PrimarySource: true,
LotNumber: "LOT123",
}
m := fhir.ExtractImmunization(imm, "patient-001", "https://ehr.example.com/fhir")
assertEqual(t, "FHIRID", "imm-001", m.FHIRID)
assertEqual(t, "Status", "completed", m.Status)
assertEqual(t, "VaccineText", "Influenza, seasonal", m.VaccineText)
assertEqual(t, "VaccineCode", "141", m.VaccineCode)
assertEqual(t, "OccurrenceDate", "2023-10-01", m.OccurrenceDate)
assertEqual(t, "LotNumber", "LOT123", m.LotNumber)
if !m.PrimarySource {
t.Error("PrimarySource should be true")
}
}
// ---------------------------------------------------------------------------
// ExtractProcedure tests
// ---------------------------------------------------------------------------
func TestExtractProcedure_WithDatetime(t *testing.T) {
p := &fhir.Procedure{
ID: "proc-001",
Status: "completed",
Code: fhir.CodeableConcept{
Text: "Appendectomy",
Coding: []fhir.Coding{{System: "http://snomed.info/sct", Code: "80146002"}},
},
PerformedDateTime: "2019-06-15",
ReasonCode: []fhir.CodeableConcept{
{Text: "Acute appendicitis"},
},
Outcome: fhir.CodeableConcept{Text: "Successful"},
}
m := fhir.ExtractProcedure(p, "patient-001", "https://ehr.example.com/fhir")
assertEqual(t, "FHIRID", "proc-001", m.FHIRID)
assertEqual(t, "Status", "completed", m.Status)
assertEqual(t, "CodeText", "Appendectomy", m.CodeText)
assertEqual(t, "PerformedDate", "2019-06-15", m.PerformedDate)
assertEqual(t, "ReasonText", "Acute appendicitis", m.ReasonText)
assertEqual(t, "Outcome", "Successful", m.Outcome)
}
func TestExtractProcedure_FallsBackToPeriodStart(t *testing.T) {
p := &fhir.Procedure{
ID: "proc-002",
Status: "completed",
Code: fhir.CodeableConcept{Text: "Colonoscopy"},
PerformedPeriod: &fhir.Period{
Start: "2022-03-01",
End: "2022-03-01",
},
}
m := fhir.ExtractProcedure(p, "patient-001", "https://ehr.example.com/fhir")
assertEqual(t, "PerformedDate (from period)", "2022-03-01", m.PerformedDate)
}
// ---------------------------------------------------------------------------
// ExtractEncounter tests
// ---------------------------------------------------------------------------
func TestExtractEncounter_Full(t *testing.T) {
e := &fhir.Encounter{
ID: "enc-001",
Status: "finished",
Class: fhir.EncounterClass{Code: "AMB"},
Type: []fhir.CodeableConcept{
{Text: "Office visit"},
},
Period: &fhir.Period{Start: "2024-03-10", End: "2024-03-10"},
ReasonCode: []fhir.CodeableConcept{
{Text: "Annual physical"},
},
}
m := fhir.ExtractEncounter(e, "patient-001", "https://ehr.example.com/fhir")
assertEqual(t, "FHIRID", "enc-001", m.FHIRID)
assertEqual(t, "Status", "finished", m.Status)
assertEqual(t, "Class", "AMB", m.Class)
assertEqual(t, "TypeText", "Office visit", m.TypeText)
assertEqual(t, "PeriodStart", "2024-03-10", m.PeriodStart)
assertEqual(t, "PeriodEnd", "2024-03-10", m.PeriodEnd)
assertEqual(t, "ReasonText", "Annual physical", m.ReasonText)
}
// ---------------------------------------------------------------------------
// ExtractCondition category code tests
// ---------------------------------------------------------------------------
func TestExtractCondition_UsesCategoryCode(t *testing.T) {
c := &fhir.Condition{
ID: "cond-001",
Code: fhir.CodeableConcept{
Text: "Hypertension",
Coding: []fhir.Coding{{System: "http://snomed.info/sct", Code: "38341003"}},
},
ClinicalStatus: fhir.CodeableConcept{Coding: []fhir.Coding{{Code: "active"}}},
VerificationStatus: fhir.CodeableConcept{Coding: []fhir.Coding{{Code: "confirmed"}}},
Category: []fhir.CodeableConcept{
{
Coding: []fhir.Coding{{System: "http://terminology.hl7.org/CodeSystem/condition-category", Code: "problem-list-item", Display: "Problem List Item"}},
Text: "Problem List Item",
},
},
}
m := fhir.ExtractCondition(c, "patient-001", "https://ehr.example.com/fhir")
// Must store the code ("problem-list-item"), not the display text ("Problem List Item").
assertEqual(t, "Category code", "problem-list-item", m.Category)
}
// ---------------------------------------------------------------------------
// ExtractUSCoreRace/Ethnicity tests
// ---------------------------------------------------------------------------
func TestExtractUSCoreRaceText(t *testing.T) {
p := &fhir.Patient{
ID: "patient-race",
Extension: []fhir.Extension{
{
URL: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race",
Extension: []fhir.Extension{
{URL: "text", ValueString: "White"},
{URL: "ombCategory", ValueCoding: &fhir.Coding{Code: "2106-3", Display: "White"}},
},
},
},
}
got := fhir.ExtractUSCoreRaceText(p)
if got != "White" {
t.Errorf("ExtractUSCoreRaceText: got %q, want White", got)
}
}
func TestExtractUSCoreEthnicityText(t *testing.T) {
p := &fhir.Patient{
ID: "patient-eth",
Extension: []fhir.Extension{
{
URL: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity",
Extension: []fhir.Extension{
{URL: "text", ValueString: "Not Hispanic or Latino"},
},
},
},
}
got := fhir.ExtractUSCoreEthnicityText(p)
if got != "Not Hispanic or Latino" {
t.Errorf("ExtractUSCoreEthnicityText: got %q, want Not Hispanic or Latino", got)
}
}
func TestExtractUSCoreRaceText_Missing(t *testing.T) {
p := &fhir.Patient{ID: "patient-no-race"}
if got := fhir.ExtractUSCoreRaceText(p); got != "" {
t.Errorf("expected empty string for patient without race extension, got %q", got)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

View File

@@ -3,12 +3,60 @@ package handlers
import (
"log"
"net/http"
"time"
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
"github.com/AmanTahiliani/FHIR-Sandbox/app/middleware"
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
)
// ClinicalSummary holds pre-computed summary values for the Summary tab.
type ClinicalSummary struct {
LatestVitals []models.Observation
ActiveCondCount int
ActiveMedCount int
AbnormalLabCount int
AbnormalLabsRecent []models.Observation
}
// buildClinicalSummary computes the clinical summary from existing in-memory data.
// No additional DB or FHIR calls are made.
func buildClinicalSummary(obs []models.Observation, conds []models.Condition, meds []models.MedicationRequest) ClinicalSummary {
// Latest value per vital code.
latestVitals := latestObsPerCode(filterObsByCategory(obs, "vital-signs"))
activeConds := 0
for _, c := range conds {
if c.ClinicalStatus == "active" {
activeConds++
}
}
activeMeds := 0
for _, m := range meds {
if m.Status == "active" {
activeMeds++
}
}
// Abnormal labs within the last 30 days.
cutoff := time.Now().AddDate(0, 0, -30).Format("2006-01-02")
var abnormalLabs []models.Observation
for _, o := range obs {
if isAbnormalInterp(o.Interpretation) && o.EffectiveDate >= cutoff {
abnormalLabs = append(abnormalLabs, o)
}
}
return ClinicalSummary{
LatestVitals: latestVitals,
ActiveCondCount: activeConds,
ActiveMedCount: activeMeds,
AbnormalLabCount: len(abnormalLabs),
AbnormalLabsRecent: abnormalLabs,
}
}
// HandleDashboard renders the stable patient dashboard.
// On first load (no prior sync), automatically triggers a sync to populate data.
// All other clinical data is read from the local database; no live FHIR calls are
@@ -63,7 +111,6 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
observations, err := h.store.ListObservations(patientID, ehrURL)
if err != nil {
log.Printf("handlers: dashboard ListObservations Patient/%s: %v", patientID, err)
// Non-fatal; render with empty slice.
}
conditions, err := h.store.ListConditions(patientID, ehrURL)
@@ -86,6 +133,22 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
log.Printf("handlers: dashboard ListAllergyIntolerances Patient/%s: %v", patientID, err)
}
immunizations, err := h.store.ListImmunizations(patientID, ehrURL)
if err != nil {
log.Printf("handlers: dashboard ListImmunizations Patient/%s: %v", patientID, err)
}
procedures, err := h.store.ListProcedures(patientID, ehrURL)
if err != nil {
log.Printf("handlers: dashboard ListProcedures Patient/%s: %v", patientID, err)
}
encounters, err := h.store.ListEncounters(patientID, ehrURL)
if err != nil {
log.Printf("handlers: dashboard ListEncounters Patient/%s: %v", patientID, err)
}
summary := buildClinicalSummary(observations, conditions, medications)
synced := r.URL.Query().Get("synced") == "true"
h.render(w, "dashboard.html", dashboardData{
@@ -97,6 +160,10 @@ func (h *Handler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
DocumentReferences: docRefs,
Medications: medications,
Allergies: allergies,
Immunizations: immunizations,
Procedures: procedures,
Encounters: encounters,
Summary: summary,
LatestSync: latestSync,
Session: sess,
Synced: synced,
@@ -113,6 +180,10 @@ type dashboardData struct {
DocumentReferences []models.DocumentReference
Medications []models.MedicationRequest
Allergies []models.AllergyIntolerance
Immunizations []models.Immunization
Procedures []models.Procedure
Encounters []models.Encounter
Summary ClinicalSummary
LatestSync *models.PatientSync
Session *models.Session
Synced bool

View File

@@ -21,14 +21,18 @@ package handlers
import (
"crypto/rand"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"sort"
"strings"
"time"
"github.com/AmanTahiliani/FHIR-Sandbox/app/config"
"github.com/AmanTahiliani/FHIR-Sandbox/app/db"
"github.com/AmanTahiliani/FHIR-Sandbox/app/fhir"
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
)
@@ -111,20 +115,74 @@ func generateState() (string, error) {
return hex.EncodeToString(b), nil
}
// templateFuncs returns the custom template function map.
// ---------------------------------------------------------------------------
// Private helpers used by both template funcs and buildClinicalSummary.
// ---------------------------------------------------------------------------
// filterObsByCategory returns observations whose category matches cat
// using a normalised (lowercase, spaces→hyphens) comparison.
func filterObsByCategory(obs []models.Observation, cat string) []models.Observation {
want := strings.ToLower(strings.ReplaceAll(cat, " ", "-"))
var out []models.Observation
for _, o := range obs {
got := strings.ToLower(strings.ReplaceAll(o.Category, " ", "-"))
if got == want {
out = append(out, o)
}
}
return out
}
// latestObsPerCode returns the most-recent observation per LOINC code (or code
// text when code is absent), sorted by code text for stable display.
func latestObsPerCode(obs []models.Observation) []models.Observation {
latest := make(map[string]models.Observation)
for _, o := range obs {
key := o.CodeCode
if key == "" {
key = o.CodeText
}
if existing, ok := latest[key]; !ok || o.EffectiveDate > existing.EffectiveDate {
latest[key] = o
}
}
out := make([]models.Observation, 0, len(latest))
for _, o := range latest {
out = append(out, o)
}
sort.Slice(out, func(i, j int) bool { return out[i].CodeText < out[j].CodeText })
return out
}
// isAbnormalInterp returns true for interpretation codes that indicate an
// out-of-range or critical result.
func isAbnormalInterp(interp string) bool {
switch strings.ToUpper(strings.TrimSpace(interp)) {
case "H", "HH", "L", "LL", "A", "AA", "HIGH", "LOW", "ABNORMAL", "CRITICAL":
return true
}
return false
}
// ---------------------------------------------------------------------------
// TemplateFuncs returns the custom template function map.
// Defined here so it is available to both main.go (for wiring) and
// handler tests.
// ---------------------------------------------------------------------------
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"formatDate": func(s string) string {
if s == "" {
return "—"
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return s
// Try full datetime first (FHIR dateTime), then plain date.
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05Z0700", "2006-01-02"} {
t, err := time.Parse(layout, s)
if err == nil {
return t.Format("Jan 2, 2006")
}
return t.Format("January 2, 2006")
}
return s
},
"formatDateTime": func(t time.Time) string {
if t.IsZero() {
@@ -153,6 +211,7 @@ func TemplateFuncs() template.FuncMap {
}
return s
},
// groupByCategory is kept for backward compatibility.
"groupByCategory": func(obs []models.Observation) map[string][]models.Observation {
m := make(map[string][]models.Observation)
for _, o := range obs {
@@ -172,5 +231,129 @@ func TemplateFuncs() template.FuncMap {
}
return false
},
// --- T1.2 Vitals/Labs ---
"filterObsByCategory": func(obs []models.Observation, cat string) []models.Observation {
return filterObsByCategory(obs, cat)
},
"latestObPerCode": func(obs []models.Observation) []models.Observation {
return latestObsPerCode(obs)
},
"isAbnormal": func(interp string) bool {
return isAbnormalInterp(interp)
},
// --- T1.3 Medication history ---
"filterMedsByStatus": func(meds []models.MedicationRequest, status string) []models.MedicationRequest {
if status == "" || status == "all" {
return meds
}
var out []models.MedicationRequest
for _, m := range meds {
if strings.EqualFold(m.Status, status) {
out = append(out, m)
}
}
return out
},
// --- T1.1 Demographics ---
"calculateAge": func(dob string) string {
if dob == "" {
return ""
}
t, err := time.Parse("2006-01-02", dob)
if err != nil {
return ""
}
now := time.Now()
years := now.Year() - t.Year()
if now.Month() < t.Month() || (now.Month() == t.Month() && now.Day() < t.Day()) {
years--
}
return fmt.Sprintf("%d", years)
},
"primaryPhone": func(p *fhir.Patient) string {
if p == nil {
return ""
}
for _, tc := range p.Telecom {
if tc.System == "phone" && tc.Value != "" {
return tc.Value
}
}
return ""
},
"primaryAddress": func(p *fhir.Patient) string {
if p == nil || len(p.Address) == 0 {
return ""
}
addr := p.Address[0]
var parts []string
if len(addr.Line) > 0 {
parts = append(parts, addr.Line[0])
}
if addr.City != "" {
parts = append(parts, addr.City)
}
if addr.State != "" {
parts = append(parts, addr.State)
}
if addr.PostalCode != "" {
parts = append(parts, addr.PostalCode)
}
return strings.Join(parts, ", ")
},
"usRace": func(p *fhir.Patient) string {
if p == nil {
return ""
}
return fhir.ExtractUSCoreRaceText(p)
},
"usEthnicity": func(p *fhir.Patient) string {
if p == nil {
return ""
}
return fhir.ExtractUSCoreEthnicityText(p)
},
// --- T2.3 Encounters ---
"encounterClassBadge": func(class string) string {
switch strings.ToUpper(class) {
case "AMB":
return "badge-info"
case "EMER":
return "badge-danger"
case "IMP", "INPATIENT":
return "badge-warning"
default:
return "badge-neutral"
}
},
"encounterClassLabel": func(class string) string {
switch strings.ToUpper(class) {
case "AMB":
return "Ambulatory"
case "EMER":
return "Emergency"
case "IMP":
return "Inpatient"
case "VR":
return "Virtual"
default:
if class == "" {
return "Visit"
}
return class
}
},
"split": func(s, sep string) []string {
if s == "" {
return nil
}
return strings.Split(s, sep)
},
"min": func(a, b int) int {
if a < b {
return a
}
return b
},
}
}

View File

@@ -143,6 +143,60 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
allergyCount++
}
// -----------------------------------------------------------------
// Fetch Immunizations
// -----------------------------------------------------------------
rawImmunizations, err := client.GetImmunizations(patientID, sinceTime)
if err != nil {
log.Printf("handlers: sync GetImmunizations for Patient/%s: %v", patientID, err)
}
immunizationCount := 0
for i := range rawImmunizations {
m := fhir.ExtractImmunization(&rawImmunizations[i], patientID, ehrURL)
if _, err := h.store.UpsertImmunization(m); err != nil {
log.Printf("handlers: sync UpsertImmunization fhir_id=%s: %v", m.FHIRID, err)
continue
}
immunizationCount++
}
// -----------------------------------------------------------------
// Fetch Procedures
// -----------------------------------------------------------------
rawProcedures, err := client.GetProcedures(patientID, sinceTime)
if err != nil {
log.Printf("handlers: sync GetProcedures for Patient/%s: %v", patientID, err)
}
procedureCount := 0
for i := range rawProcedures {
m := fhir.ExtractProcedure(&rawProcedures[i], patientID, ehrURL)
if _, err := h.store.UpsertProcedure(m); err != nil {
log.Printf("handlers: sync UpsertProcedure fhir_id=%s: %v", m.FHIRID, err)
continue
}
procedureCount++
}
// -----------------------------------------------------------------
// Fetch Encounters
// -----------------------------------------------------------------
rawEncounters, err := client.GetEncounters(patientID, sinceTime)
if err != nil {
log.Printf("handlers: sync GetEncounters for Patient/%s: %v", patientID, err)
}
encounterCount := 0
for i := range rawEncounters {
m := fhir.ExtractEncounter(&rawEncounters[i], patientID, ehrURL)
if _, err := h.store.UpsertEncounter(m); err != nil {
log.Printf("handlers: sync UpsertEncounter fhir_id=%s: %v", m.FHIRID, err)
continue
}
encounterCount++
}
// -----------------------------------------------------------------
// Record the sync event
// -----------------------------------------------------------------
@@ -150,8 +204,8 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
log.Printf("handlers: sync RecordSync Patient/%s: %v", patientID, err)
}
log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d med=%d allergy=%d",
patientID, obsCount, condCount, docCount, medCount, allergyCount)
log.Printf("handlers: sync complete for Patient/%s — obs=%d cond=%d docs=%d med=%d allergy=%d imm=%d proc=%d enc=%d",
patientID, obsCount, condCount, docCount, medCount, allergyCount, immunizationCount, procedureCount, encounterCount)
dashboardURL := "/dashboard?synced=true"
if overrideID := r.URL.Query().Get("patient_id"); overrideID != "" {

View File

@@ -48,6 +48,7 @@ type User struct {
FirstName string `json:"first_name" db:"first_name"`
MiddleName string `json:"middle_name" db:"middle_name"`
LastName string `json:"last_name" db:"last_name"`
MRN string `json:"mrn" db:"mrn"` // Medical Record Number
DOB string `json:"dob" db:"dob"` // ISO 8601 date, e.g. "1990-04-22"
Gender string `json:"gender" db:"gender"` // FHIR value set: male|female|other|unknown
@@ -195,6 +196,55 @@ type AllergyIntolerance struct {
CodeSystem string `json:"code_system" db:"code_system"`
CodeCode string `json:"code_code" db:"code_code"`
RecordedDate string `json:"recorded_date" db:"recorded_date"`
ReactionSeverity string `json:"reaction_severity" db:"reaction_severity"`
ReactionManifestation string `json:"reaction_manifestation" db:"reaction_manifestation"`
SyncedAt time.Time `json:"synced_at" db:"synced_at"`
}
// Immunization is the persisted representation of a FHIR R4 Immunization.
type Immunization struct {
ID string `json:"id" db:"id"`
FHIRID string `json:"fhir_id" db:"fhir_id"`
EHRURL string `json:"ehr_url" db:"ehr_url"`
PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"`
Status string `json:"status" db:"status"`
VaccineText string `json:"vaccine_text" db:"vaccine_text"`
VaccineSystem string `json:"vaccine_system" db:"vaccine_system"`
VaccineCode string `json:"vaccine_code" db:"vaccine_code"`
OccurrenceDate string `json:"occurrence_date" db:"occurrence_date"`
PrimarySource bool `json:"primary_source" db:"primary_source"`
LotNumber string `json:"lot_number" db:"lot_number"`
SyncedAt time.Time `json:"synced_at" db:"synced_at"`
}
// Procedure is the persisted representation of a FHIR R4 Procedure.
type Procedure struct {
ID string `json:"id" db:"id"`
FHIRID string `json:"fhir_id" db:"fhir_id"`
EHRURL string `json:"ehr_url" db:"ehr_url"`
PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"`
Status string `json:"status" db:"status"`
CodeText string `json:"code_text" db:"code_text"`
CodeSystem string `json:"code_system" db:"code_system"`
CodeCode string `json:"code_code" db:"code_code"`
PerformedDate string `json:"performed_date" db:"performed_date"`
ReasonText string `json:"reason_text" db:"reason_text"`
Outcome string `json:"outcome" db:"outcome"`
SyncedAt time.Time `json:"synced_at" db:"synced_at"`
}
// Encounter is the persisted representation of a FHIR R4 Encounter.
type Encounter struct {
ID string `json:"id" db:"id"`
FHIRID string `json:"fhir_id" db:"fhir_id"`
EHRURL string `json:"ehr_url" db:"ehr_url"`
PatientFHIRID string `json:"patient_fhir_id" db:"patient_fhir_id"`
Status string `json:"status" db:"status"`
Class string `json:"class" db:"class"`
TypeText string `json:"type_text" db:"type_text"`
PeriodStart string `json:"period_start" db:"period_start"`
PeriodEnd string `json:"period_end" db:"period_end"`
ReasonText string `json:"reason_text" db:"reason_text"`
SyncedAt time.Time `json:"synced_at" db:"synced_at"`
}

View File

@@ -1,453 +1,324 @@
:root {
/* Brand Colors */
--primary-color: #2563EB;
--primary-hover: #1D4ED8;
--secondary-color: #0D9488;
--secondary-hover: #0F766E;
/* Ultra-refined Palette (Anthropic / Apple inspired) */
--bg-app: #FCFCFC; /* Very subtle warm off-white for main background */
--bg-panel: #FFFFFF; /* Pure white for cards */
--bg-hover: #F4F4F5; /* Zinc 100 for subtle hovers */
/* 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;
--border-soft: #F4F4F5; /* Zinc 100 */
--border-hard: #E4E4E7; /* Zinc 200 */
--border-focus: #A1A1AA; /* Zinc 400 */
/* Neutral Colors */
--background-color: #F3F4F6;
--surface-color: #FFFFFF;
--text-primary: #111827;
--text-secondary: #4B5563;
--text-muted: #9CA3AF;
--border-color: #E5E7EB;
--text-main: #18181B; /* Zinc 900 - Almost black */
--text-muted: #71717A; /* Zinc 500 - Refined gray */
--text-faint: #A1A1AA; /* Zinc 400 - For placeholders/meta */
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--brand-dark: #09090B; /* Anthropic style primary action */
--brand-light: #F4F4F5;
/* 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;
/* Semantic Colors - Muted & Sophisticated */
--accent-blue: #0284C7; /* Sky 600 */
--accent-blue-bg: #F0F9FF; /* Sky 50 */
--danger: #E11D48; /* Rose 600 */
--danger-bg: #FFF1F2; /* Rose 50 */
--warning: #D97706; /* Amber 600 */
--warning-bg: #FFFBEB; /* Amber 50 */
--success: #059669; /* Emerald 600 */
--success-bg: #ECFDF5; /* Emerald 50 */
/* 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;
/* Shapes & Metrics */
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 20px;
--radius-full: 9999px;
/* Shadows - Apple-style diffused */
--shadow-subtle: 0 2px 8px -2px rgba(0, 0, 0, 0.04), 0 1px 2px -1px rgba(0, 0, 0, 0.02);
--shadow-float: 0 12px 32px -4px rgba(0, 0, 0, 0.08), 0 4px 12px -2px rgba(0, 0, 0, 0.04);
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace;
}
/* Reset & Typography */
* { 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;
background-color: var(--bg-app);
color: var(--text-main);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
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;
}
h1, h2, h3, h4 { font-weight: 600; letter-spacing: -0.02em; color: var(--text-main); line-height: 1.2; }
a { color: var(--text-main); text-decoration: none; transition: color 0.2s; }
a:hover { color: var(--text-muted); }
/* Layout */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--spacing-md);
width: 100%;
}
main {
flex: 1;
padding: var(--spacing-xl) 0;
}
.container { max-width: 1280px; margin: 0 auto; padding: 0 2rem; width: 100%; }
main { flex: 1; padding: 3rem 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;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-hard);
position: sticky; top: 0; z-index: 50;
padding: 1rem 0;
}
.navbar-content { display: flex; justify-content: space-between; align-items: center; }
.brand { font-size: 1.125rem; font-weight: 700; letter-spacing: -0.03em; color: var(--text-main); display: flex; align-items: center; gap: 0.5rem; }
.brand .brand-light { font-weight: 400; color: var(--text-muted); }
.nav-links { display: flex; align-items: center; gap: 1.5rem; font-size: 0.875rem; font-weight: 500; }
.nav-link { color: var(--text-muted); }
.nav-link:hover { color: var(--text-main); }
/* 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);
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
padding: 0.5rem 1rem; border-radius: var(--radius-sm); font-size: 0.875rem; font-weight: 500;
cursor: pointer; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); border: 1px solid transparent;
}
.btn-primary { background: var(--brand-dark); color: white; box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
.btn-primary:hover { background: #27272A; transform: translateY(-1px); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); }
.btn-secondary, .btn-outline { background: white; color: var(--text-main); border-color: var(--border-hard); box-shadow: 0 1px 2px rgba(0,0,0,0.02); }
.btn-secondary:hover, .btn-outline:hover { background: var(--bg-hover); }
.btn-danger { background: var(--danger-bg); color: var(--danger); }
.btn-danger:hover { background: #FFE4E6; }
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; }
.btn-icon { padding: 0.375rem; border-radius: var(--radius-sm); }
.btn-primary {
background-color: var(--primary-color);
color: white;
/* Panels / Cards */
.panel {
background: var(--bg-panel); border-radius: var(--radius-md);
border: 1px solid var(--border-soft); box-shadow: var(--shadow-subtle);
}
.panel-padded { padding: 2rem; }
.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;
/* Typography Utilities */
.text-main { color: var(--text-main); }
.text-muted { color: var(--text-muted); }
.text-faint { color: var(--text-faint); }
.text-danger { color: var(--danger); }
.text-success { color: var(--success); }
.text-blue { color: var(--accent-blue); }
.text-xs { font-size: 0.75rem; }
.text-sm { font-size: 0.875rem; }
.text-lg { font-size: 1.125rem; }
.text-xl { font-size: 1.25rem; }
.text-2xl { font-size: 1.5rem; letter-spacing: -0.03em; }
.text-3xl { font-size: 1.875rem; letter-spacing: -0.04em; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-mono { font-family: var(--font-mono); }
.uppercase { text-transform: uppercase; letter-spacing: 0.05em; }
/* Layout Utilities */
.flex { display: flex; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.flex-col { flex-direction: column; }
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.gap-8 { gap: 2rem; }
.w-full { width: 100%; }
/* Avatars */
.avatar {
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-full); font-weight: 600;
background: var(--bg-hover); color: var(--text-main); border: 1px solid var(--border-hard);
}
.avatar-sm { width: 32px; height: 32px; font-size: 0.875rem; }
.avatar-md { width: 48px; height: 48px; font-size: 1.125rem; }
.avatar-lg { width: 80px; height: 80px; font-size: 2rem; background: var(--bg-panel); box-shadow: var(--shadow-subtle); border-color: var(--border-soft); }
/* 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;
display: inline-flex; align-items: center; padding: 0.125rem 0.5rem;
border-radius: var(--radius-full); font-size: 0.75rem; font-weight: 500;
}
.badge-neutral { background: var(--bg-hover); color: var(--text-muted); border: 1px solid var(--border-hard); }
.badge-blue { background: var(--accent-blue-bg); color: var(--accent-blue); border: 1px solid #BAE6FD; }
.badge-danger { background: var(--danger-bg); color: var(--danger); border: 1px solid #FECDD3; }
.badge-warning { background: var(--warning-bg); color: var(--warning); border: 1px solid #FDE68A; }
.badge-success { background: var(--success-bg); color: var(--success); border: 1px solid #A7F3D0; }
.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;
/* Tables - Elegant & Airy */
.table-wrapper { width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: separate; border-spacing: 0; }
th {
font-weight: 500; color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.05em; border-bottom: 1px solid var(--border-hard);
padding: 1rem 0.75rem; text-align: left; background: var(--bg-panel);
}
td {
padding: 1.25rem 0.75rem; border-bottom: 1px solid var(--border-soft);
color: var(--text-main); font-size: 0.875rem; vertical-align: top;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background-color: #FAFAFB; }
.td-sub { color: var(--text-muted); font-size: 0.8125rem; margin-top: 0.25rem; }
/* Tabs - Minimal Underline */
.tabs { display: flex; gap: 2rem; border-bottom: 1px solid var(--border-hard); overflow-x: auto; scrollbar-width: none; }
.tabs::-webkit-scrollbar { display: none; }
.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;
background: none; border: none; padding: 0 0 1rem 0; margin-bottom: -1px;
color: var(--text-muted); font-size: 0.875rem; font-weight: 500;
border-bottom: 2px solid transparent; cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.tab-btn:hover { color: var(--text-main); }
.tab-btn.active { color: var(--text-main); border-bottom-color: var(--text-main); }
.tab-content { display: none; animation: fadeIn 0.3s ease; }
.tab-content.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
/* Inputs / Search */
.input-base {
width: 100%; padding: 0.625rem 1rem; border-radius: var(--radius-sm);
border: 1px solid var(--border-hard); background: var(--bg-panel);
color: var(--text-main); font-size: 0.875rem; outline: none; transition: border-color 0.2s;
}
.input-base:focus { border-color: var(--border-focus); box-shadow: 0 0 0 3px rgba(161, 161, 170, 0.1); }
.search-wrapper { position: relative; display: flex; align-items: center; max-width: 480px; }
.search-icon { position: absolute; left: 1rem; color: var(--text-faint); pointer-events: none; }
.search-input { padding-left: 2.75rem; border-radius: var(--radius-full); background: var(--bg-hover); border-color: transparent; }
.search-input:focus { background: var(--bg-panel); border-color: var(--border-hard); }
/* Key-Value Lists */
.kv-list { display: flex; flex-direction: column; gap: 0.75rem; }
.kv-row { display: flex; flex-direction: column; gap: 0.125rem; }
.kv-label { font-size: 0.75rem; color: var(--text-muted); font-weight: 500; }
.kv-value { font-size: 0.875rem; color: var(--text-main); }
/* Animation Utils */
.animate-spin { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* Grid Utilities */
.grid { display: grid; }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@media (max-width: 1024px) {
.lg\:flex-col { flex-direction: column; }
.lg\:grid-cols-1 { grid-template-columns: minmax(0, 1fr); }
}
.tab-btn:hover {
color: var(--primary-color);
/* Page Headers */
.page-header { margin-bottom: 2rem; }
.page-title { font-size: 1.875rem; font-weight: 600; color: var(--text-main); margin: 0; letter-spacing: -0.04em; }
.page-subtitle { font-size: 1rem; color: var(--text-muted); margin-top: 0.25rem; }
/* Dashboard Specific Layout */
.dashboard-layout { display: flex; gap: 1.5rem; align-items: flex-start; }
.dashboard-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1.5rem; }
.dashboard-sidebar { width: 320px; flex-shrink: 0; display: flex; flex-direction: column; gap: 1rem; }
@media (max-width: 1024px) {
.dashboard-layout { flex-direction: column; }
.dashboard-sidebar { width: 100%; order: 2; }
.dashboard-main { order: 1; }
}
.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 */
/* Custom UI Components for App */
.flash-message {
padding: var(--spacing-md);
border-radius: var(--radius);
margin-bottom: var(--spacing-lg);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--success-bg); color: var(--success);
padding: 1rem; border-radius: var(--radius-md); border: 1px solid #A7F3D0;
display: flex; align-items: center; justify-content: space-between;
}
.flash-success {
background-color: var(--success-bg);
border: 1px solid var(--success-color);
color: #065F46;
.mrn-badge {
background: var(--bg-hover); color: var(--text-muted); padding: 0.25rem 0.5rem; border-radius: var(--radius-sm);
font-size: 0.875rem; font-weight: 500; font-family: var(--font-mono); border: 1px solid var(--border-hard);
}
/* 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);
}
.patient-meta-strip { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 0.5rem; }
.meta-item { font-size: 0.875rem; color: var(--text-muted); }
.meta-item strong { color: var(--text-main); font-weight: 500; }
details > summary::-webkit-details-marker {
display: none;
}
.stat-card { text-align: center; }
.stat-value { font-size: 2rem; font-weight: 600; line-height: 1; margin-bottom: 0.25rem; letter-spacing: -0.04em; }
.stat-label { font-size: 0.75rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.stat-card.danger { border-top: 3px solid var(--danger); }
details > summary::before {
content: '▶';
font-size: 0.75rem;
transition: transform 0.2s;
}
/* Vitals strip & grid */
.vitals-strip { display: flex; gap: 0.75rem; overflow-x: auto; padding-bottom: 0.5rem; }
.vital-item { min-width: 140px; background: var(--bg-hover); padding: 1rem; border-radius: var(--radius-md); display: flex; flex-direction: column; gap: 0.25rem; border: 1px solid var(--border-soft); }
.vital-label { font-size: 0.75rem; color: var(--text-muted); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vital-value { font-size: 1.25rem; font-weight: 600; color: var(--text-main); }
.vital-value small { font-size: 0.75rem; font-weight: 500; color: var(--text-muted); }
.vital-date { font-size: 0.65rem; color: var(--text-muted); }
.vital-abnormal { background: var(--danger-bg); border-color: #FECDD3; }
.vital-abnormal .vital-value { color: var(--danger); }
details[open] > summary::before {
transform: rotate(90deg);
}
.vitals-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; }
.alert-card { border-left: 3px solid var(--danger); }
.alert-item { display: flex; flex-direction: column; gap: 0.125rem; padding: 0.5rem 0; }
.alert-code { font-weight: 600; color: var(--danger); font-size: 0.875rem; }
.alert-detail { font-size: 0.75rem; color: var(--text-muted); }
.inspector-card { background: var(--bg-hover); border: 1px dashed var(--border-focus); }
.inspector-content { margin-top: 1rem; display: flex; flex-direction: column; gap: 1rem; }
.inspect-item .label { font-size: 0.75rem; font-weight: 600; color: var(--text-muted); display: block; margin-bottom: 0.25rem; }
.scopes-cloud { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.scope-tag { font-size: 0.75rem; background: var(--bg-panel); border: 1px solid var(--border-hard); padding: 0.125rem 0.375rem; border-radius: var(--radius-sm); color: var(--text-muted); font-family: var(--font-mono); }
.practitioner-mini-card { display: flex; align-items: center; gap: 0.5rem; background: var(--bg-hover); padding: 0.25rem 0.75rem 0.25rem 0.25rem; border-radius: var(--radius-full); border: 1px solid var(--border-soft); }
.empty-state { text-align: center; padding: 3rem; color: var(--text-muted); font-style: italic; }
.row-abnormal { background: var(--danger-bg); }
/* Note cards */
.notes-list { display: flex; flex-direction: column; gap: 1rem; }
.note-item { display: flex; flex-direction: column; gap: 0.5rem; }
.note-header { display: flex; align-items: center; gap: 0.75rem; }
.note-date { font-weight: 500; font-size: 0.875rem; color: var(--text-main); }
.note-type { font-size: 0.875rem; color: var(--text-muted); }
.note-desc { font-size: 0.875rem; color: var(--text-main); line-height: 1.5; }
/* Directory toolbar */
.directory-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; background: var(--bg-hover); border-bottom: 1px solid var(--border-hard); }
/* Additional Utilities */
.text-center { text-align: center; }
.text-right { text-align: right; }
.pt-4 { padding-top: 1rem; }
.pb-4 { padding-bottom: 1rem; }
.py-16 { padding-top: 4rem; padding-bottom: 4rem; }
.mt-4 { margin-top: 1rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.min-h-\[400px\] { min-height: 400px; }
.tracking-wider { letter-spacing: 0.05em; }
.break-all { word-break: break-all; }
/* Tailwind-like utilities used in templates */
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
.border-b { border-bottom-width: 1px; border-bottom-style: solid; }
.border-hard { border-color: var(--border-hard); }
.bg-panel { background-color: var(--bg-panel); }
/* Additional Spacing & Layout Utilities */
.m-0 { margin: 0; }
.p-0 { padding: 0; }
.p-5 { padding: 1.25rem; }
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.shrink-0 { flex-shrink: 0; }
.overflow-hidden { overflow: hidden; }
.cursor-pointer { cursor: pointer; }
.outline-none { outline: 2px solid transparent; outline-offset: 2px; }
.max-w-\[500px\] { max-width: 500px; }
.align-middle { vertical-align: middle; }
.border-l-brand { border-left: 6px solid var(--brand-dark); }
.border-b { border-bottom-width: 1px; border-bottom-style: solid; }
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }

View File

@@ -6,16 +6,13 @@
<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>
body { font-family: 'Inter', sans-serif; }
</style>
</head>
<body>
<header class="navbar">
<div class="container navbar-content">
<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>
FHIR <span class="brand-light">Sandbox</span>
</div>
<nav class="nav-links">
{{block "nav" .}}{{end}}
@@ -27,7 +24,7 @@
{{block "content" .}}{{end}}
</main>
<footer style="text-align: center; padding: 2rem; color: var(--text-muted); font-size: 0.875rem;">
<footer class="w-full text-center text-xs text-muted py-8">
<p>FHIR Health Platform &copy; 2026 — SMART on FHIR R4 Sandbox</p>
</footer>

View File

@@ -1,251 +1,241 @@
{{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>
<form action="/logout" method="POST" style="display:inline">
<button class="btn btn-danger" type="submit">Logout</button>
{{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 flash-success">
<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>Sync complete</strong>
<span class="text-sm">Data has been refreshed from the EHR.</span>
<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';" style="background:none;border:none;cursor:pointer;font-size:1.2rem;color:inherit;">&times;</button>
<button onclick="this.parentElement.style.display='none';" class="btn btn-icon text-muted">&times;</button>
</div>
{{end}}
<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}}
<div class="card">
<div class="card-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">
{{if .Practitioner.FirstName}}{{slice .Practitioner.FirstName 0 1}}{{end}}{{if .Practitioner.LastName}}{{slice .Practitioner.LastName 0 1}}{{end}}
</div>
<div>
<h4 class="font-bold">
{{if .Practitioner.FirstName}}{{.Practitioner.FirstName}} {{end}}
{{if .Practitioner.MiddleName}}{{.Practitioner.MiddleName}} {{end}}
{{if .Practitioner.LastName}}{{.Practitioner.LastName}}{{end}}
</h4>
<span class="text-sm text-muted">
{{if eq .Practitioner.Role "practitioner"}}Health Care Practitioner{{else}}Patient{{end}}
</span>
</div>
</div>
</div>
{{end}}
{{/* Patient Card */}}
{{if .Patient}}
<div class="card">
<div class="card-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">
{{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>
<h2 class="font-bold text-lg">
{{if .Patient.FirstName}}{{.Patient.FirstName}} {{end}}
{{if .Patient.MiddleName}}{{.Patient.MiddleName}} {{end}}
{{if .Patient.LastName}}{{.Patient.LastName}}{{end}}
</h2>
<span class="text-sm text-muted">ID: {{.Patient.FHIRID}}</span>
<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="details-list" style="grid-template-columns: 1fr;">
<div class="detail-item">
<span class="detail-label">DOB</span>
<span class="detail-value">{{formatDate .Patient.DOB}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Gender</span>
<span class="detail-value">{{titleCase .Patient.Gender}}</span>
</div>
<div class="detail-item">
<span class="detail-label">Email</span>
<span class="detail-value">{{orDash .Patient.Email}}</span>
</div>
</div>
</div>
{{/* Alerts Card */}}
{{if hasCriticalAllergies .Allergies}}
<div class="card" style="border-left: 4px solid var(--danger-color);">
<div class="card-header" style="border-bottom:none; padding-bottom:0; margin-bottom:0;">
<h3 class="card-title text-danger">Critical Allergies</h3>
</div>
<div class="mt-4">
{{range .Allergies}}
{{if eq .Criticality "high"}}
<div class="flex justify-between items-center mb-2">
<span class="font-medium text-danger">{{.CodeText}}</span>
<span class="badge badge-danger">High</span>
</div>
{{end}}
{{end}}
</div>
</div>
{{end}}
{{/* SMART Inspector */}}
<div class="card">
<details>
<summary>SMART Inspector</summary>
<div class="mt-4">
<div class="detail-item mb-4">
<span class="detail-label">FHIR Base URL</span>
<code class="code-block">{{.Session.EHRURL}}</code>
</div>
<div class="detail-item mb-4">
<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}}
</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;">
<button class="btn btn-primary btn-sm" type="submit" id="syncBtn" onclick="onSyncClick()">
<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
<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>
<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, 'observations')">Vitals & Labs <span class="badge badge-neutral ml-2">{{len .Observations}}</span></button>
<button class="tab-btn" onclick="openTab(event, 'conditions')">Conditions <span class="badge badge-neutral ml-2">{{len .Conditions}}</span></button>
<button class="tab-btn" onclick="openTab(event, 'medications')">Meds <span class="badge badge-neutral ml-2">{{len .Medications}}</span></button>
<button class="tab-btn" onclick="openTab(event, 'allergies')">Allergies <span class="badge badge-neutral ml-2">{{len .Allergies}}</span></button>
<button class="tab-btn" onclick="openTab(event, 'notes')">Notes <span class="badge badge-neutral ml-2">{{len .DocumentReferences}}</span></button>
<button class="tab-btn active" onclick="openTab(event, 'summary')">Summary</button>
<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>
{{/* Observations */}}
<div id="observations" class="tab-content active">
{{if .Observations}}
{{range $cat, $obs := groupByCategory .Observations}}
<div class="mb-4">
<h4 class="font-bold text-muted uppercase text-xs mb-2 tracking-wide">{{titleCase $cat}}</h4>
<div class="table-container">
<table>
<thead>
<tr>
<th>Date</th>
<th>Code</th>
<th>Value</th>
<th>Interpretation</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{{range $obs}}
<tr>
<td>{{orDash .EffectiveDate}}</td>
<td class="font-medium">{{orDash .CodeText}}</td>
<td>
{{if .ValueQuantity}}
<strong>{{printf "%.4g" (derefFloat64 .ValueQuantity)}}</strong> {{.ValueUnit}}
{{if or .ReferenceRangeLow .ReferenceRangeHigh}}
<div class="text-xs text-muted">
Ref: [{{if .ReferenceRangeLow}}{{printf "%.4g" (derefFloat64 .ReferenceRangeLow)}}{{else}}—{{end}} {{if .ReferenceRangeHigh}}{{printf "%.4g" (derefFloat64 .ReferenceRangeHigh)}}{{else}}—{{end}}]
<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}}
{{else if .ValueString}}
{{.ValueString}}
{{else}}—{{end}}
</td>
<td>
{{if .Interpretation}}
<span class="badge badge-info">{{.Interpretation}}</span>
{{else}}—{{end}}
</td>
<td><span class="badge badge-neutral">{{.Status}}</span></td>
</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>
{{/* ---- 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}}
<div class="text-center py-8 text-muted">No observations found.</div>
<p class="empty-state">No vital signs found.</p>
{{end}}
</div>
{{/* Conditions */}}
<div id="conditions" class="tab-content">
{{if .Conditions}}
<div class="flex gap-2 mb-4">
<button onclick="filterConditions('all')" class="btn btn-outline btn-sm active">All</button>
<button onclick="filterConditions('active')" class="btn btn-outline btn-sm">Active</button>
<button onclick="filterConditions('resolved')" class="btn btn-outline btn-sm">Resolved</button>
</div>
<div class="table-container">
{{/* ---- 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>Condition</th>
<th>Status</th>
<th>Verification</th>
<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 class="condition-row" data-status="{{.ClinicalStatus}}">
<td>{{orDash .RecordedDate}}</td>
<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 if eq .ClinicalStatus "resolved"}}
<span class="badge badge-neutral">Resolved</span>
{{else}}
<span class="badge badge-warning">{{.ClinicalStatus}}</span>
{{end}}
</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}}
@@ -253,77 +243,49 @@
</table>
</div>
{{else}}
<div class="text-center py-8 text-muted">No conditions found.</div>
<p class="empty-state">No conditions found.</p>
{{end}}
</div>
{{/* Medications */}}
{{/* ---- Medications Tab ---- */}}
<div id="medications" class="tab-content">
{{if .Medications}}
<div class="table-container">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Authored</th>
<th>Medication</th>
<th>Dosage</th>
<th>Status</th>
<th>Requester</th>
</tr>
<tr><th>Authored</th><th>Medication</th><th>Dosage</th><th>Status</th></tr>
</thead>
<tbody>
{{range .Medications}}
<tr>
<td>{{orDash .AuthoredOn}}</td>
<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>
<td>{{orDash .RequesterDisplay}}</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}}
<div class="text-center py-8 text-muted">No medications found.</div>
<p class="empty-state">No medications found.</p>
{{end}}
</div>
{{/* Allergies */}}
{{/* ---- Allergies Tab ---- */}}
<div id="allergies" class="tab-content">
{{if .Allergies}}
<div class="table-container">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Date</th>
<th>Allergen</th>
<th>Type</th>
<th>Criticality</th>
<th>Status</th>
</tr>
<tr><th>Allergen</th><th>Reaction</th><th>Severity</th><th>Status</th></tr>
</thead>
<tbody>
{{range .Allergies}}
<tr>
<td>{{orDash .RecordedDate}}</td>
<td class="font-medium">{{orDash .CodeText}}</td>
<td>{{titleCase .Type}}</td>
<td>
{{if eq .Criticality "high"}}
<span class="badge badge-danger">High</span>
{{else if eq .Criticality "medium"}}
<span class="badge badge-warning">Medium</span>
{{else}}
<span class="badge badge-neutral">{{orDash .Criticality}}</span>
{{end}}
</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}}
@@ -331,105 +293,181 @@
</table>
</div>
{{else}}
<div class="text-center py-8 text-muted">No allergies found.</div>
<p class="empty-state">No allergies found.</p>
{{end}}
</div>
{{/* Notes */}}
{{/* ---- Notes Tab ---- */}}
<div id="notes" class="tab-content">
{{if .DocumentReferences}}
<div class="table-container">
<table>
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Description</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<div class="notes-list">
{{range .DocumentReferences}}
<tr>
<td>{{orDash .Date}}</td>
<td class="font-medium">{{orDash .TypeText}}</td>
<td>{{orDash .Description}}</td>
<td><span class="badge badge-neutral">{{orDash .Status}}</span></td>
<td>
{{if .ContentURL}}
<a href="{{.ContentURL}}" target="_blank" class="btn btn-outline btn-sm" style="padding: 2px 8px; font-size: 0.75rem;">View</a>
{{else}}
<span class="text-muted text-xs">Inline</span>
<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}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="text-center py-8 text-muted">No clinical notes found.</div>
<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>
{{end}} {{/* End if .Patient */}}
</div>
</div>
{{end}}
{{end}}
{{define "scripts"}}
<script>
function openTab(evt, tabName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tab-content");
for (i = 0; i < tabcontent.length; i++) {
var tabcontent = document.getElementsByClassName("tab-content");
for (var i = 0; i < tabcontent.length; i++) {
tabcontent[i].classList.remove("active");
tabcontent[i].style.display = "none";
}
tablinks = document.getElementsByClassName("tab-btn");
for (i = 0; i < tablinks.length; i++) {
var tablinks = document.getElementsByClassName("tab-btn");
for (var i = 0; i < tablinks.length; i++) {
tablinks[i].classList.remove("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) {
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++) {
var rowStatus = rows[i].getAttribute("data-status");
if (status === "all" || rowStatus === status) {
rows[i].style.display = "";
} else {
rows[i].style.display = "none";
}
}
}
function onSyncClick() {
var btn = document.getElementById("syncBtn");
// Delay disabling to ensure form submission triggers
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); }
</script>
<style>
.animate-spin { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
{{end}}

View File

@@ -6,7 +6,7 @@
{{define "content"}}
<div class="card max-w-lg mx-auto mt-12 text-center p-8 shadow-md">
<div class="mb-6 text-danger" style="display:flex; justify-content:center;">
<div class="mb-6 text-danger flex justify-center">
<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>

View File

@@ -14,7 +14,7 @@
</p>
<div class="flex justify-center gap-4">
<a class="btn btn-primary btn-lg" style="padding: 12px 24px; font-size: 1.1rem;"
<a class="btn btn-primary btn-lg px-6 py-3 text-lg"
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

View File

@@ -1,71 +1,79 @@
{{template "base.html" .}}
{{define "nav"}}
<div class="flex items-center gap-4">
<a href="/dashboard" class="nav-link">Dashboard</a>
<form action="/logout" method="POST" style="display:inline">
<button class="btn btn-danger" type="submit">Logout</button>
<a href="/" class="nav-link">Home</a>
<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"}}
<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 class="page-header">
<div class="flex flex-col">
<h1 class="page-title">Patient Directory</h1>
<p class="page-subtitle">Showing all patients synced from <strong class="text-main font-medium">{{.Session.EHRURL}}</strong></p>
</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">
<div class="panel p-0 overflow-hidden">
<div class="directory-toolbar">
<div class="search-wrapper w-full max-w-[500px]">
<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" class="search-icon"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<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);"
class="input-base search-input"
placeholder="Search by name, MRN, or FHIR ID..."
onkeyup="filterPatients()">
</div>
<div class="badge badge-neutral text-xs font-semibold uppercase tracking-wider px-3 py-1">
{{len .Patients}} Patients
</div>
</div>
{{if .Patients}}
<div class="table-container">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Name</th>
<th>Date of Birth</th>
<th>Patient Name</th>
<th>MRN</th>
<th>DOB (Age)</th>
<th>Gender</th>
<th>FHIR ID</th>
<th class="text-right">Actions</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;">
<tr class="patient-row"
data-name="{{.FirstName}} {{.LastName}}"
data-fhir-id="{{.FHIRID}}"
data-mrn="{{.MRN}}">
<td>
<div class="flex items-center gap-3">
<div class="avatar avatar-sm">
{{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 class="flex flex-col">
<span class="font-semibold text-main">{{.LastName}}, {{.FirstName}} {{.MiddleName}}</span>
<span class="text-xs text-muted">FHIR ID: {{.FHIRID}}</span>
</div>
</div>
</td>
<td>{{formatDate .DOB}}</td>
<td><span class="mrn-badge">{{orDash .MRN}}</span></td>
<td>
<div class="flex flex-col">
<span>{{formatDate .DOB}}</span>
<span class="text-xs text-muted">{{calculateAge .DOB}} years old</span>
</div>
</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
<td class="text-right align-middle">
<a href="/dashboard?patient_id={{.FHIRID}}" class="btn btn-primary btn-sm btn-icon">
View Chart
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
</a>
</td>
</tr>
@@ -74,8 +82,12 @@
</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 class="empty-state flex flex-col items-center justify-center py-16">
<div class="text-faint mb-4">
<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"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<h3 class="text-lg font-semibold text-main mb-2">No Patients Found</h3>
<p class="text-sm text-muted">Try syncing a patient from the EHR or check your connection.</p>
</div>
{{end}}
</div>
@@ -92,8 +104,9 @@ function filterPatients() {
for (var i = 0; i < rows.length; i++) {
var name = rows[i].getAttribute("data-name").toLowerCase();
var fhirId = rows[i].getAttribute("data-fhir-id").toLowerCase();
var mrn = (rows[i].getAttribute("data-mrn") || "").toLowerCase();
if (name.includes(filter) || fhirId.includes(filter)) {
if (name.includes(filter) || fhirId.includes(filter) || mrn.includes(filter)) {
rows[i].style.display = "";
} else {
rows[i].style.display = "none";