MEK Charge Reconciliation: Waystar + Aquilaehr download, per-location reconciliation, web app
- mek_recon.py: Playwright download for both sites in parallel (headless on server) - consolidate.py: Patient Name + DOS match, per-location and All Locations Excel - app.py: sign-in (server-side password), Run button, per-file and ZIP downloads - Dockerfile for Dokploy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
fd480fa874
10 files changed
+1335
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
# secrets
|
||||||
|
.env
|
||||||
|
# patient data (PHI) - never commit
|
||||||
|
processing/
|
||||||
|
output/
|
||||||
|
logs/
|
||||||
|
# browser profiles / caches
|
||||||
|
.chrome_profile*/
|
||||||
|
__pycache__/
|
||||||
|
.ruff_cache/
|
||||||
|
.git/
|
||||||
|
.claude/
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# secrets
|
||||||
|
.env
|
||||||
|
# patient data (PHI) - never commit
|
||||||
|
processing/
|
||||||
|
output/
|
||||||
|
logs/
|
||||||
|
# browser profiles / caches
|
||||||
|
.chrome_profile*/
|
||||||
|
__pycache__/
|
||||||
|
.ruff_cache/
|
||||||
|
.claude/
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Playwright image ships Chromium + its OS deps for this exact Playwright version
|
||||||
|
FROM mcr.microsoft.com/playwright/python:v1.49.0-noble
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# headless, bundled Chromium (no Google Chrome in the image)
|
||||||
|
ENV HEADLESS=1 CHROME_CHANNEL= PYTHONUNBUFFERED=1 TZ=America/New_York
|
||||||
|
EXPOSE 8000
|
||||||
|
# one worker: run state is kept in memory
|
||||||
|
CMD ["gunicorn", "-w", "1", "--threads", "8", "-b", "0.0.0.0:8000", "--timeout", "120", "app:app"]
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Web front end: sign in -> Run -> download per-location reports or a ZIP of all.
|
||||||
|
|
||||||
|
Env: APP_PASSWORD (sign-in password), SECRET_KEY (session signing; random if unset),
|
||||||
|
COOKIE_SECURE=1 behind HTTPS, plus the Waystar/Aquilaehr logins mek_recon.py reads.
|
||||||
|
Run locally: python app.py -> http://127.0.0.1:8000
|
||||||
|
"""
|
||||||
|
import hmac
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from functools import wraps
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, abort, jsonify, redirect, render_template, request, send_file, session
|
||||||
|
|
||||||
|
import consolidate
|
||||||
|
import mek_recon
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent
|
||||||
|
OUT = ROOT / "output"
|
||||||
|
LOG = ROOT / "logs" / "run.log"
|
||||||
|
|
||||||
|
mek_recon.load_env() # local .env; on the server the values come from the environment
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
|
||||||
|
app.config.update(
|
||||||
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
|
SESSION_COOKIE_SECURE=os.environ.get("COOKIE_SECURE") == "1",
|
||||||
|
PERMANENT_SESSION_LIFETIME=12 * 3600,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ponytail: one run at a time, state in memory - fine for one gunicorn worker; restart forgets the last run
|
||||||
|
run = {"proc": None, "started": None, "finished": None, "code": None}
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def signed_in(view):
|
||||||
|
@wraps(view)
|
||||||
|
def wrapper(*a, **kw):
|
||||||
|
if not session.get("ok"):
|
||||||
|
return (jsonify(error="sign in"), 401) if request.path != "/" else render_template("login.html")
|
||||||
|
return view(*a, **kw)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/login")
|
||||||
|
def login():
|
||||||
|
expected = os.environ.get("APP_PASSWORD", "")
|
||||||
|
given = request.form.get("password", "")
|
||||||
|
if not expected or not hmac.compare_digest(given.encode(), expected.encode()):
|
||||||
|
time.sleep(1) # slow down guessing
|
||||||
|
return jsonify(error="wrong password"), 401
|
||||||
|
session.clear()
|
||||||
|
session.permanent = True
|
||||||
|
session["ok"] = True
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
@signed_in
|
||||||
|
def index():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
def reports():
|
||||||
|
return sorted(OUT.glob("*.xlsx"), key=lambda f: (f.name != consolidate.ALL_FILE, f.name.lower()))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/run")
|
||||||
|
@signed_in
|
||||||
|
def start_run():
|
||||||
|
with lock:
|
||||||
|
if run["proc"] and run["proc"].poll() is None:
|
||||||
|
return jsonify(error="already running"), 409
|
||||||
|
# start clean: a location that fails must not leave last run's file looking current
|
||||||
|
for f in [*ROOT.glob("processing/*/*.csv"), *OUT.glob("*.xlsx")]:
|
||||||
|
f.unlink()
|
||||||
|
LOG.parent.mkdir(exist_ok=True)
|
||||||
|
env = {**os.environ, "HEADLESS": os.environ.get("HEADLESS", "1"),
|
||||||
|
"PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"}
|
||||||
|
with LOG.open("w", encoding="utf-8") as log:
|
||||||
|
run["proc"] = subprocess.Popen([sys.executable, "mek_recon.py"], cwd=ROOT, env=env,
|
||||||
|
stdout=log, stderr=subprocess.STDOUT)
|
||||||
|
run.update(started=time.time(), finished=None, code=None)
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/status")
|
||||||
|
@signed_in
|
||||||
|
def status():
|
||||||
|
proc = run["proc"]
|
||||||
|
running = bool(proc and proc.poll() is None)
|
||||||
|
if proc and not running and run["finished"] is None:
|
||||||
|
run.update(finished=time.time(), code=proc.returncode)
|
||||||
|
log = LOG.read_text(encoding="utf-8", errors="replace") if LOG.exists() else ""
|
||||||
|
return jsonify(
|
||||||
|
running=running, started=run["started"], finished=run["finished"], code=run["code"],
|
||||||
|
log="\n".join(log.splitlines()[-300:]),
|
||||||
|
files=[{"name": f.name, "size": f.stat().st_size, "mtime": f.stat().st_mtime} for f in reports()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/download/<name>")
|
||||||
|
@signed_in
|
||||||
|
def download(name):
|
||||||
|
match = next((f for f in reports() if f.name == name), None) # only files we list
|
||||||
|
if not match:
|
||||||
|
abort(404)
|
||||||
|
return send_file(match, as_attachment=True, download_name=match.name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/download-all")
|
||||||
|
@signed_in
|
||||||
|
def download_all():
|
||||||
|
files = reports()
|
||||||
|
if not files:
|
||||||
|
abort(404)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for f in files:
|
||||||
|
z.write(f, f.name)
|
||||||
|
buf.seek(0)
|
||||||
|
stamp = time.strftime("%Y-%m-%d_%H%M", time.localtime(max(f.stat().st_mtime for f in files)))
|
||||||
|
return send_file(buf, as_attachment=True, download_name=f"MEK_Charge_Reconciliation_{stamp}.zip",
|
||||||
|
mimetype="application/zip")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", 8000)))
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
"""Per-location reconciliation: Aquilaehr visits vs Waystar claims, matched on
|
||||||
|
Patient Name + Date of Service. Writes output/<Location>.xlsx.
|
||||||
|
|
||||||
|
Aquilaehr: Patient ("Mary Lopez Garcia") + Visit Date
|
||||||
|
Waystar: Patient Name ("LOPEZ, MARY") + From Date
|
||||||
|
Match = same date, same first name, and at least one last-name word in common.
|
||||||
|
|
||||||
|
Run: python consolidate.py
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent
|
||||||
|
WAYSTAR_DIR = ROOT / "processing" / "Waystar"
|
||||||
|
AQUILAEHR_DIR = ROOT / "processing" / "Aquilaehr"
|
||||||
|
OUT_DIR = ROOT / "output"
|
||||||
|
SUFFIXES = {"JR", "SR", "II", "III", "IV"}
|
||||||
|
|
||||||
|
|
||||||
|
def words(s):
|
||||||
|
return [w for w in str(s).upper().replace(",", " ").replace(".", " ").split() if w not in SUFFIXES]
|
||||||
|
|
||||||
|
|
||||||
|
def join(values):
|
||||||
|
return ", ".join(dict.fromkeys(v for v in values if isinstance(v, str) and v.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def load_aquilaehr(path):
|
||||||
|
a = pd.read_csv(path, dtype=str).fillna("")
|
||||||
|
a["DOS"] = pd.to_datetime(a["Visit Date"], errors="coerce").dt.date
|
||||||
|
a["Name"] = a["Patient"].str.split().str.join(" ")
|
||||||
|
# one row per note/template -> one row per patient visit
|
||||||
|
v = a.groupby(["Account #", "Name", "DOS"], as_index=False).agg(
|
||||||
|
**{"Aquilaehr Claim ID": ("Claim ID", join), "Note Status": ("Note Status", join),
|
||||||
|
"Claim Status": ("Claim Status", join), "Provider": ("Provider", lambda s: join(" ".join(x.split()) for x in s))}
|
||||||
|
)
|
||||||
|
t = v["Name"].map(words)
|
||||||
|
v["first"] = t.str[0].fillna("")
|
||||||
|
v["last"] = t.str[1:].map(set)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def load_waystar(path):
|
||||||
|
w = pd.read_csv(path, skiprows=7, dtype=str, index_col=False).fillna("")
|
||||||
|
for c in w.columns: # Excel-friendly CSV wraps values like ="..."
|
||||||
|
w[c] = w[c].str.replace(r'^="(.*)"$', r"\1", regex=True)
|
||||||
|
w["DOS"] = pd.to_datetime(w["From Date"], errors="coerce").dt.date
|
||||||
|
w["Charges"] = pd.to_numeric(w["Charges"].str.replace(r"[$,]", "", regex=True), errors="coerce").fillna(0)
|
||||||
|
w["Claim ID ref"] = w["Claim Number"].str.split().str[0]
|
||||||
|
v = w.groupby(["Patient Name", "DOS"], as_index=False).agg(
|
||||||
|
**{"Waystar Claim Number": ("Claim Number", join), "Charges": ("Charges", "sum"),
|
||||||
|
"Waystar Status": ("Status", join), "Claim ID ref": ("Claim ID ref", join)}
|
||||||
|
)
|
||||||
|
parts = v["Patient Name"].str.partition(",") # "LAST, FIRST MI"
|
||||||
|
v["first"] = [(words(f) or [""])[0] for f in parts[2]]
|
||||||
|
v["last"] = [set(words(n)) for n in parts[0]]
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile(a, w):
|
||||||
|
rows, used = [], set()
|
||||||
|
a_by_key = {}
|
||||||
|
for i, r in a.iterrows():
|
||||||
|
a_by_key.setdefault((r["first"], r["DOS"]), []).append(i)
|
||||||
|
a_claim_dates = {}
|
||||||
|
for _, r in a.iterrows():
|
||||||
|
for cid in r["Aquilaehr Claim ID"].split(", "):
|
||||||
|
if cid:
|
||||||
|
a_claim_dates.setdefault(cid, set()).add(r["DOS"])
|
||||||
|
w_claim_dates = {}
|
||||||
|
for _, r in w.iterrows():
|
||||||
|
for cid in r["Claim ID ref"].split(", "):
|
||||||
|
if cid:
|
||||||
|
w_claim_dates.setdefault(cid, set()).add(r["DOS"])
|
||||||
|
|
||||||
|
def fmt(dates):
|
||||||
|
return ", ".join(d.strftime("%m/%d/%Y") for d in sorted(dates) if pd.notna(d))
|
||||||
|
|
||||||
|
for _, r in w.iterrows():
|
||||||
|
same = [i for i in a_by_key.get((r["first"], r["DOS"]), []) if a.at[i, "last"] & r["last"]]
|
||||||
|
hit = next((i for i in same if i not in used), None)
|
||||||
|
row = {"Patient Name": r["Patient Name"], "DOS": r["DOS"],
|
||||||
|
"Waystar Claim Number": r["Waystar Claim Number"], "Charges": r["Charges"],
|
||||||
|
"Waystar Status": r["Waystar Status"]}
|
||||||
|
if hit is not None:
|
||||||
|
used.add(hit)
|
||||||
|
row.update(Result="Matched", **a.loc[hit, ["Aquilaehr Claim ID", "Note Status", "Claim Status", "Provider"]])
|
||||||
|
row["Aquilaehr Patient"] = a.at[hit, "Name"]
|
||||||
|
elif same: # visit already matched to another Waystar claim
|
||||||
|
row.update(Result="Duplicate in Waystar", **a.loc[same[0], ["Aquilaehr Claim ID", "Claim Status"]])
|
||||||
|
row["Aquilaehr Patient"] = a.at[same[0], "Name"]
|
||||||
|
row["Note"] = "Same patient + DOS already matched to another Waystar claim"
|
||||||
|
else:
|
||||||
|
row["Result"] = "Missing in Aquilaehr"
|
||||||
|
other = set().union(*(a_claim_dates.get(c, set()) for c in r["Claim ID ref"].split(", ")))
|
||||||
|
if other:
|
||||||
|
row["Note"] = f"Claim ID {r['Claim ID ref']} is in Aquilaehr with Visit Date {fmt(other)}"
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
for i, r in a.iterrows():
|
||||||
|
if i in used:
|
||||||
|
continue
|
||||||
|
row = {"Patient Name": r["Name"], "DOS": r["DOS"], "Result": "Missing in Waystar",
|
||||||
|
"Aquilaehr Patient": r["Name"], **r[["Aquilaehr Claim ID", "Note Status", "Claim Status", "Provider"]]}
|
||||||
|
other = set().union(*(w_claim_dates.get(c, set()) for c in r["Aquilaehr Claim ID"].split(", ") if c))
|
||||||
|
if other:
|
||||||
|
row["Note"] = f"Claim ID {r['Aquilaehr Claim ID']} is in Waystar with DOS {fmt(other)}"
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
cols = ["Result", "Patient Name", "DOS", "Aquilaehr Patient", "Aquilaehr Claim ID", "Note Status",
|
||||||
|
"Claim Status", "Provider", "Waystar Claim Number", "Charges", "Waystar Status", "Note"]
|
||||||
|
order = {"Missing in Waystar": 0, "Missing in Aquilaehr": 1, "Duplicate in Waystar": 2, "Matched": 3}
|
||||||
|
df = pd.DataFrame(rows).reindex(columns=cols)
|
||||||
|
return df.sort_values(["Result", "Patient Name", "DOS"], key=lambda s: s.map(order) if s.name == "Result" else s)
|
||||||
|
|
||||||
|
|
||||||
|
def write_report(df, location, out):
|
||||||
|
counts = df["Result"].value_counts()
|
||||||
|
summary = pd.DataFrame({
|
||||||
|
"Item": ["Location", "Aquilaehr visits", "Waystar visits", "Matched",
|
||||||
|
"Missing in Waystar", "Missing in Aquilaehr", "Duplicate in Waystar"],
|
||||||
|
"Value": [location, int(counts.get("Matched", 0) + counts.get("Missing in Waystar", 0)),
|
||||||
|
int((df["Result"] != "Missing in Waystar").sum()), int(counts.get("Matched", 0)),
|
||||||
|
int(counts.get("Missing in Waystar", 0)), int(counts.get("Missing in Aquilaehr", 0)),
|
||||||
|
int(counts.get("Duplicate in Waystar", 0))],
|
||||||
|
})
|
||||||
|
write_xlsx(out, summary, df)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def write_xlsx(out, summary, df):
|
||||||
|
with pd.ExcelWriter(out, engine="openpyxl") as xw:
|
||||||
|
summary.to_excel(xw, sheet_name="Summary", index=False)
|
||||||
|
df.to_excel(xw, sheet_name="Reconciliation", index=False)
|
||||||
|
for ws in xw.book.worksheets:
|
||||||
|
ws.freeze_panes = "A2"
|
||||||
|
ws.auto_filter.ref = ws.dimensions
|
||||||
|
for col in ws.columns:
|
||||||
|
width = max(len(str(c.value or "")) for c in col[:200])
|
||||||
|
ws.column_dimensions[col[0].column_letter].width = min(max(width + 2, 10), 60)
|
||||||
|
ws = xw.book["Reconciliation"]
|
||||||
|
for col in ws.iter_cols(min_row=1, max_row=ws.max_row):
|
||||||
|
fmt = {"DOS": "mm/dd/yyyy", "Charges": '"$"#,##0.00'}.get(col[0].value)
|
||||||
|
for c in col[1:] if fmt else ():
|
||||||
|
c.number_format = fmt
|
||||||
|
|
||||||
|
|
||||||
|
ALL_FILE = "All Locations.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
OUT_DIR.mkdir(exist_ok=True)
|
||||||
|
summaries, frames = [], []
|
||||||
|
for wf in sorted(WAYSTAR_DIR.glob("*.csv")):
|
||||||
|
af = AQUILAEHR_DIR / wf.name
|
||||||
|
if not af.exists():
|
||||||
|
print(f"SKIP {wf.stem}: no Aquilaehr file")
|
||||||
|
continue
|
||||||
|
df = reconcile(load_aquilaehr(af), load_waystar(wf))
|
||||||
|
summary = write_report(df, wf.stem, OUT_DIR / f"{wf.stem}.xlsx")
|
||||||
|
s = summary["Value"].tolist()
|
||||||
|
print(f"{wf.stem:40} Aquilaehr={s[1]:5} Waystar={s[2]:5} Matched={s[3]:5} "
|
||||||
|
f"Missing in Waystar={s[4]:4} Missing in Aquilaehr={s[5]:4} Duplicate in Waystar={s[6]:3}")
|
||||||
|
if s[1] and s[2] and s[3] < 0.5 * min(s[1], s[2]):
|
||||||
|
print(f" WARNING {wf.stem}: under half the visits match - wrong practice data downloaded?")
|
||||||
|
summaries.append(summary.set_index("Item")["Value"])
|
||||||
|
frames.append(df.assign(Location=wf.stem))
|
||||||
|
|
||||||
|
if frames: # one overall workbook: a row per location + every location's rows
|
||||||
|
overall = pd.DataFrame(summaries).reset_index(drop=True)
|
||||||
|
total = overall.drop(columns="Location").sum().to_dict()
|
||||||
|
overall = pd.concat([overall, pd.DataFrame([{"Location": "TOTAL", **total}])], ignore_index=True)
|
||||||
|
rows = pd.concat(frames, ignore_index=True)
|
||||||
|
write_xlsx(OUT_DIR / ALL_FILE, overall, rows[["Location", *rows.columns[:-1]]])
|
||||||
|
print(f"{'ALL LOCATIONS':40} -> output/{ALL_FILE}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+325
@@ -0,0 +1,325 @@
|
|||||||
|
"""MEK Charge reconciliation process.
|
||||||
|
|
||||||
|
Waystar (ZirMed): for each location -> switch account -> Claims -> Status All,
|
||||||
|
Transaction Date All -> Search -> Download CSV -> processing/Waystar/<location>.csv
|
||||||
|
Aquilaehr: Reports -> Encounter & Claim Monitor -> pick practice -> Export CSV
|
||||||
|
-> processing/Aquilaehr/<location>.csv (same file name as Waystar so the pair lines up)
|
||||||
|
Then consolidate.py matches Patient Name + DOS per location -> output/<location>.xlsx
|
||||||
|
|
||||||
|
Run: python mek_recon.py both sites at the same time, all locations
|
||||||
|
python mek_recon.py Aquilaehr only Aquilaehr
|
||||||
|
python mek_recon.py Waystar "Healing Bridge" only Waystar, only that location
|
||||||
|
Login is automatic from .env (WAYSTAR_USERNAME, WAYSTAR_PASSWORD, WAYSTAR_ANSWER_CAR,
|
||||||
|
WAYSTAR_ANSWER_JOB, AQUILAEHR_USERNAME, AQUILAEHR_PASSWORD). If something is
|
||||||
|
missing/unexpected, finish the login by hand in Chrome; the script waits.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from multiprocessing import Process
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
import consolidate
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent
|
||||||
|
WAYSTAR, AQUILAEHR = "Waystar", "Aquilaehr"
|
||||||
|
OUT = {site: ROOT / "processing" / site for site in (WAYSTAR, AQUILAEHR)}
|
||||||
|
|
||||||
|
# Waystar account name -> Aquilaehr practice dropdown names.
|
||||||
|
# Several Aquilaehr practices are exported and merged into one file.
|
||||||
|
# Adelman Health is skipped on both sites (no Aquilaehr practice).
|
||||||
|
LOCATIONS = {
|
||||||
|
"Alpha Well-Being LLC": ["ALPHA WELL-BEING LLC"],
|
||||||
|
"ARC Wound Care": ["ARC Woundcare LLC"],
|
||||||
|
"Cynthia DM Concierge NP Services LLC": ["Moona Concierge Nurse Practitioner Services LLC"],
|
||||||
|
"Healing Bridge": ["HEALING BRIDGE LLC"],
|
||||||
|
"Joy Solutions LLC": ["Joy Solutions CORP"],
|
||||||
|
"Lunas Home NP LLC": ["LUNAS HOME NP LLC"],
|
||||||
|
"Stem Regen NJ": ["Stem Regen Medical"],
|
||||||
|
"Sunwave Woundcare": ["Sunwave Woundcare"],
|
||||||
|
"WoundDocs LLC": ["WOUNDOCS LLC", "WOUNDOCS LLC - 2"],
|
||||||
|
"Wound Pros of FL": ["Wound Professionals of Florida LLC"],
|
||||||
|
}
|
||||||
|
|
||||||
|
WAYSTAR_LOGIN = "https://login.zirmed.com/ui"
|
||||||
|
WAYSTAR_CLAIMS = "https://claims.zirmed.com/Claims/Listing/Index?appid=1"
|
||||||
|
AQUILAEHR_REPORTS = "https://platform.aquilaehr.com/#/reports/clinical"
|
||||||
|
# time to finish a login by hand; headless (server) runs have no hand, so fail sooner
|
||||||
|
LOGIN_WAIT_MS = (3 if os.environ.get("HEADLESS") == "1" else 10) * 60 * 1000
|
||||||
|
|
||||||
|
|
||||||
|
def load_env():
|
||||||
|
env = ROOT / ".env"
|
||||||
|
if env.exists():
|
||||||
|
for line in env.read_text(encoding="utf-8").splitlines():
|
||||||
|
if "=" in line and not line.lstrip().startswith("#"):
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
os.environ.setdefault(k.strip(), v.strip().strip('"'))
|
||||||
|
|
||||||
|
|
||||||
|
def log(site, msg):
|
||||||
|
print(f"{site:9} {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Waystar ----------------
|
||||||
|
|
||||||
|
def waystar_login(page):
|
||||||
|
page.goto(WAYSTAR_LOGIN)
|
||||||
|
account_box = page.locator("input.header-account-search-text")
|
||||||
|
if os.environ.get("WAYSTAR_USERNAME") and page.locator("#loginName").is_visible():
|
||||||
|
page.fill("#loginName", os.environ["WAYSTAR_USERNAME"])
|
||||||
|
page.fill("#password", os.environ.get("WAYSTAR_PASSWORD", ""))
|
||||||
|
page.click("#loginButton")
|
||||||
|
log(WAYSTAR, "login submitted")
|
||||||
|
|
||||||
|
# pages Waystar may show after "Log in" (ids taken from the G2 ChargeBilledAutomation bot)
|
||||||
|
answers = {"first car": os.environ.get("WAYSTAR_ANSWER_CAR"), "first job": os.environ.get("WAYSTAR_ANSWER_JOB")}
|
||||||
|
question = page.locator("#authDiv strong").first
|
||||||
|
alert = page.locator("#continueButton") # "Proactive Alert" page
|
||||||
|
sec_info = page.locator("h2", has_text="Security Information")
|
||||||
|
end = time.time() + LOGIN_WAIT_MS / 1000
|
||||||
|
while time.time() < end:
|
||||||
|
if account_box.is_visible():
|
||||||
|
log(WAYSTAR, "logged in")
|
||||||
|
return
|
||||||
|
if alert.is_visible():
|
||||||
|
alert.click()
|
||||||
|
log(WAYSTAR, "proactive alert continued")
|
||||||
|
elif question.is_visible():
|
||||||
|
text = question.inner_text().lower()
|
||||||
|
key = next((k for k in answers if k in text), None)
|
||||||
|
if not key or not answers[key]:
|
||||||
|
raise RuntimeError(f"unknown Waystar security question: {text}")
|
||||||
|
page.fill("#verifyAnswer", answers[key])
|
||||||
|
page.click("#VerifyButton")
|
||||||
|
log(WAYSTAR, f"security question ({key}) answered")
|
||||||
|
elif sec_info.is_visible():
|
||||||
|
page.click("#NextButton")
|
||||||
|
page.get_by_text("Skip This Step", exact=True).click()
|
||||||
|
log(WAYSTAR, "security information page skipped")
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
raise RuntimeError("Waystar login did not finish in time")
|
||||||
|
|
||||||
|
|
||||||
|
def waystar_switch_account(page, location):
|
||||||
|
box = page.locator("input.header-account-search-text")
|
||||||
|
box.click()
|
||||||
|
box.fill("")
|
||||||
|
box.press_sequentially(location, delay=50)
|
||||||
|
items = page.locator("ul.ui-autocomplete li:visible")
|
||||||
|
# dropdown text looks like "Healing Bridge (313858)"; it shows the first 25
|
||||||
|
# accounts before the typed filter kicks in, so wait for the actual match
|
||||||
|
match = items.filter(has_text=location).first
|
||||||
|
try:
|
||||||
|
match.wait_for(timeout=15000)
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError(f"no account match in dropdown: {items.all_inner_texts()}")
|
||||||
|
match.click()
|
||||||
|
page.wait_for_url("**/SuccessfulAccountChange/**", timeout=30000)
|
||||||
|
|
||||||
|
|
||||||
|
def waystar_download(page, location):
|
||||||
|
waystar_switch_account(page, location)
|
||||||
|
page.goto(WAYSTAR_CLAIMS)
|
||||||
|
page.select_option("#SearchCriteria_Status", label="All")
|
||||||
|
page.select_option("#SearchCriteria_TransDate", label="All")
|
||||||
|
page.click("#ClaimListingSearchButtonTop")
|
||||||
|
page.get_by_text("Transaction Date: All").first.wait_for(timeout=60000)
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
with page.expect_download(timeout=120000) as dl:
|
||||||
|
page.click("#downloadCsvBtn")
|
||||||
|
out = OUT[WAYSTAR] / f"{location}.csv"
|
||||||
|
dl.value.save_as(out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Aquilaehr ----------------
|
||||||
|
|
||||||
|
AQUILAEHR_PRACTICE = ".ant-select[style*='200px']" # practice dropdown at the top of the page
|
||||||
|
AQUILAEHR_EXPORT = "a[download]" # <a> inside the "Export CSV" button (blob URL)
|
||||||
|
|
||||||
|
|
||||||
|
def aquilaehr_login(page):
|
||||||
|
page.goto(AQUILAEHR_REPORTS)
|
||||||
|
email = page.locator("#normal_login_email")
|
||||||
|
logged_in = page.get_by_text("Reports", exact=True).first
|
||||||
|
submitted = False
|
||||||
|
end = time.time() + LOGIN_WAIT_MS / 1000
|
||||||
|
# SPA: either the login form or the app shows up after a few seconds
|
||||||
|
while time.time() < end:
|
||||||
|
if logged_in.is_visible():
|
||||||
|
log(AQUILAEHR, "logged in")
|
||||||
|
return
|
||||||
|
if not submitted and os.environ.get("AQUILAEHR_USERNAME") and email.is_visible():
|
||||||
|
email.fill(os.environ["AQUILAEHR_USERNAME"])
|
||||||
|
page.fill("#normal_login_password", os.environ.get("AQUILAEHR_PASSWORD", ""))
|
||||||
|
page.click("button[type=submit]")
|
||||||
|
submitted = True
|
||||||
|
log(AQUILAEHR, "login submitted")
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
raise RuntimeError("Aquilaehr login did not finish in time")
|
||||||
|
|
||||||
|
|
||||||
|
def aquilaehr_download(page, location):
|
||||||
|
"""Export every practice for this location and merge into one CSV (header once)."""
|
||||||
|
out = OUT[AQUILAEHR] / f"{location}.csv"
|
||||||
|
lines = []
|
||||||
|
for practice in LOCATIONS[location]:
|
||||||
|
part = aquilaehr_export(page, practice, OUT[AQUILAEHR] / f"{location}.part.csv")
|
||||||
|
rows = part.read_text(encoding="utf-8-sig").splitlines()
|
||||||
|
part.unlink()
|
||||||
|
lines += rows if not lines else rows[1:]
|
||||||
|
log(AQUILAEHR, f" {practice}: {len(rows) - 1} rows")
|
||||||
|
out.write_text("\n".join(lines) + "\n", encoding="utf-8-sig")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def norm(s):
|
||||||
|
return " ".join(s.split()).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def aquilaehr_select_practice(page, practice):
|
||||||
|
# practice dropdown is a virtual list: scroll the open one until the option renders
|
||||||
|
page.locator(AQUILAEHR_PRACTICE).click()
|
||||||
|
page.locator(".ant-select-dropdown:not(.ant-select-dropdown-hidden)").wait_for()
|
||||||
|
found = page.evaluate(
|
||||||
|
"""async (title) => {
|
||||||
|
const norm = s => (s || '').split(/\\s+/).join(' ').trim().toLowerCase();
|
||||||
|
const dd = document.querySelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
|
||||||
|
const h = dd.querySelector('.rc-virtual-list-holder');
|
||||||
|
for (let y = 0; y <= h.scrollHeight + 100; y += 80) {
|
||||||
|
h.scrollTop = y;
|
||||||
|
await new Promise(r => setTimeout(r, 150));
|
||||||
|
const o = [...dd.querySelectorAll('.ant-select-item-option')]
|
||||||
|
.find(o => norm(o.getAttribute('title')) === norm(title));
|
||||||
|
if (o) { o.click(); return true; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}""",
|
||||||
|
practice,
|
||||||
|
)
|
||||||
|
if not found:
|
||||||
|
raise RuntimeError(f"practice '{practice}' not in dropdown")
|
||||||
|
|
||||||
|
|
||||||
|
def aquilaehr_export(page, practice, out):
|
||||||
|
# practice ids whose report data has arrived (GET .../reports/clinical_notes?practice_id=<id>)
|
||||||
|
loaded = []
|
||||||
|
|
||||||
|
def on_response(r):
|
||||||
|
m = re.search(r"/reports/clinical_notes\?practice_id=([\w-]+)", r.url)
|
||||||
|
if m and r.ok:
|
||||||
|
loaded.append(m.group(1))
|
||||||
|
|
||||||
|
page.on("response", on_response)
|
||||||
|
try:
|
||||||
|
# Reports -> Encounter & Claim Monitor tab
|
||||||
|
page.goto(AQUILAEHR_REPORTS)
|
||||||
|
page.get_by_text("Encounter & Claim Monitor", exact=True).first.click()
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
current = page.locator(f"{AQUILAEHR_PRACTICE} .ant-select-selection-item")
|
||||||
|
if norm(current.inner_text()) != norm(practice):
|
||||||
|
before = page.url
|
||||||
|
aquilaehr_select_practice(page, practice)
|
||||||
|
page.wait_for_url(lambda u: u != before and "practiceId=" in u, timeout=30000)
|
||||||
|
if norm(current.inner_text()) != norm(practice):
|
||||||
|
raise RuntimeError(f"dropdown shows '{current.inner_text()}', expected '{practice}'")
|
||||||
|
|
||||||
|
# wait for THIS practice's data; data for the previous practice can still be arriving
|
||||||
|
pid = re.search(r"practiceId=([\w-]+)", page.url)
|
||||||
|
end = time.time() + 120
|
||||||
|
while not (pid.group(1) in loaded if pid else loaded):
|
||||||
|
if time.time() > end:
|
||||||
|
raise RuntimeError(f"report data for '{practice}' did not load")
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
page.wait_for_timeout(2000) # let the table + export link re-render
|
||||||
|
|
||||||
|
with page.expect_download(timeout=120000) as dl:
|
||||||
|
page.locator(AQUILAEHR_EXPORT).click()
|
||||||
|
dl.value.save_as(out)
|
||||||
|
finally:
|
||||||
|
page.remove_listener("response", on_response)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- runner ----------------
|
||||||
|
|
||||||
|
SITES = {
|
||||||
|
WAYSTAR: (waystar_login, waystar_download),
|
||||||
|
AQUILAEHR: (aquilaehr_login, aquilaehr_download),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_site(site, locations):
|
||||||
|
"""One site in its own Chrome (own profile), so both sites can run at the same time."""
|
||||||
|
load_env()
|
||||||
|
OUT[site].mkdir(parents=True, exist_ok=True)
|
||||||
|
login, download = SITES[site]
|
||||||
|
failed = []
|
||||||
|
seen = {} # file content -> location; two locations with identical exports = stale data
|
||||||
|
with sync_playwright() as p:
|
||||||
|
# server/Docker: HEADLESS=1 and CHROME_CHANNEL="" (Playwright's bundled Chromium)
|
||||||
|
headless = os.environ.get("HEADLESS") == "1"
|
||||||
|
ctx = p.chromium.launch_persistent_context(
|
||||||
|
str(ROOT / f".chrome_profile_{site.lower()}"),
|
||||||
|
channel=os.environ.get("CHROME_CHANNEL", "chrome") or None, headless=headless,
|
||||||
|
accept_downloads=True, no_viewport=not headless,
|
||||||
|
viewport={"width": 1440, "height": 900} if headless else None,
|
||||||
|
)
|
||||||
|
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||||
|
login(page)
|
||||||
|
for loc in locations:
|
||||||
|
try:
|
||||||
|
out = download(page, loc)
|
||||||
|
content = out.read_bytes()
|
||||||
|
if content in seen:
|
||||||
|
log(site, f"WARNING {loc}: same data as {seen[content]} - stale export?")
|
||||||
|
seen[content] = loc
|
||||||
|
log(site, f"OK {loc} -> {out.name} ({content.count(b'\n')} lines)")
|
||||||
|
except Exception as e:
|
||||||
|
failed.append(loc)
|
||||||
|
log(site, f"FAIL {loc}: {e}")
|
||||||
|
ctx.close()
|
||||||
|
log(site, f"failed: {failed or 'none'}")
|
||||||
|
|
||||||
|
|
||||||
|
def locked_files():
|
||||||
|
"""Files open in Excel can't be overwritten -> the run would silently keep old data."""
|
||||||
|
locked = []
|
||||||
|
for f in [*ROOT.glob("processing/*/*.csv"), *ROOT.glob("output/*.xlsx")]:
|
||||||
|
try:
|
||||||
|
with open(f, "a"):
|
||||||
|
pass
|
||||||
|
except PermissionError:
|
||||||
|
locked.append(f.relative_to(ROOT))
|
||||||
|
return locked
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
locked = locked_files()
|
||||||
|
if locked:
|
||||||
|
print("Close these files in Excel first, then run again:")
|
||||||
|
for f in locked:
|
||||||
|
print(f" {f}")
|
||||||
|
sys.exit(1)
|
||||||
|
args = sys.argv[1:]
|
||||||
|
sites = [args.pop(0)] if args and args[0] in SITES else list(SITES)
|
||||||
|
locations = [loc for loc in LOCATIONS if not args or loc in args]
|
||||||
|
if len(sites) == 1:
|
||||||
|
run_site(sites[0], locations)
|
||||||
|
else:
|
||||||
|
procs = [Process(target=run_site, args=(site, locations)) for site in sites]
|
||||||
|
for pr in procs:
|
||||||
|
pr.start()
|
||||||
|
for pr in procs:
|
||||||
|
pr.join()
|
||||||
|
print("Consolidating -> output/<Location>.xlsx", flush=True)
|
||||||
|
consolidate.main()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# MEK Charge reconciliation process
|
||||||
|
|
||||||
|
Sites:
|
||||||
|
- Waystar (ZirMed) -> https://login.zirmed.com/ui
|
||||||
|
- Aquilaehr -> https://platform.aquilaehr.com/
|
||||||
|
|
||||||
|
Run: `python mek_recon.py` -> both sites at the same time, each in its own Chrome window
|
||||||
|
(own profile: .chrome_profile_waystar / .chrome_profile_aquilaehr).
|
||||||
|
One site only: `python mek_recon.py Waystar` or `python mek_recon.py Aquilaehr`.
|
||||||
|
Close processing/ and output/ files in Excel/WPS before running (the script stops and lists them otherwise).
|
||||||
|
Full run verified 10/27/2026: both sites 10/10 locations, reports in output/ (~1 min).
|
||||||
|
|
||||||
|
Logins come from .env (values never stored in code):
|
||||||
|
- Waystar: WAYSTAR_USERNAME, WAYSTAR_PASSWORD, WAYSTAR_ANSWER_CAR, WAYSTAR_ANSWER_JOB
|
||||||
|
-> fills #loginName / #password -> "Log in" -> security question page: answer by keyword
|
||||||
|
("first car" / "first job") -> click "Verify"
|
||||||
|
- Aquilaehr: AQUILAEHR_USERNAME, AQUILAEHR_PASSWORD
|
||||||
|
|
||||||
|
## Locations (run the whole process for each, on BOTH sites)
|
||||||
|
- ~~Adelman Health~~ (skipped)
|
||||||
|
- Alpha Well-Being LLC
|
||||||
|
- ARC Wound Care
|
||||||
|
- Cynthia DM Concierge NP Services LLC
|
||||||
|
- Healing Bridge
|
||||||
|
- Joy Solutions LLC
|
||||||
|
- Lunas Home NP LLC
|
||||||
|
- Stem Regen NJ
|
||||||
|
- Sunwave Woundcare
|
||||||
|
- WoundDocs LLC
|
||||||
|
- Wound Pros of FL
|
||||||
|
|
||||||
|
## Waystar steps
|
||||||
|
1. Open https://login.zirmed.com/ui and log in
|
||||||
|
2. Select location: top "Account" box -> type location name
|
||||||
|
-> pick from dropdown (e.g. "Healing Bridge (313858)")
|
||||||
|
-> lands on "Success - You have switched to the following account" page
|
||||||
|
3. Professional Claims > click "Claims" tab
|
||||||
|
-> https://claims.zirmed.com/Claims/Listing/Index?appid=1
|
||||||
|
4. Left panel filters:
|
||||||
|
- Status (select#SearchCriteria_Status) -> "All" (default was "All Rejected")
|
||||||
|
- Transaction Date (select#SearchCriteria_TransDate) -> "All" (default "Last 90 Days"; "All" = last 2 years)
|
||||||
|
5. Click "Search"
|
||||||
|
6. Click "Download CSV" (#downloadCsvBtn, "Excel-Friendly CSV" ticked)
|
||||||
|
-> save as processing/Waystar/<Location>.csv
|
||||||
|
CSV layout: 7 preamble lines (Generated by / Account: / Filters...), then header row:
|
||||||
|
Trans Date, Patient Name, Claim Number, Claim ID, Instance ID, Payer Name, Payer ID, From Date, To Date,
|
||||||
|
Rendering Provider, Charges, Status, Sequence, Source, Hidden, Remit #s, Total Remit Amount, Last Note, Last Event Message
|
||||||
|
7. Repeat 2-6 for every location.
|
||||||
|
|
||||||
|
## Aquilaehr steps
|
||||||
|
1. Open https://platform.aquilaehr.com/ (login page: #normal_login_email, #normal_login_password, "Login")
|
||||||
|
2. Sidebar "Reports" -> https://platform.aquilaehr.com/#/reports/clinical
|
||||||
|
3. Click "Encounter & Claim Monitor" tab
|
||||||
|
4. Top practice dropdown (antd select, virtual list - scroll to find) -> pick location
|
||||||
|
-> URL gets ?practiceId=<guid>; page reloads data. Date range left empty = all dates.
|
||||||
|
5. Wait until the data call for the NEW practice finishes
|
||||||
|
(GET aquila-api-prod-xv7xr.ondigitalocean.app/api/v1/reports/clinical_notes?practice_id=<new id>),
|
||||||
|
and the dropdown shows the chosen practice. (Exporting earlier gives the PREVIOUS practice's data.)
|
||||||
|
Then click "Export CSV" (it's <a download="e&c-monitor.csv" href=blob:...>, contains ALL rows, not just page 1)
|
||||||
|
-> save as processing/Aquilaehr/<Waystar location name>.csv
|
||||||
|
Columns: Account #, Patient, DOB, Visit Date, Provider, Location, Visit Reason, Room, Template,
|
||||||
|
Plan Name, Claim ID, Note Status, Claim Status
|
||||||
|
6. Repeat 4-5 for every location.
|
||||||
|
|
||||||
|
Waystar name -> Aquilaehr dropdown name: see LOCATIONS in mek_recon.py
|
||||||
|
- Adelman Health: SKIPPED on both sites (no Aquilaehr practice)
|
||||||
|
- Cynthia DM Concierge NP Services LLC -> "Moona Concierge Nurse Practitioner Services LLC"
|
||||||
|
- WoundDocs LLC -> "WOUNDOCS LLC" + "WOUNDOCS LLC - 2", both exported and merged into one file
|
||||||
|
|
||||||
|
## Consolidation
|
||||||
|
Match key (verified 10/27/2026, 3938 of 4027 Waystar claims found in Aquilaehr = 97.8%):
|
||||||
|
Waystar "Claim Number" = ="<Aquilaehr Claim ID> <Aquilaehr Account #>" e.g. ="42756 35055"
|
||||||
|
-> first token of Waystar Claim Number == Aquilaehr "Claim ID"
|
||||||
|
Parsing notes: Waystar CSV = skip 7 preamble lines, rows end with a trailing comma (read with index_col=False),
|
||||||
|
values wrapped like ="..." (strip them). Aquilaehr CSV: one row per note/template, so a Claim ID can repeat.
|
||||||
|
|
||||||
|
Report (consolidate.py, runs automatically at the end of mek_recon.py, or alone: `python consolidate.py`):
|
||||||
|
- Match on Patient Name + Date of Service, per location:
|
||||||
|
Aquilaehr Patient + Visit Date vs Waystar Patient Name + From Date
|
||||||
|
= same DOS, same first name, at least one last-name word in common
|
||||||
|
(handles "Mary Lopez Garcia" vs "LOPEZ, MARY", extra spaces, Jr/Sr)
|
||||||
|
- Aquilaehr rows grouped to one row per visit (Account # + Patient + Visit Date);
|
||||||
|
Waystar rows grouped per Patient Name + From Date (Charges summed)
|
||||||
|
- Output: output/<Location>.xlsx with 2 sheets
|
||||||
|
- Summary: Aquilaehr visits, Waystar visits, Matched, Missing in Waystar, Missing in Aquilaehr, Duplicate in Waystar
|
||||||
|
- Reconciliation: one row per visit, Result column:
|
||||||
|
Missing in Waystar = visit in Aquilaehr, no Waystar claim for that patient + DOS
|
||||||
|
Missing in Aquilaehr = Waystar claim, no Aquilaehr visit for that patient + DOS
|
||||||
|
Duplicate in Waystar = 2nd Waystar claim for an already-matched patient + DOS (e.g. two name spellings)
|
||||||
|
Matched
|
||||||
|
- Note column: when the same Claim ID exists on the other side under a different date
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
playwright==1.49.0
|
||||||
|
pandas==3.0.3
|
||||||
|
openpyxl==3.1.5
|
||||||
|
flask==3.1.0
|
||||||
|
gunicorn==23.0.0
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>MEK Charge Reconciliation</title>
|
||||||
|
<style>
|
||||||
|
/* same night palette as the sign-in page */
|
||||||
|
:root {
|
||||||
|
--night: #040b26;
|
||||||
|
--panel: #0c1638;
|
||||||
|
--panel-line: #26335e;
|
||||||
|
--field: #050c28;
|
||||||
|
--pill-line: #c9d5f2;
|
||||||
|
--ink: #eef2ff;
|
||||||
|
--ink-2: #a9b6d8;
|
||||||
|
--owl: #ec7d33;
|
||||||
|
--ok: #7fe0a8;
|
||||||
|
--error: #ff9a9a;
|
||||||
|
--ui: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--night); color: var(--ink); font: 15px/1.5 var(--ui); }
|
||||||
|
main { width: min(860px, 100%); margin: 0 auto; padding: 32px 16px 48px; }
|
||||||
|
header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 20px; }
|
||||||
|
h1 { margin: 0; font-size: clamp(20px, 5vw, 24px); font-weight: 650; letter-spacing: -.01em; }
|
||||||
|
h2 { margin: 0 0 12px; font-size: 17px; font-weight: 650; }
|
||||||
|
.card { background: var(--panel); border: 1px solid var(--panel-line); border-radius: 18px; padding: 22px 22px 20px; margin-bottom: 16px; }
|
||||||
|
.muted { color: var(--ink-2); }
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
height: 44px; padding: 0 22px; background: #0a1435; color: #fff; text-decoration: none;
|
||||||
|
border: 1.5px solid var(--pill-line); border-radius: 999px; font: 600 15px var(--ui); cursor: pointer;
|
||||||
|
}
|
||||||
|
.pill:hover { border-color: var(--owl); background: #101d45; }
|
||||||
|
.pill:focus-visible, a:focus-visible { outline: 2px solid var(--owl); outline-offset: 3px; }
|
||||||
|
.pill.primary { border-color: var(--owl); }
|
||||||
|
.pill[disabled] { opacity: .5; cursor: not-allowed; }
|
||||||
|
.pill.small { height: 34px; padding: 0 14px; font-size: 13px; }
|
||||||
|
.row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
|
||||||
|
.status { font-weight: 600; }
|
||||||
|
.status.running { color: var(--owl); }
|
||||||
|
.status.ok { color: var(--ok); }
|
||||||
|
.status.fail { color: var(--error); }
|
||||||
|
pre {
|
||||||
|
margin: 14px 0 0; max-height: 280px; overflow: auto; padding: 12px 14px;
|
||||||
|
background: var(--field); border: 1px solid var(--panel-line); border-radius: 12px;
|
||||||
|
font: 12.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-word;
|
||||||
|
}
|
||||||
|
pre:empty { display: none; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
td { padding: 9px 4px; border-top: 1px solid var(--panel-line); vertical-align: middle; }
|
||||||
|
td:last-child { text-align: right; white-space: nowrap; }
|
||||||
|
.file { font-weight: 600; }
|
||||||
|
.file small { display: block; font-weight: 400; color: var(--ink-2); }
|
||||||
|
.empty { color: var(--ink-2); margin: 0; }
|
||||||
|
form.inline { margin: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<h1>MEK Charge Reconciliation</h1>
|
||||||
|
<form class="inline" method="post" action="/logout"><button class="pill small" type="submit">Sign out</button></form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card" aria-labelledby="run-h">
|
||||||
|
<h2 id="run-h">Run</h2>
|
||||||
|
<p class="muted" style="margin:0 0 14px">Downloads Waystar claims and Aquilaehr encounters for every location at the same time,
|
||||||
|
then builds one reconciliation report per location (Patient Name + Date of Service).</p>
|
||||||
|
<div class="row">
|
||||||
|
<button class="pill primary" id="run" type="button">Run reconciliation</button>
|
||||||
|
<span class="status" id="status" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
<pre id="log" aria-label="Run log"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" aria-labelledby="dl-h">
|
||||||
|
<div class="row" style="justify-content:space-between;margin-bottom:6px">
|
||||||
|
<h2 id="dl-h" style="margin:0">Reports</h2>
|
||||||
|
<a class="pill small" id="zip" href="/download-all">Download all (ZIP)</a>
|
||||||
|
</div>
|
||||||
|
<table><tbody id="files"></tbody></table>
|
||||||
|
<p class="empty" id="no-files" hidden>No reports yet. Run the reconciliation first.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const runBtn = document.getElementById("run");
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const logEl = document.getElementById("log");
|
||||||
|
const filesEl = document.getElementById("files");
|
||||||
|
const noFiles = document.getElementById("no-files");
|
||||||
|
const zip = document.getElementById("zip");
|
||||||
|
let timer;
|
||||||
|
|
||||||
|
const fmtTime = s => s ? new Date(s * 1000).toLocaleString() : "";
|
||||||
|
|
||||||
|
function render(st) {
|
||||||
|
runBtn.disabled = st.running;
|
||||||
|
statusEl.className = "status " + (st.running ? "running" : st.code === 0 ? "ok" : st.code == null ? "" : "fail");
|
||||||
|
statusEl.textContent = st.running ? `Running… started ${fmtTime(st.started)}`
|
||||||
|
: st.code === 0 ? `Finished ${fmtTime(st.finished)}`
|
||||||
|
: st.code == null ? "" : `Failed (exit ${st.code}) ${fmtTime(st.finished)}. See the log.`;
|
||||||
|
const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 8;
|
||||||
|
logEl.textContent = st.log;
|
||||||
|
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
|
||||||
|
filesEl.innerHTML = "";
|
||||||
|
for (const f of st.files) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const name = document.createElement("td");
|
||||||
|
name.className = "file";
|
||||||
|
name.textContent = f.name.replace(/\.xlsx$/, "");
|
||||||
|
const meta = document.createElement("small");
|
||||||
|
meta.textContent = `Updated ${fmtTime(f.mtime)} · ${(f.size / 1024).toFixed(0)} KB`;
|
||||||
|
name.append(meta);
|
||||||
|
const act = document.createElement("td");
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.className = "pill small";
|
||||||
|
a.href = "/download/" + encodeURIComponent(f.name);
|
||||||
|
a.textContent = "Download";
|
||||||
|
act.append(a);
|
||||||
|
tr.append(name, act);
|
||||||
|
filesEl.append(tr);
|
||||||
|
}
|
||||||
|
noFiles.hidden = st.files.length > 0;
|
||||||
|
zip.hidden = st.files.length === 0;
|
||||||
|
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(refresh, st.running ? 2000 : 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const res = await fetch("/status");
|
||||||
|
if (res.status === 401) return location.href = "/";
|
||||||
|
render(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
runBtn.addEventListener("click", async () => {
|
||||||
|
runBtn.disabled = true;
|
||||||
|
const res = await fetch("/run", { method: "POST" });
|
||||||
|
if (res.status === 409) statusEl.textContent = "A run is already in progress.";
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sign in · MEK Charge Reconciliation</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--night: #040b26;
|
||||||
|
--panel: #0c1638;
|
||||||
|
--panel-line: #26335e;
|
||||||
|
--field: #050c28;
|
||||||
|
--field-line: #56689c;
|
||||||
|
--pill-line: #c9d5f2;
|
||||||
|
--ink: #eef2ff;
|
||||||
|
--ink-2: #a9b6d8;
|
||||||
|
--owl: #ec7d33;
|
||||||
|
--owl-soft: #f5a25a;
|
||||||
|
--error: #ff9a9a;
|
||||||
|
--owl-w: 190px;
|
||||||
|
--ui: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
color-scheme: dark;
|
||||||
|
scrollbar-color: var(--panel-line) var(--night);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--night);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 15px/1.5 var(--ui);
|
||||||
|
caret-color: var(--owl);
|
||||||
|
}
|
||||||
|
::selection { background: var(--owl); color: var(--night); }
|
||||||
|
|
||||||
|
.sky { position: fixed; inset: 0; overflow: hidden; pointer-events: none; }
|
||||||
|
.sky span {
|
||||||
|
position: absolute;
|
||||||
|
width: var(--s);
|
||||||
|
height: var(--s);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--c);
|
||||||
|
opacity: var(--o);
|
||||||
|
}
|
||||||
|
.sky .tw { animation: twinkle 4s ease-in-out infinite; animation-delay: var(--d); }
|
||||||
|
@keyframes twinkle { 50% { opacity: .15; } }
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
/* Anchored near the middle rather than centered, so an error line grows the card downward instead of moving the owl. */
|
||||||
|
padding: max(32px, calc(50vh - 230px)) 16px 48px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(20px, 5vw, 23px);
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
text-wrap: balance;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.sub { margin: 4px 0 22px; color: var(--ink-2); text-align: center; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
width: min(360px, 100%);
|
||||||
|
margin-top: calc(var(--owl-w) * 50 / 48 + 12px);
|
||||||
|
padding: 28px 24px 24px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--panel-line);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 3, 15, .5);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.owl {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(100% - 7px);
|
||||||
|
width: var(--owl-w);
|
||||||
|
height: auto;
|
||||||
|
margin-left: calc(var(--owl-w) / -2);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
label { display: block; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
.field { position: relative; }
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
height: 46px;
|
||||||
|
padding: 0 52px 0 20px;
|
||||||
|
background: var(--field);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--field-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
font: 16px var(--ui);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
input:focus-visible { outline: 2px solid var(--owl); outline-offset: 2px; border-color: var(--owl); }
|
||||||
|
input[aria-invalid="true"] { border-color: var(--error); outline-color: var(--error); }
|
||||||
|
.reveal {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--ink-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.reveal:hover { color: var(--ink); background: rgba(201, 213, 242, .1); }
|
||||||
|
.reveal:focus-visible { outline: 2px solid var(--owl); outline-offset: 0; color: var(--ink); }
|
||||||
|
.reveal svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note, .error { margin: 8px 0 0; font-size: 14px; }
|
||||||
|
.note { color: var(--owl-soft); }
|
||||||
|
.error { color: var(--error); }
|
||||||
|
.error:empty { margin: 0; }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
width: 100%;
|
||||||
|
height: 46px;
|
||||||
|
margin-top: 20px;
|
||||||
|
background: #0a1435;
|
||||||
|
color: #fff;
|
||||||
|
border: 1.5px solid var(--pill-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
font: 600 15px var(--ui);
|
||||||
|
letter-spacing: .02em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s ease-out, background-color .15s ease-out;
|
||||||
|
}
|
||||||
|
.pill:hover { border-color: var(--owl); background: #101d45; }
|
||||||
|
.pill:active { transform: translateY(1px); }
|
||||||
|
.pill:focus-visible { outline: 2px solid var(--owl); outline-offset: 3px; }
|
||||||
|
|
||||||
|
.done h2 { margin: 0; font-size: 20px; }
|
||||||
|
.done h2:focus { outline: none; }
|
||||||
|
.done p { margin: 6px 0 0; color: var(--ink-2); }
|
||||||
|
|
||||||
|
/* Owl. Origins are in the SVG's own units (viewBox 0 0 48 50). */
|
||||||
|
#bob, #head, #glasses, #wing-l, #wing-r { transform-box: view-box; }
|
||||||
|
#bob { transform-origin: 24px 30px; }
|
||||||
|
#head { transform-origin: 24px 30px; }
|
||||||
|
#glasses { transform-origin: 24px 24px; }
|
||||||
|
#wing-l { transform-origin: 11px 25px; }
|
||||||
|
#wing-r { transform-origin: 37px 25px; }
|
||||||
|
.iris { transition: transform .14s ease-out; }
|
||||||
|
.lid { transform-box: fill-box; transform-origin: 50% 0; transform: scaleY(0); transition: transform .08s ease-out; }
|
||||||
|
.owl.blink .lid { transform: scaleY(1); }
|
||||||
|
.smile { transform-box: fill-box; transform-origin: 50% 100%; transform: scaleY(0); transition: transform .12s ease-out; }
|
||||||
|
.owl.happy .smile { transform: none; }
|
||||||
|
|
||||||
|
.owl.idle #bob { animation: breathe 3.2s ease-in-out infinite; }
|
||||||
|
@keyframes breathe { 50% { transform: translateY(.4px); } }
|
||||||
|
|
||||||
|
.owl.sad #head { animation: droop 1.4s ease-in-out; }
|
||||||
|
@keyframes droop { 20%, 75% { transform: translateY(1.2px) rotate(-6deg); } }
|
||||||
|
.owl.sad .lid { animation: slow-blink 1.4s ease-in-out; }
|
||||||
|
@keyframes slow-blink { 30%, 55% { transform: scaleY(1); } }
|
||||||
|
|
||||||
|
.eye-rim { transition: stroke .15s ease-out, filter .15s ease-out; }
|
||||||
|
.owl.peek .eye-rim { stroke: #ffc400; stroke-width: 1.5; filter: drop-shadow(0 0 2px #ffd84d) drop-shadow(0 0 5px #ffb300); }
|
||||||
|
#glasses rect { transition: fill .15s ease-out, fill-opacity .15s ease-out, filter .15s ease-out; }
|
||||||
|
.owl.peek #glasses rect { fill: #ffe066; fill-opacity: .45; filter: drop-shadow(0 0 3px #ffc400); }
|
||||||
|
|
||||||
|
|
||||||
|
.owl.tilt #head { animation: tilt .8s ease-in-out; }
|
||||||
|
@keyframes tilt { 25%, 70% { transform: rotate(-10deg); } }
|
||||||
|
|
||||||
|
.owl { cursor: pointer; }
|
||||||
|
.owl.happy, .owl.jump { animation: hop .75s cubic-bezier(.3, .6, .35, 1); }
|
||||||
|
@keyframes hop { 40% { transform: translateY(-14px); } 70% { transform: none; } 82% { transform: translateY(-3px); } }
|
||||||
|
.owl.happy #wing-l, .owl.jump #wing-l { animation: flap-l .75s ease-in-out; }
|
||||||
|
.owl.happy #wing-r, .owl.jump #wing-r { animation: flap-r .75s ease-in-out; }
|
||||||
|
@keyframes flap-l { 20%, 60% { transform: rotate(32deg); } 40%, 80% { transform: rotate(8deg); } }
|
||||||
|
@keyframes flap-r { 20%, 60% { transform: rotate(-32deg); } 40%, 80% { transform: rotate(-8deg); } }
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
:root { --owl-w: 160px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sky .tw, .owl, .owl * { animation: none !important; transition: none !important; }
|
||||||
|
.pill { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sky" id="sky" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<main class="stage">
|
||||||
|
<section class="card">
|
||||||
|
<svg class="owl idle" id="owl" viewBox="0 0 48 50" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="coat" cx="42%" cy="28%" r="78%">
|
||||||
|
<stop stop-color="#ff990e"/><stop offset=".6" stop-color="#f58200"/><stop offset="1" stop-color="#bd4b00"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="wing" x2=".8" y2="1">
|
||||||
|
<stop stop-color="#ed7b00"/><stop offset="1" stop-color="#a73e00"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow">
|
||||||
|
<stop offset="0" stop-color="#ffaa5a" stop-opacity=".35"/><stop offset="1" stop-color="#ffaa5a" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<clipPath id="eye-l"><ellipse cx="17" cy="20" rx="4" ry="5"/></clipPath>
|
||||||
|
<clipPath id="eye-r"><ellipse cx="31" cy="20" rx="4" ry="5"/></clipPath>
|
||||||
|
</defs>
|
||||||
|
<ellipse cx="24" cy="48.5" rx="17" ry="2.6" fill="url(#glow)"/>
|
||||||
|
<g stroke="#38200b" stroke-width="1.2" stroke-linejoin="round">
|
||||||
|
<path id="wing-l" fill="url(#wing)" d="M10 24 Q5 24 4 32 Q3 38 8 39 L12 34Z"/>
|
||||||
|
<path id="wing-r" fill="url(#wing)" d="M38 24 Q43 24 44 32 Q45 38 40 39 L36 34Z"/>
|
||||||
|
<path fill="url(#coat)" d="M8 27 C8 18 7 11 13 6 Q17 2 24 2 Q31 2 35 6 C41 11 40 18 40 27 Q42 38 35 43 Q30 47 24 47 Q17 47 13 43 Q6 38 8 27Z"/>
|
||||||
|
<path fill="none" stroke="#ffbe42" stroke-width="1.25" stroke-linecap="round"
|
||||||
|
d="M13 33 l1.5 2 l1.5 -2 M19 33 l1.5 2 l1.5 -2 M25 33 l1.5 2 l1.5 -2 M31 33 l1.5 2 l1.5 -2 M16 38 l1.5 2 l1.5 -2 M22 38 l1.5 2 l1.5 -2 M28 38 l1.5 2 l1.5 -2 M20 43 l1.5 1 l1.5 -1 M26 43 l1.5 1 l1.5 -1"/>
|
||||||
|
<path fill="none" stroke="#ac4904" stroke-width="1" d="M12 8 Q18 6 24 14 Q21 6 18 4 M24 13 Q25 6 30 4 M26 13 Q31 7 36 9"/>
|
||||||
|
<g id="bob"><g id="head">
|
||||||
|
<path fill="#ffe8b3" stroke-width="1" d="M24 14 C19 8 11 11 11 18 V26 Q11 30 17 30 Q21 30 24 28 Q27 30 31 30 Q37 30 37 26 V18 C37 11 29 8 24 14Z"/>
|
||||||
|
<g clip-path="url(#eye-l)">
|
||||||
|
<ellipse cx="17" cy="20" rx="4" ry="5" fill="#fffdf4" stroke="none"/>
|
||||||
|
<g class="iris"><ellipse cx="17" cy="21" rx="2.7" ry="3.3" fill="#6b3518" stroke="none"/><ellipse cx="17" cy="21.5" rx="1.8" ry="2.5" fill="#170e08" stroke="none"/><circle cx="16.3" cy="19.5" r="1" fill="#fff" stroke="none"/></g>
|
||||||
|
<rect class="lid" x="13" y="15" width="8" height="10" fill="#ffe8b3" stroke="none"/>
|
||||||
|
<g class="smile"><path fill="#ffe8b3" stroke="none" d="M13 21Q17 16.5 21 21L21 25L13 25Z"/><path fill="none" stroke="#6b3518" stroke-width=".7" stroke-linecap="round" d="M13.6 20.6Q17 17 20.4 20.6"/></g>
|
||||||
|
</g>
|
||||||
|
<ellipse class="eye-rim" cx="17" cy="20" rx="4" ry="5" fill="none" stroke-width="1"/>
|
||||||
|
<g clip-path="url(#eye-r)">
|
||||||
|
<ellipse cx="31" cy="20" rx="4" ry="5" fill="#fffdf4" stroke="none"/>
|
||||||
|
<g class="iris"><ellipse cx="31" cy="21" rx="2.7" ry="3.3" fill="#6b3518" stroke="none"/><ellipse cx="31" cy="21.5" rx="1.8" ry="2.5" fill="#170e08" stroke="none"/><circle cx="30.3" cy="19.5" r="1" fill="#fff" stroke="none"/></g>
|
||||||
|
<rect class="lid" x="27" y="15" width="8" height="10" fill="#ffe8b3" stroke="none"/>
|
||||||
|
<g class="smile"><path fill="#ffe8b3" stroke="none" d="M27 21Q31 16.5 35 21L35 25L27 25Z"/><path fill="none" stroke="#6b3518" stroke-width=".7" stroke-linecap="round" d="M27.6 20.6Q31 17 34.4 20.6"/></g>
|
||||||
|
</g>
|
||||||
|
<ellipse class="eye-rim" cx="31" cy="20" rx="4" ry="5" fill="none" stroke-width="1"/>
|
||||||
|
<g id="glasses" fill="none" stroke="#2a180c" stroke-width="1">
|
||||||
|
<rect x="12" y="22" width="10" height="6" rx="1.5"/>
|
||||||
|
<rect x="26" y="22" width="10" height="6" rx="1.5"/>
|
||||||
|
<path d="M22 23 Q24 21 26 23 M10 21 L12 23 M36 23 L38 21"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#eb870a" stroke-width=".8" d="M22 26 Q24 24 26 26 L24 30Z"/>
|
||||||
|
</g></g>
|
||||||
|
<path fill="#ed900f" stroke-width="1" d="M16 45 L14 48 Q17 49 20 48 L19 46Z M29 46 L28 48 Q31 49 34 48 L32 45Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h1>MEK Charge Reconciliation</h1>
|
||||||
|
<p class="sub" id="sub">Sign in</p>
|
||||||
|
|
||||||
|
<form id="login" method="post" action="/login" novalidate>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<div class="field">
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" autofocus
|
||||||
|
aria-describedby="password-msg caps" required>
|
||||||
|
<button type="button" class="reveal" id="reveal" aria-label="Show password" aria-pressed="false"></button>
|
||||||
|
</div>
|
||||||
|
<p class="note" id="caps" aria-live="polite" hidden>Caps Lock is on</p>
|
||||||
|
<p class="error" id="password-msg" role="alert"></p>
|
||||||
|
<button type="submit" class="pill">Sign in</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Password is checked on the server (APP_PASSWORD); nothing secret lives in this page.
|
||||||
|
const EYE = '<path d="M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12z"/><circle cx="12" cy="12" r="2.8"/>';
|
||||||
|
const EYE_OFF = '<path d="M4 4l16 16"/><path d="M9.9 5.7A10 10 0 0 1 12 5.5c6 0 9.5 6.5 9.5 6.5a16 16 0 0 1-2.6 3.4M6.7 7.2C4 9 2.5 12 2.5 12s3.5 6.5 9.5 6.5a9.6 9.6 0 0 0 4.7-1.2"/><path d="M10 10.1a2.8 2.8 0 0 0 3.9 3.9"/>';
|
||||||
|
|
||||||
|
const owl = document.getElementById("owl");
|
||||||
|
const form = document.getElementById("login");
|
||||||
|
const pw = document.getElementById("password");
|
||||||
|
const reveal = document.getElementById("reveal");
|
||||||
|
const caps = document.getElementById("caps");
|
||||||
|
const msg = document.getElementById("password-msg");
|
||||||
|
const irises = owl.querySelectorAll(".iris");
|
||||||
|
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
|
||||||
|
// Seeded so the stars stay put; none behind the text, owl or card.
|
||||||
|
function drawSky() {
|
||||||
|
let seed = 7;
|
||||||
|
const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
|
||||||
|
const colors = ["#6ea8ff", "#4fd1c5", "#f29c4b", "#dfe7ff"];
|
||||||
|
const keepOut = [...document.querySelectorAll(".card, .owl")].map(e => e.getBoundingClientRect());
|
||||||
|
let html = "";
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const tw = rand() < .35, x = rand() * innerWidth, y = rand() * innerHeight;
|
||||||
|
const size = (1.5 + rand() * 1.5).toFixed(1), color = colors[Math.floor(rand() * 4)];
|
||||||
|
const alpha = (.45 + rand() * .45).toFixed(2), delay = (rand() * 4).toFixed(2);
|
||||||
|
if (keepOut.some(r => x > r.left - 10 && x < r.right + 10 && y > r.top - 10 && y < r.bottom + 10)) continue;
|
||||||
|
html += `<span class="${tw ? "tw" : ""}" style="left:${x.toFixed(0)}px;top:${y.toFixed(0)}px;` +
|
||||||
|
`--s:${size}px;--c:${color};--o:${alpha};--d:${delay}s"></span>`;
|
||||||
|
}
|
||||||
|
document.getElementById("sky").innerHTML = html;
|
||||||
|
}
|
||||||
|
drawSky();
|
||||||
|
addEventListener("resize", drawSky);
|
||||||
|
|
||||||
|
function look(dx, dy) {
|
||||||
|
irises.forEach(g => { g.style.transform = `translate(${dx}px, ${dy}px)`; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pupils slide from the left end of the field to the right as the typed text grows.
|
||||||
|
const measure = document.createElement("canvas").getContext("2d");
|
||||||
|
function followCaret() {
|
||||||
|
if (document.activeElement !== pw) return look(0, 0);
|
||||||
|
const cs = getComputedStyle(pw);
|
||||||
|
measure.font = cs.font;
|
||||||
|
measure.letterSpacing = cs.letterSpacing;
|
||||||
|
const caret = pw.selectionStart ?? pw.value.length;
|
||||||
|
const shown = pw.type === "password" ? "•".repeat(caret) : pw.value.slice(0, caret);
|
||||||
|
const room = pw.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||||
|
const ratio = Math.min(measure.measureText(shown).width / room, 1);
|
||||||
|
look(-1.2 + 2.4 * ratio, 1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timers = {};
|
||||||
|
function play(name, ms, then) {
|
||||||
|
clearTimeout(timers[name]);
|
||||||
|
owl.classList.remove(name);
|
||||||
|
void owl.getBoundingClientRect();
|
||||||
|
owl.classList.add(name);
|
||||||
|
timers[name] = setTimeout(() => { owl.classList.remove(name); then?.(); }, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleared first so a repeated identical message is announced again.
|
||||||
|
function showError(text) {
|
||||||
|
msg.textContent = "";
|
||||||
|
pw.setAttribute("aria-invalid", text ? "true" : "false");
|
||||||
|
if (text) setTimeout(() => { msg.textContent = text; }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReveal(show) {
|
||||||
|
pw.type = show ? "text" : "password";
|
||||||
|
reveal.setAttribute("aria-pressed", String(show));
|
||||||
|
reveal.setAttribute("aria-label", show ? "Hide password" : "Show password");
|
||||||
|
owl.classList.toggle("peek", show);
|
||||||
|
reveal.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${show ? EYE_OFF : EYE}</svg>`;
|
||||||
|
}
|
||||||
|
setReveal(false);
|
||||||
|
|
||||||
|
for (const ev of ["focus", "blur", "input", "keyup", "click", "select"]) pw.addEventListener(ev, followCaret);
|
||||||
|
pw.addEventListener("input", () => { if (msg.textContent) showError(""); });
|
||||||
|
for (const ev of ["keydown", "keyup"]) {
|
||||||
|
pw.addEventListener(ev, e => { caps.hidden = !e.getModifierState?.("CapsLock"); });
|
||||||
|
}
|
||||||
|
pw.addEventListener("blur", () => { caps.hidden = true; });
|
||||||
|
|
||||||
|
// Keep focus (and the owl's gaze) in the field when the eye button is clicked.
|
||||||
|
reveal.addEventListener("mousedown", e => e.preventDefault());
|
||||||
|
reveal.addEventListener("click", () => { setReveal(pw.type === "password"); followCaret(); });
|
||||||
|
|
||||||
|
form.addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!pw.value) {
|
||||||
|
showError("Enter your password.");
|
||||||
|
play("tilt", 800);
|
||||||
|
pw.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch("/login", { method: "POST", body: new FormData(form) });
|
||||||
|
} catch {
|
||||||
|
showError("Can't reach the server. Try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
showError(res.status === 401 ? "That password isn't right. Try again." : "Sign-in failed. Try again.");
|
||||||
|
pw.focus();
|
||||||
|
pw.select();
|
||||||
|
play("sad", 1400, followCaret);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showError("");
|
||||||
|
pw.value = "";
|
||||||
|
setReveal(false);
|
||||||
|
const enter = () => { location.href = "/"; };
|
||||||
|
if (reducedMotion.matches) enter();
|
||||||
|
else play("happy", 750, enter);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tap the owl and it jumps.
|
||||||
|
owl.addEventListener("click", () => { if (!reducedMotion.matches) play("jump", 750); });
|
||||||
|
|
||||||
|
(function blink() {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!reducedMotion.matches && !owl.classList.contains("happy") && !owl.classList.contains("sad")) play("blink", 160);
|
||||||
|
blink();
|
||||||
|
}, 2500 + Math.random() * 3500);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user