- 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>
407 lines
17 KiB
HTML
407 lines
17 KiB
HTML
<!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>
|