Files
claudetools/clients/cascades-tucson/docs/cloud/questionnaires/cascades-staff-editor-2026-04-22.html
Howard Enos 6bd416657c sync: auto-sync from HOWARD-HOME at 2026-04-22 17:39:56
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-04-22 17:39:56
2026-04-22 17:39:57 -07:00

736 lines
33 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Cascades — Staff &amp; 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 &amp; 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">&nbsp;</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) — TueSat
["Thelma Abainza","Caregiver — Tower (TueSat)","Caregivers (shift staff)","D+P",false,true,""],
["Niel Castro","MedTech / CCG — Tower (TueSat)","Caregivers (shift staff)","D+P",false,true,""],
["Espe Esperance","MedTech — Tower (TueSat)","Caregivers (shift staff)","D+P",false,true,""],
["Barbara Johnson","Caregiver — Tower (TueSat)","Caregivers (shift staff)","D+P",false,true,""],
["Kasey Flores","Caregiver — Memory Care (TueSat)","Caregivers (shift staff)","D+P",false,true,""],
["Richard Flores","Caregiver — Memory Care (TueSat)","Caregivers (shift staff)","D+P",false,true,""],
["Marie Kastner","Caregiver — Memory Care (TueSat)","Caregivers (shift staff)","D+P",false,true,""],
["Bella Mendoza","Caregiver — Memory Care (TueSat)","Caregivers (shift staff)","D+P",false,true,""],
["Rosa Morales","MedTech — Memory Care (TueSat)","Caregivers (shift staff)","D+P",false,true,""],
["Sandra Padilla","MedTech / CCG — Tower (TueSat)","Caregivers (shift staff)","D+P",false,true,""],
["Whisper Reed","MedTech — Tower overnight (TueSat)","Caregivers (shift staff)","D+P",false,true,""],
["Patricia Sandoval-Beck","MedTech — Tower (TueSat)","Caregivers (shift staff)","D+P",false,true,""],
["Charity Sika","Caregiver — Memory Care (TueSat)","Caregivers (shift staff)","D+P",false,true,""],
["Ederick Yuzon","Caregiver — Tower (TueSat)","Caregivers (shift staff)","D+P",false,true,"[?] Confirm spelling of the first name — Ederick vs Edrick vs other?"],
// Caregivers — SunThu
["Juan Andrade","Caregiver — Memory Care (SunThu)","Caregivers (shift staff)","D+P",false,true,""],
["Jahmeka Clarke","MedTech — Memory Care (SunThu)","Caregivers (shift staff)","D+P",false,true,""],
["Karina Aziakpo","MedTech / CCG — MC overnight (SunThu)","Caregivers (shift staff)","D+P",false,true,""],
["Jinnelle Dittbenner","Caregiver — Tower (SunThu)","Caregivers (shift staff)","D+P",false,true,""],
["Agnes McFerren","Caregiver — Tower (SunThu)","Caregivers (shift staff)","D+P",false,true,""],
["Samuel Ramirez","Caregiver — Tower (SunThu)","Caregivers (shift staff)","D+P",false,true,""],
["Erica Sanchez","Caregiver — Memory Care (SunThu)","Caregivers (shift staff)","D+P",false,true,""],
["Katrina Wyzykowski","MedTech — Memory Care (SunThu)","Caregivers (shift staff)","D+P",false,true,""],
["Corey Tate","Caregiver — Tower NOC (SunThu)","Caregivers (shift staff)","D+P",false,true,""],
// Caregivers — FriMon / weekend doubles
["Ashli Atwood","MedTech / CCG — MC overnight (FriMon)","Caregivers (shift staff)","D+P",false,true,""],
["Cole Johnson","MedTech — Tower (FriMon)","Caregivers (shift staff)","D+P",false,true,""],
["Roseline Cooper","Caregiver — MC overnight (FriMon)","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 — ThuMon
["Sarah Carroll","Caregiver — Tower (ThuMon)","Caregivers (shift staff)","D+P",false,true,""],
["Luke Hogan","Caregiver — Tower (ThuMon)","Caregivers (shift staff)","D+P",false,true,""],
["Gina Williams","Caregiver — Tower (ThuMon)","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 SatMon + 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function escapeAttr(s) { return String(s ?? "").replace(/'/g, "\\'"); }
render();
</script>
</body>
</html>