736 lines
33 KiB
HTML
736 lines
33 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Cascades — Staff & Department Editor (2026-04-22)</title>
|
||
<style>
|
||
:root {
|
||
--bg:#f4f6fa; --panel:#fff; --line:#d7dce4; --ink:#1a2030;
|
||
--muted:#5c6678; --accent:#2b6cb0; --accent-ink:#fff;
|
||
--caregiver:#fff6e5; --caregiver-line:#f1c27d;
|
||
--staff:#eef3fb; --staff-line:#c5d5ec;
|
||
--warn:#b42318; --ok:#15803d; --drop:#fff8c5;
|
||
--pending:#fdecec; --pending-line:#f4b4ae;
|
||
}
|
||
* { box-sizing:border-box; }
|
||
html,body { margin:0; background:var(--bg); color:var(--ink);
|
||
font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||
header {
|
||
position:sticky; top:0; z-index:5; background:#1a2030; color:#fff;
|
||
padding:10px 16px; display:flex; gap:10px; align-items:center; flex-wrap:wrap;
|
||
box-shadow:0 1px 4px rgba(0,0,0,.15);
|
||
}
|
||
header h1 { font-size:16px; margin:0; flex:1; font-weight:600; }
|
||
header .sub { font-size:12px; color:#9aa4b8; margin-left:6px; }
|
||
button {
|
||
background:#2b6cb0; color:#fff; border:0; border-radius:5px;
|
||
padding:7px 12px; font:inherit; cursor:pointer;
|
||
}
|
||
button:hover { background:#224f84; }
|
||
button.ghost { background:transparent; color:#cfd6e4; border:1px solid #3a4458; }
|
||
button.ghost:hover { background:#2a3346; }
|
||
button.danger { background:#b42318; }
|
||
button.danger:hover { background:#8a1a11; }
|
||
|
||
main { padding:14px 16px 80px; max-width:1300px; margin:0 auto; }
|
||
.hint {
|
||
background:#fffbe6; border:1px solid #f1d982; color:#5c4a00;
|
||
padding:8px 12px; border-radius:6px; margin:0 0 14px; font-size:13px;
|
||
}
|
||
.hint b { color:#3d3200; }
|
||
|
||
.dept {
|
||
background:var(--panel); border:1px solid var(--line); border-radius:8px;
|
||
margin:10px 0; padding:0; overflow:hidden;
|
||
box-shadow:0 1px 3px rgba(0,0,0,.06);
|
||
}
|
||
.dept-header {
|
||
padding:10px 14px; background:#eef2f7; border-bottom:1px solid var(--line);
|
||
display:flex; gap:10px; align-items:center;
|
||
}
|
||
.dept-name { font-weight:600; font-size:15px; flex:1; outline:none; }
|
||
.dept-name:focus { background:#fff; padding:2px 6px; border-radius:4px; }
|
||
.count {
|
||
background:#fff; border:1px solid var(--line); color:var(--muted);
|
||
padding:2px 8px; border-radius:10px; font-size:12px;
|
||
}
|
||
.dept-actions { display:flex; gap:6px; }
|
||
.dept-actions button { padding:4px 8px; font-size:12px; }
|
||
|
||
.people { padding:6px 8px 10px; }
|
||
.person {
|
||
display:grid;
|
||
grid-template-columns: 18px 1.4fr 1.6fr auto auto auto auto auto auto;
|
||
gap:6px; align-items:center;
|
||
padding:6px 8px; margin:4px 0;
|
||
border:1px solid transparent; border-radius:5px;
|
||
background:var(--staff); border-color:var(--staff-line);
|
||
}
|
||
.person.caregiver { background:var(--caregiver); border-color:var(--caregiver-line); }
|
||
.person.pending { background:var(--pending); border-color:var(--pending-line); }
|
||
.person.dragging { opacity:.35; }
|
||
.person.drop-over { outline:2px dashed var(--accent); outline-offset:-2px; }
|
||
.grip {
|
||
cursor:grab; color:var(--muted); user-select:none; text-align:center;
|
||
font-size:16px; line-height:1;
|
||
}
|
||
.grip:active { cursor:grabbing; }
|
||
.field {
|
||
border:1px solid transparent; padding:4px 6px; border-radius:4px;
|
||
min-width:40px; outline:none;
|
||
}
|
||
.field:hover { border-color:#cfd6e4; background:#fff; }
|
||
.field:focus { border-color:var(--accent); background:#fff; }
|
||
.name { font-weight:600; }
|
||
.title { color:var(--muted); font-size:13px; }
|
||
|
||
.seg {
|
||
display:inline-flex; border:1px solid #c5cddb; border-radius:4px;
|
||
background:#fff; overflow:hidden; font-size:12px;
|
||
}
|
||
.seg label {
|
||
padding:3px 7px; cursor:pointer; user-select:none;
|
||
border-right:1px solid #e5e9f0;
|
||
}
|
||
.seg label:last-child { border-right:0; }
|
||
.seg input { display:none; }
|
||
.seg input:checked + span {
|
||
background:var(--accent); color:var(--accent-ink);
|
||
margin:-3px -7px; padding:3px 7px; display:inline-block;
|
||
}
|
||
|
||
.chk {
|
||
display:inline-flex; align-items:center; gap:4px;
|
||
font-size:12px; color:var(--muted); cursor:pointer; user-select:none;
|
||
padding:3px 6px; border:1px solid #c5cddb; border-radius:4px; background:#fff;
|
||
}
|
||
.chk input { accent-color:var(--accent); margin:0; }
|
||
.chk.on { color:var(--ok); border-color:#9ad6a4; background:#eefaf0; }
|
||
|
||
.dropdown-wrap { position:relative; }
|
||
select.dept-select {
|
||
font:inherit; font-size:12px; padding:4px 6px;
|
||
border:1px solid #c5cddb; border-radius:4px; background:#fff; max-width:150px;
|
||
}
|
||
.delete {
|
||
background:transparent; color:var(--warn); padding:3px 8px; font-size:16px;
|
||
line-height:1; border:1px solid transparent; border-radius:4px;
|
||
}
|
||
.delete:hover { background:#fde8e6; border-color:#f4b4ae; }
|
||
|
||
.notes {
|
||
grid-column: 2 / -1;
|
||
display:block; cursor:text;
|
||
margin-top:4px; padding:6px 8px;
|
||
border:1px dashed #cfd6e4; border-radius:4px;
|
||
background:rgba(255,255,255,.7);
|
||
font-size:12px; color:#2a3140; min-height:20px;
|
||
outline:none; white-space:pre-wrap; word-wrap:break-word;
|
||
}
|
||
.notes:hover { border-color:#9aa4b8; background:#fff; }
|
||
.notes:focus {
|
||
border-style:solid; border-color:var(--accent); background:#fff;
|
||
box-shadow:0 0 0 2px rgba(43,108,176,.15);
|
||
}
|
||
.notes:empty::before {
|
||
content: attr(data-placeholder);
|
||
color:#9aa4b8; font-style:italic;
|
||
}
|
||
.add-row {
|
||
padding:6px 8px; display:flex; gap:6px; align-items:center;
|
||
border-top:1px dashed #dbe0ea; margin-top:4px;
|
||
}
|
||
.add-row input {
|
||
flex:1; padding:5px 8px; border:1px solid #c5cddb; border-radius:4px;
|
||
font:inherit;
|
||
}
|
||
.add-row button { padding:5px 10px; font-size:13px; }
|
||
|
||
.drop-zone { min-height:24px; }
|
||
.drop-zone.drop-over {
|
||
background:var(--drop); border:2px dashed #caa300; border-radius:6px;
|
||
}
|
||
|
||
footer {
|
||
position:fixed; bottom:0; left:0; right:0;
|
||
background:#fff; border-top:1px solid var(--line);
|
||
padding:10px 16px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;
|
||
box-shadow:0 -1px 3px rgba(0,0,0,.06);
|
||
}
|
||
footer .status { color:var(--muted); font-size:12px; flex:1; }
|
||
.legend { color:var(--muted); font-size:12px; }
|
||
.legend span { display:inline-block; width:10px; height:10px; border-radius:2px; vertical-align:middle; margin:0 4px 0 10px; }
|
||
.legend .l-staff { background:var(--staff); border:1px solid var(--staff-line); }
|
||
.legend .l-cg { background:var(--caregiver); border:1px solid var(--caregiver-line); }
|
||
.legend .l-pending { background:var(--pending); border:1px solid var(--pending-line); }
|
||
|
||
@media (max-width: 900px) {
|
||
.person {
|
||
grid-template-columns: 18px 1fr auto auto;
|
||
}
|
||
.person .title, .person .seg, .person .dropdown-wrap { grid-column: 2 / -1; }
|
||
}
|
||
@media print {
|
||
header, footer, .dept-actions, .add-row, .delete, .grip { display:none !important; }
|
||
.person { break-inside:avoid; background:#fff !important; border-color:#bbb !important; }
|
||
.dept { break-inside:avoid; page-break-inside:avoid; }
|
||
.dept-header { background:#eee !important; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Cascades — Staff & Department Editor <span class="sub">(revised 2026-04-22)</span></h1>
|
||
<span class="sub" id="savedTag">loaded</span>
|
||
<button onclick="exportJSON()" title="Download everything as JSON — email this file back to Howard">Export JSON</button>
|
||
<button class="ghost" onclick="importJSON()">Import JSON</button>
|
||
<button class="ghost" onclick="window.print()">Print</button>
|
||
<button class="danger" onclick="resetAll()" title="Revert to the Howard-provided baseline">Reset</button>
|
||
</header>
|
||
|
||
<main>
|
||
<div class="hint">
|
||
<b>What changed since last time:</b> the list now matches what you sent back. A few items are still marked in <b style="color:#b42318;">red</b> — please take a look and fill in the missing pieces (spelling, title, confirmations).
|
||
<ul style="margin:6px 0 0; padding-left:22px;">
|
||
<li><b>Drag</b> the <b>⋮⋮</b> grip on any name to move them to another department.</li>
|
||
<li><b>Click</b> any name or title to edit it inline. The notes box under each name is for anything you want to tell us.</li>
|
||
<li>
|
||
<b>Access type</b> (pick one):
|
||
<b>D</b> = desktop / PC only,
|
||
<b>P</b> = phone only,
|
||
<b>D+P</b> = both a desk and a phone,
|
||
<b>—</b> = not set.
|
||
</li>
|
||
<li><b>Outside</b> = this person is allowed to sign in from outside the building (home, personal cell, travel). <b>Default is OFF for everyone</b> — leave it off to lock to Cascades only, tick it only for people who truly need off-site access.</li>
|
||
<li><b>ALIS</b> = ticks if the person logs into ALIS.</li>
|
||
<li>Use the <b>+ Add</b> row at the bottom of any department to add anyone we've missed.</li>
|
||
<li>When you're done, click <b>Export JSON</b> and email the downloaded file to Howard. (JSON is the only format that imports back in, so it's the only export option.)</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div id="board"></div>
|
||
|
||
<div style="margin-top:18px; display:flex; gap:8px; align-items:center;">
|
||
<input id="newDept" placeholder="New department name (e.g. Security, Activities)" style="flex:1; padding:8px 10px; border:1px solid var(--line); border-radius:5px; font:inherit;">
|
||
<button onclick="addDept()">+ Add department</button>
|
||
</div>
|
||
</main>
|
||
|
||
<footer>
|
||
<span class="status" id="status"> </span>
|
||
<span class="legend">
|
||
Legend: <span class="l-staff"></span>Staff <span class="l-cg"></span>Caregiver <span class="l-pending"></span>Needs answer
|
||
</span>
|
||
</footer>
|
||
|
||
<script>
|
||
// -----------------------------------------------------------------------------
|
||
// Baseline roster — 2026-04-22, after merging the returned CSV + Howard's
|
||
// live confirmations. Items flagged "pending" have notes starting with "[?]"
|
||
// which colors the row pink so Meredith/John can't miss them.
|
||
// -----------------------------------------------------------------------------
|
||
const INITIAL = {
|
||
departments: [
|
||
"Administrative","Marketing / Sales",
|
||
"Care, Assisted Living (Nursing / Clinical)","Care, Memory Care",
|
||
"Resident Services","Life Enrichment","Culinary","Maintenance",
|
||
"Housekeeping","Transportation","Caregivers (shift staff)"
|
||
],
|
||
people: [
|
||
// Administrative
|
||
["Meredith Kuhn","Executive Director","Administrative","D+P",true,true,""],
|
||
["Ashley Jensen","Assistant Executive Director","Administrative","D+P",true,true,""],
|
||
["Lauren Hasselman","Business Office Director","Administrative","D+P",true,true,""],
|
||
["Allison Reibschied","Accounting Assistant","Administrative","D+P",false,true,""],
|
||
|
||
// Marketing / Sales
|
||
["Megan Hiatt","Sales Director","Marketing / Sales","D+P",true,true,"Handles resident intake (PHI)"],
|
||
["Crystal Rodriguez","Sales Associate","Marketing / Sales","D+P",true,true,"Handles resident intake (PHI)"],
|
||
["Tamra Matthews","Move-In Coordinator","Marketing / Sales","D+P",true,true,"Leaving June 2026 — confirmed"],
|
||
|
||
// Care, Assisted Living (Nursing / Clinical)
|
||
["Lois Lane","Health Services Director","Care, Assisted Living (Nursing / Clinical)","D+P",true,true,""],
|
||
["Karen Rossini","Health Services Manager","Care, Assisted Living (Nursing / Clinical)","D+P",true,true,""],
|
||
["Veronica Feller","Care, Assisted Living Aide","Care, Assisted Living (Nursing / Clinical)","D+P",true,true,""],
|
||
|
||
// Care, Memory Care
|
||
["Shelby Trozzi","Memory Care Director","Care, Memory Care","D+P",true,true,""],
|
||
["Christine Nyanzunda","Memory Care Admin Assistant","Care, Memory Care","D+P",true,true,"Dual role — MC Admin + part-time Sun/Mon MedTech — one account covers both (confirmed)"],
|
||
|
||
// Resident Services
|
||
["Christina DuPras","Resident Services Director","Resident Services","D+P",true,true,""],
|
||
["Cathy Kingston","Receptionist","Resident Services","D",false,false,"Front desk shared PC"],
|
||
["Shontiel Nunn","Receptionist","Resident Services","D",false,false,"Front desk shared PC"],
|
||
["Kyla QuickTiffany","Receptionist","Resident Services","D",false,false,"Shared front desk. Surname spelled as one word per her preference — account will be kyla.quicktiffany@"],
|
||
["Michelle Shestko","MC Receptionist","Resident Services","D",false,false,"MC front desk shared PC"],
|
||
["Sebastian Leon","Courtesy Patrol","Resident Services","D+P",false,false,""],
|
||
["Sheldon Gardfrey","Courtesy Patrol","Resident Services","D+P",false,false,""],
|
||
["Ray Rai","Courtesy Patrol","Resident Services","D+P",false,false,""],
|
||
|
||
// Life Enrichment
|
||
["Susan Hicks","Life Enrichment Director","Life Enrichment","D+P",true,true,""],
|
||
["Sharon Edwards","Life Enrichment Assistant","Life Enrichment","D+P",false,true,""],
|
||
["Alma R Montt","Memory Care Life Enrichment","Life Enrichment","D+P",true,true,"Confirmed by John 2026-04-22: D+P, ALIS, offsite. LE staff assigned to Memory Care residents."],
|
||
|
||
// Culinary
|
||
["JD Martin","Culinary Director","Culinary","D+P",true,true,""],
|
||
["Ramon Castaneda","Kitchen Manager","Culinary","D+P",false,false,""],
|
||
["Alyssa Brooks","Dining Manager","Culinary","D+P",true,true,""],
|
||
|
||
// Maintenance
|
||
["John Trozzi","Facilities Director","Maintenance","D+P",true,true,""],
|
||
["Matt Brooks","Memory Care Receptionist / Maintenance","Maintenance","D+P",false,true,"Works in both Maintenance and MC — confirmed"],
|
||
|
||
// Housekeeping
|
||
["Lupe Sanchez","Housekeeping Director","Housekeeping","D+P",true,true,"AKA Guadalupe Sanchez"],
|
||
|
||
// Transportation — on the roster for tracking but no IT access (2026-04-22 Howard decision).
|
||
// Existing AD accounts will be disabled.
|
||
["Richard Adams","Driver","Transportation","",false,false,"No IT access — drivers use personal phones for Google Maps. Existing AD account will be disabled."],
|
||
["Julian Crim","Driver","Transportation","",false,false,"No IT access — drivers use personal phones for Google Maps. Existing AD account will be disabled."],
|
||
["Christopher Holick","Driver","Transportation","",false,false,"No IT access — drivers use personal phones for Google Maps. Existing AD account will be disabled."],
|
||
|
||
// Caregivers (shift staff) — Tue–Sat
|
||
["Thelma Abainza","Caregiver — Tower (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Niel Castro","MedTech / CCG — Tower (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Espe Esperance","MedTech — Tower (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Barbara Johnson","Caregiver — Tower (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Kasey Flores","Caregiver — Memory Care (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Richard Flores","Caregiver — Memory Care (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Marie Kastner","Caregiver — Memory Care (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Bella Mendoza","Caregiver — Memory Care (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Rosa Morales","MedTech — Memory Care (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Sandra Padilla","MedTech / CCG — Tower (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Whisper Reed","MedTech — Tower overnight (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Patricia Sandoval-Beck","MedTech — Tower (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Charity Sika","Caregiver — Memory Care (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Ederick Yuzon","Caregiver — Tower (Tue–Sat)","Caregivers (shift staff)","D+P",false,true,"[?] Confirm spelling of the first name — Ederick vs Edrick vs other?"],
|
||
|
||
// Caregivers — Sun–Thu
|
||
["Juan Andrade","Caregiver — Memory Care (Sun–Thu)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Jahmeka Clarke","MedTech — Memory Care (Sun–Thu)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Karina Aziakpo","MedTech / CCG — MC overnight (Sun–Thu)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Jinnelle Dittbenner","Caregiver — Tower (Sun–Thu)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Agnes McFerren","Caregiver — Tower (Sun–Thu)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Samuel Ramirez","Caregiver — Tower (Sun–Thu)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Erica Sanchez","Caregiver — Memory Care (Sun–Thu)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Katrina Wyzykowski","MedTech — Memory Care (Sun–Thu)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Corey Tate","Caregiver — Tower NOC (Sun–Thu)","Caregivers (shift staff)","D+P",false,true,""],
|
||
|
||
// Caregivers — Fri–Mon / weekend doubles
|
||
["Ashli Atwood","MedTech / CCG — MC overnight (Fri–Mon)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Cole Johnson","MedTech — Tower (Fri–Mon)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Roseline Cooper","Caregiver — MC overnight (Fri–Mon)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Monique Lopez","Caregiver — Tower Fri+Sat doubles","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Gloria Williford","MedTech — MC Fri+Sat doubles","Caregivers (shift staff)","D+P",false,true,""],
|
||
|
||
// Caregivers — Thu–Mon
|
||
["Sarah Carroll","Caregiver — Tower (Thu–Mon)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Luke Hogan","Caregiver — Tower (Thu–Mon)","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Gina Williams","Caregiver — Tower (Thu–Mon)","Caregivers (shift staff)","D+P",false,true,""],
|
||
|
||
// Caregivers — other patterns
|
||
["Jen Higdon","Caregiver — Tower M/W/F AM","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Mary Kariuki","Caregiver — Tower Sat–Mon + Wed PM","Caregivers (shift staff)","D+P",false,true,""],
|
||
["CeCe Lassey","Caregiver — Tower Sun/Mon doubles + Tue PM","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Patricia Camarena Doran","MedTech / CCG — Tower Sun/Mon only","Caregivers (shift staff)","D+P",false,true,"Also goes by Paty / Patti — legal name confirmed. Account will be patricia.doran@"],
|
||
|
||
// Caregivers — PRN / part-time
|
||
["Ezekiel Huerta","Caregiver PRN — Tower","Caregivers (shift staff)","D+P",false,true,""],
|
||
["Maia Baker","MedTech PRN — Memory Care","Caregivers (shift staff)","D+P",false,true,"Part-time (confirmed)"],
|
||
|
||
// Agency caregivers — no shared accounts. HIPAA review 2026-04-22: shared logins for PHI
|
||
// access violate 45 CFR 164.312(a)(2)(i) Unique User Identification. Reliable Agency must
|
||
// supply individual names before per-person accounts can be provisioned. No rows here
|
||
// until names arrive.
|
||
]
|
||
};
|
||
const CAREGIVER_DEPT = "Caregivers (shift staff)";
|
||
const STORAGE_KEY = "cascades-staff-editor-2026-04-22";
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// State
|
||
// -----------------------------------------------------------------------------
|
||
let state = pickInitialState();
|
||
|
||
function pickInitialState() {
|
||
const local = load();
|
||
return local || rebuild(INITIAL);
|
||
}
|
||
|
||
function rebuild(src) {
|
||
let pid = 1;
|
||
return {
|
||
departments: [...src.departments],
|
||
people: src.people.map(p => ({
|
||
id: pid++, name: p[0], title: p[1], dept: p[2],
|
||
access: p[3], outside: !!p[4], alis: !!p[5], notes: p[6] || ""
|
||
})),
|
||
savedAt: new Date().toISOString()
|
||
};
|
||
}
|
||
function save() {
|
||
state.savedAt = new Date().toISOString();
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||
document.getElementById("savedTag").textContent = "saved " + new Date().toLocaleTimeString();
|
||
} catch(e) { document.getElementById("savedTag").textContent = "save failed"; }
|
||
}
|
||
function load() {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
if (!raw) return null;
|
||
const obj = JSON.parse(raw);
|
||
if (!obj.departments || !obj.people) return null;
|
||
return obj;
|
||
} catch(e) { return null; }
|
||
}
|
||
function resetAll() {
|
||
if (!confirm("Reset everything back to the Howard-provided 2026-04-22 baseline? Your edits will be lost.")) return;
|
||
state = rebuild(INITIAL);
|
||
save(); render();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Rendering
|
||
// -----------------------------------------------------------------------------
|
||
function render() {
|
||
const board = document.getElementById("board");
|
||
board.innerHTML = "";
|
||
|
||
const deptSet = new Set(state.departments);
|
||
for (const p of state.people) {
|
||
if (!deptSet.has(p.dept)) {
|
||
if (!deptSet.has("Unsorted")) { state.departments.push("Unsorted"); deptSet.add("Unsorted"); }
|
||
p.dept = "Unsorted";
|
||
}
|
||
}
|
||
|
||
for (const dept of state.departments) {
|
||
const peopleInDept = state.people.filter(p => p.dept === dept);
|
||
const det = document.createElement("div");
|
||
det.className = "dept"; det.dataset.dept = dept;
|
||
|
||
const sum = document.createElement("div");
|
||
sum.className = "dept-header";
|
||
sum.innerHTML = `
|
||
<span class="dept-name" contenteditable spellcheck="false"
|
||
onblur="renameDept(this, '${escapeAttr(dept)}')"
|
||
onkeydown="if(event.key==='Enter'){event.preventDefault();this.blur();}">${escapeHTML(dept)}</span>
|
||
<span class="count">${peopleInDept.length}</span>
|
||
<span class="dept-actions">
|
||
<button onclick="moveDeptUp('${escapeAttr(dept)}')" title="Move up">↑</button>
|
||
<button onclick="moveDeptDown('${escapeAttr(dept)}')" title="Move down">↓</button>
|
||
<button class="danger" onclick="deleteDept('${escapeAttr(dept)}')" title="Delete department (moves members to Unsorted)">✕</button>
|
||
</span>`;
|
||
det.appendChild(sum);
|
||
|
||
const list = document.createElement("div");
|
||
list.className = "people drop-zone";
|
||
list.dataset.dept = dept;
|
||
list.addEventListener("dragover", onDragOver);
|
||
list.addEventListener("dragleave", onDragLeave);
|
||
list.addEventListener("drop", onDrop);
|
||
sum.addEventListener("dragover", onDragOver);
|
||
sum.addEventListener("dragleave", onDragLeave);
|
||
sum.addEventListener("drop", onDrop);
|
||
|
||
for (const p of peopleInDept) list.appendChild(personRow(p));
|
||
|
||
const add = document.createElement("div");
|
||
add.className = "add-row";
|
||
add.innerHTML = `
|
||
<input placeholder="New person — name" data-field="name">
|
||
<input placeholder="Title / role" data-field="title" style="flex:0.8;">
|
||
<button>+ Add</button>`;
|
||
const btn = add.querySelector("button");
|
||
btn.onclick = () => {
|
||
const name = add.querySelector("[data-field=name]").value.trim();
|
||
const title = add.querySelector("[data-field=title]").value.trim();
|
||
if (!name) return;
|
||
state.people.push({
|
||
id: nextId(), name, title, dept,
|
||
access: "D+P",
|
||
outside: false, alis: false, notes: ""
|
||
});
|
||
save(); render();
|
||
};
|
||
list.appendChild(add);
|
||
det.appendChild(list);
|
||
board.appendChild(det);
|
||
}
|
||
updateStatus();
|
||
}
|
||
|
||
function personRow(p) {
|
||
const row = document.createElement("div");
|
||
const isPending = (p.notes || "").trim().startsWith("[?]");
|
||
row.className = "person"
|
||
+ (p.dept === CAREGIVER_DEPT ? " caregiver" : "")
|
||
+ (isPending ? " pending" : "");
|
||
row.dataset.id = p.id;
|
||
|
||
row.innerHTML = `
|
||
<span class="grip" draggable="true" title="Drag to move this person to another department">⋮⋮</span>
|
||
<span class="field name" contenteditable spellcheck="false"
|
||
data-id="${p.id}" data-k="name">${escapeHTML(p.name)}</span>
|
||
<span class="field title" contenteditable spellcheck="false"
|
||
data-id="${p.id}" data-k="title">${escapeHTML(p.title)}</span>
|
||
<span class="seg" title="Access type">
|
||
${accessSeg(p)}
|
||
</span>
|
||
<label class="chk ${p.outside ? "on" : ""}" title="Allowed to sign in outside the building">
|
||
<input type="checkbox" ${p.outside ? "checked" : ""} data-id="${p.id}" data-k="outside">Outside
|
||
</label>
|
||
<label class="chk ${p.alis ? "on" : ""}" title="Uses ALIS">
|
||
<input type="checkbox" ${p.alis ? "checked" : ""} data-id="${p.id}" data-k="alis">ALIS
|
||
</label>
|
||
<span class="dropdown-wrap">
|
||
<select class="dept-select" data-id="${p.id}" title="Move to another department">
|
||
${state.departments.map(d => `<option ${d===p.dept?"selected":""}>${escapeHTML(d)}</option>`).join("")}
|
||
</select>
|
||
</span>
|
||
<button class="delete" title="Remove this person" data-id="${p.id}">✕</button>
|
||
<span class="notes" contenteditable spellcheck="true"
|
||
data-id="${p.id}" data-k="notes"
|
||
data-placeholder="Click to add notes about this person — spelling, department conflicts, schedule, etc.">${escapeHTML(p.notes)}</span>
|
||
`;
|
||
|
||
row.querySelectorAll("[contenteditable]").forEach(el => {
|
||
el.addEventListener("blur", onFieldBlur);
|
||
el.addEventListener("keydown", e => {
|
||
if (e.key === "Enter" && !el.classList.contains("notes")) {
|
||
e.preventDefault(); el.blur();
|
||
}
|
||
});
|
||
});
|
||
row.querySelectorAll(".notes").forEach(el => {
|
||
el.addEventListener("mousedown", onNotesMouseDown);
|
||
});
|
||
row.querySelectorAll('input[type="radio"]').forEach(el => el.addEventListener("change", onAccessChange));
|
||
row.querySelectorAll('input[type="checkbox"]').forEach(el => el.addEventListener("change", onToggle));
|
||
row.querySelector("select.dept-select").addEventListener("change", onDeptSelect);
|
||
row.querySelector("button.delete").addEventListener("click", onDelete);
|
||
|
||
const grip = row.querySelector(".grip");
|
||
grip.addEventListener("dragstart", onDragStart);
|
||
grip.addEventListener("dragend", onDragEnd);
|
||
|
||
return row;
|
||
}
|
||
|
||
function accessSeg(p) {
|
||
const opts = [
|
||
{v:"D", label:"D", title:"Desktop / PC only"},
|
||
{v:"P", label:"P", title:"Phone only"},
|
||
{v:"D+P", label:"D+P", title:"Both desktop and phone"},
|
||
{v:"", label:"—", title:"Not set / unknown"}
|
||
];
|
||
return opts.map(o => `
|
||
<label title="${o.title}">
|
||
<input type="radio" name="acc-${p.id}" value="${o.v}" ${p.access===o.v?"checked":""} data-id="${p.id}" data-k="access">
|
||
<span>${o.label}</span>
|
||
</label>`).join("");
|
||
}
|
||
|
||
function updateStatus() {
|
||
const total = state.people.length;
|
||
const staff = state.people.filter(p => p.dept !== CAREGIVER_DEPT).length;
|
||
const cg = total - staff;
|
||
const outside = state.people.filter(p => p.outside).length;
|
||
const alis = state.people.filter(p => p.alis).length;
|
||
const pending = state.people.filter(p => (p.notes || "").trim().startsWith("[?]")).length;
|
||
const pendingText = pending ? ` · ${pending} still need answer` : "";
|
||
document.getElementById("status").textContent =
|
||
`${total} people (${staff} staff + ${cg} caregiver) · ${outside} outside-access · ${alis} ALIS · ${state.departments.length} departments${pendingText}`;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Field handlers
|
||
// -----------------------------------------------------------------------------
|
||
function onFieldBlur(e) {
|
||
const id = +e.target.dataset.id, k = e.target.dataset.k;
|
||
const p = state.people.find(x => x.id === id); if (!p) return;
|
||
const v = e.target.textContent.trim();
|
||
if (p[k] !== v) {
|
||
p[k] = v;
|
||
save();
|
||
if (k === "notes") render(); else updateStatus();
|
||
}
|
||
}
|
||
function onAccessChange(e) {
|
||
const id = +e.target.dataset.id;
|
||
const p = state.people.find(x => x.id === id); if (!p) return;
|
||
p.access = e.target.value; save();
|
||
}
|
||
function onToggle(e) {
|
||
const id = +e.target.dataset.id, k = e.target.dataset.k;
|
||
const p = state.people.find(x => x.id === id); if (!p) return;
|
||
p[k] = e.target.checked;
|
||
e.target.closest("label").classList.toggle("on", e.target.checked);
|
||
save(); updateStatus();
|
||
}
|
||
function onDeptSelect(e) {
|
||
const id = +e.target.dataset.id;
|
||
const p = state.people.find(x => x.id === id); if (!p) return;
|
||
p.dept = e.target.value; save(); render();
|
||
}
|
||
function onDelete(e) {
|
||
const id = +e.currentTarget.dataset.id;
|
||
const p = state.people.find(x => x.id === id); if (!p) return;
|
||
if (!confirm(`Remove ${p.name} from the list? (You can add them back later.)`)) return;
|
||
state.people = state.people.filter(x => x.id !== id);
|
||
save(); render();
|
||
}
|
||
|
||
function renameDept(el, oldName) {
|
||
const newName = el.textContent.trim();
|
||
if (!newName || newName === oldName) { el.textContent = oldName; return; }
|
||
if (state.departments.includes(newName)) {
|
||
alert("A department with that name already exists."); el.textContent = oldName; return;
|
||
}
|
||
const i = state.departments.indexOf(oldName);
|
||
if (i >= 0) state.departments[i] = newName;
|
||
for (const p of state.people) if (p.dept === oldName) p.dept = newName;
|
||
save(); render();
|
||
}
|
||
function moveDeptUp(name) {
|
||
const i = state.departments.indexOf(name); if (i <= 0) return;
|
||
[state.departments[i-1], state.departments[i]] = [state.departments[i], state.departments[i-1]];
|
||
save(); render();
|
||
}
|
||
function moveDeptDown(name) {
|
||
const i = state.departments.indexOf(name); if (i < 0 || i >= state.departments.length-1) return;
|
||
[state.departments[i+1], state.departments[i]] = [state.departments[i], state.departments[i+1]];
|
||
save(); render();
|
||
}
|
||
function deleteDept(name) {
|
||
const n = state.people.filter(p => p.dept === name).length;
|
||
if (!confirm(`Delete department "${name}"?` + (n ? ` ${n} person(s) will move to Unsorted.` : ""))) return;
|
||
state.departments = state.departments.filter(d => d !== name);
|
||
for (const p of state.people) if (p.dept === name) p.dept = "Unsorted";
|
||
if (state.people.some(p => p.dept === "Unsorted") && !state.departments.includes("Unsorted"))
|
||
state.departments.push("Unsorted");
|
||
save(); render();
|
||
}
|
||
function addDept() {
|
||
const el = document.getElementById("newDept");
|
||
const name = el.value.trim();
|
||
if (!name) return;
|
||
if (state.departments.includes(name)) { alert("That department already exists."); return; }
|
||
state.departments.push(name); el.value = "";
|
||
save(); render();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Drag & drop
|
||
// -----------------------------------------------------------------------------
|
||
let dragId = null;
|
||
function onDragStart(e) {
|
||
const row = e.currentTarget.closest(".person");
|
||
if (!row) return;
|
||
dragId = +row.dataset.id;
|
||
row.classList.add("dragging");
|
||
e.dataTransfer.effectAllowed = "move";
|
||
try { e.dataTransfer.setData("text/plain", String(dragId)); } catch(_) {}
|
||
try { e.dataTransfer.setDragImage(row, 20, 10); } catch(_) {}
|
||
}
|
||
function onDragEnd(e) {
|
||
const row = e.currentTarget.closest(".person");
|
||
if (row) row.classList.remove("dragging");
|
||
document.querySelectorAll(".drop-over").forEach(n => n.classList.remove("drop-over"));
|
||
dragId = null;
|
||
}
|
||
function onDragOver(e) {
|
||
e.preventDefault();
|
||
const zone = e.currentTarget;
|
||
zone.classList.add("drop-over");
|
||
e.dataTransfer.dropEffect = "move";
|
||
}
|
||
function onDragLeave(e) {
|
||
e.currentTarget.classList.remove("drop-over");
|
||
}
|
||
function onDrop(e) {
|
||
e.preventDefault();
|
||
const zone = e.currentTarget;
|
||
zone.classList.remove("drop-over");
|
||
const dept = zone.dataset.dept || zone.closest(".dept")?.dataset.dept;
|
||
if (!dept) return;
|
||
const id = dragId || +e.dataTransfer.getData("text/plain");
|
||
const p = state.people.find(x => x.id === id); if (!p) return;
|
||
if (p.dept === dept) return;
|
||
p.dept = dept; save(); render();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Export / import — JSON only (it's the only format we can import back in)
|
||
// -----------------------------------------------------------------------------
|
||
function exportJSON() {
|
||
const data = JSON.stringify(state, null, 2);
|
||
downloadBlob(`cascades-staff-${stamp()}.json`, data, "application/json");
|
||
}
|
||
function importJSON() {
|
||
const inp = document.createElement("input");
|
||
inp.type = "file"; inp.accept = ".json,application/json";
|
||
inp.onchange = () => {
|
||
const f = inp.files[0]; if (!f) return;
|
||
const r = new FileReader();
|
||
r.onload = () => {
|
||
try {
|
||
const obj = JSON.parse(r.result);
|
||
if (!obj.departments || !obj.people) throw new Error("Missing fields");
|
||
state = obj; save(); render();
|
||
alert("Imported OK.");
|
||
} catch(e) { alert("Could not read that file: " + e.message); }
|
||
};
|
||
r.readAsText(f);
|
||
};
|
||
inp.click();
|
||
}
|
||
function downloadBlob(name, data, mime) {
|
||
const blob = new Blob([data], {type: mime});
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob); a.download = name;
|
||
document.body.appendChild(a); a.click();
|
||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 0);
|
||
}
|
||
function stamp() {
|
||
const d = new Date(), p = n => String(n).padStart(2,"0");
|
||
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Utilities
|
||
// -----------------------------------------------------------------------------
|
||
function onNotesMouseDown(e) {
|
||
const el = e.currentTarget;
|
||
let inside = false;
|
||
if (document.caretPositionFromPoint) {
|
||
const p = document.caretPositionFromPoint(e.clientX, e.clientY);
|
||
if (p && el.contains(p.offsetNode)) inside = true;
|
||
} else if (document.caretRangeFromPoint) {
|
||
const r = document.caretRangeFromPoint(e.clientX, e.clientY);
|
||
if (r && el.contains(r.startContainer)) inside = true;
|
||
}
|
||
if (!inside) {
|
||
e.preventDefault();
|
||
el.focus();
|
||
const r = document.createRange();
|
||
r.selectNodeContents(el);
|
||
r.collapse(false);
|
||
const sel = window.getSelection();
|
||
sel.removeAllRanges();
|
||
sel.addRange(r);
|
||
}
|
||
}
|
||
|
||
function nextId() { return Math.max(0, ...state.people.map(p => p.id)) + 1; }
|
||
function escapeHTML(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||
function escapeAttr(s) { return String(s ?? "").replace(/'/g, "\\'"); }
|
||
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|