Files
claudetools/clients/cascades-tucson/docs/cloud/questionnaires/cascades-staff-editor.html
Howard Enos c4fdb5a233 sync: auto-sync from ACG-TECH03L at 2026-04-19 12:50:13
Author: Howard Enos
Machine: ACG-TECH03L
Timestamp: 2026-04-19 12:50:13
2026-04-19 12:50:24 -07:00

774 lines
35 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</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;
}
* { 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.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); }
@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</h1>
<span class="sub" id="savedTag">loaded</span>
<button onclick="saveToFile()" title="Download an updated copy of this HTML file with all your edits baked in. Works on any machine — just open the downloaded file." style="background:#15803d;">Save to File</button>
<button onclick="exportJSON()" title="Download everything as JSON">Export JSON</button>
<button onclick="exportCSV()" title="Download as CSV for spreadsheet">Export CSV</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 original roster">Reset</button>
</header>
<main>
<div class="hint">
<b>How to use:</b>
<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 — drop on the department header or anywhere in its list.</li>
<li><b>Click</b> any name or title to edit it inline.</li>
<li><b>Notes box</b> under each name — click it to add spelling concerns, department conflicts, schedule notes, or anything else we should know about that person.</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 shared phone,
<b></b> = not set / leave blank if you're not sure and we'll decide together.
</li>
<li><b>Outside</b> is a <em>separate</em> checkbox — tick it if the person is allowed to sign in from outside the building (home, personal cell, travel). Leave it unchecked to lock them to Cascades. Works with any access type.</li>
<li><b>ALIS</b> is a <em>separate</em> checkbox — tick it for anyone who logs into ALIS.</li>
<li>Use the <b>+ Add</b> row at the bottom of a department to add a person we've missed.</li>
</ul>
</div>
</p>
<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>
</footer>
<script>
// -----------------------------------------------------------------------------
// Initial roster — mirrors clients/cascades-tucson/scripts/generate-user-questionnaire.py
// -----------------------------------------------------------------------------
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: [
// Staff
["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",true,false,""],
["Allison Reibschied","Accounting Assistant","Administrative","D",false,false,""],
["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 — confirm"],
["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,""],
["Britney Thompson","Memory Care Nurse","Care, Assisted Living (Nursing / Clinical)","D+P",true,true,"Title says Memory Care — which department?"],
["Veronica Feller","Care, Assisted Living Aide","Care, Assisted Living (Nursing / Clinical)","P",false,true,""],
["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,"Also on caregiver list — same person?"],
["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 Quick Tiffany","Receptionist","Resident Services","D",false,false,"Is the spelling correct? Three separate names, or is it 'Quick-Tiffany' with a hyphen?"],
["Michelle Shestko","MC Receptionist","Resident Services","D",false,false,"MC front desk shared PC"],
["Sebastian Leon","Courtesy Patrol","Resident Services","P",false,false,""],
["Sheldon Gardfrey","Courtesy Patrol","Resident Services","P",false,false,""],
["Ray Rai","Courtesy Patrol","Resident Services","P",false,false,""],
["Susan Hicks","Life Enrichment Director","Life Enrichment","D",true,false,""],
["Sharon Edwards","Life Enrichment Assistant","Life Enrichment","D",false,false,""],
["JD Martin","Culinary Director","Culinary","D+P",false,false,""],
["Ramon Castaneda","Kitchen Manager","Culinary","D+P",false,false,""],
["Alyssa Brooks","Dining Manager","Culinary","D+P",false,false,""],
["John Trozzi","Maintenance Director","Maintenance","D+P",true,false,""],
["Matt Brooks","Memory Care Receptionist","Maintenance","D+P",false,true,"HR says Maintenance — which is correct?"],
["Lupe Sanchez","Housekeeping Director","Housekeeping","D+P",false,false,"AKA Guadalupe Sanchez"],
["Richard Adams","Driver","Transportation","P",false,false,""],
["Julian Crim","Driver","Transportation","P",false,false,""],
["Christopher Holick","Driver","Transportation","P",false,false,""],
// Caregivers (default P, no outside, no ALIS — flag ones that differ)
["Thelma Abainza","Caregiver — Tower (TueSat)","Caregivers (shift staff)","P",false,false,""],
["Niel Castro","MedTech / CCG — Tower (TueSat)","Caregivers (shift staff)","P",false,false,""],
["Espe Esperance","MedTech — Tower (TueSat)","Caregivers (shift staff)","P",false,false,""],
["Barbara Johnson","Caregiver — Tower (TueSat)","Caregivers (shift staff)","P",false,false,""],
["Kasey Flores","Caregiver — Memory Care (TueSat)","Caregivers (shift staff)","P",false,false,""],
["Richard Flores","Caregiver — Memory Care (TueSat)","Caregivers (shift staff)","P",false,false,""],
["Marie Kastner","Caregiver — Memory Care (TueSat)","Caregivers (shift staff)","P",false,false,""],
["Bella Mendoza","Caregiver — Memory Care (TueSat)","Caregivers (shift staff)","P",false,false,""],
["Rosa Morales","MedTech — Memory Care (TueSat)","Caregivers (shift staff)","P",false,false,""],
["Sandra Padilla","MedTech / CCG — Tower (TueSat)","Caregivers (shift staff)","P",false,false,""],
["Polett Pinazavala","MedTech — Memory Care (TueSat)","Caregivers (shift staff)","P",false,false,"Confirm spelling"],
["Whisper Reed","MedTech — Tower overnight (TueSat)","Caregivers (shift staff)","P",false,false,""],
["Patricia Sandoval-Beck","MedTech — Tower (TueSat)","Caregivers (shift staff)","P",false,false,"Hyphenated last name — correct?"],
["Charity Sika","Caregiver — Memory Care (TueSat)","Caregivers (shift staff)","P",false,false,""],
["Ederick Yuzon","Caregiver — Tower (TueSat)","Caregivers (shift staff)","P",false,false,"Confirm spelling"],
["Juan Andrade","Caregiver — Memory Care (SunThu)","Caregivers (shift staff)","P",false,false,""],
["Jahmeka Clarke","MedTech — Memory Care (SunThu)","Caregivers (shift staff)","P",false,false,""],
["Karina Aziakpo","MedTech / CCG — MC overnight (SunThu)","Caregivers (shift staff)","P",false,false,""],
["Jinnelle Dittbenner","Caregiver — Tower (SunThu)","Caregivers (shift staff)","P",false,false,""],
["Agnes McFerren","Caregiver — Tower (SunThu)","Caregivers (shift staff)","P",false,false,""],
["Samuel Ramirez","Caregiver — Tower (SunThu)","Caregivers (shift staff)","P",false,false,""],
["Erica Sanchez","Caregiver — Memory Care (SunThu)","Caregivers (shift staff)","P",false,false,""],
["Katrina Wyzykowski","MedTech — Memory Care (SunThu)","Caregivers (shift staff)","P",false,false,""],
["Corey Tate","Caregiver — Tower NOC (SunThu)","Caregivers (shift staff)","P",false,false,""],
["Ashli Atwood","MedTech / CCG — MC overnight (FriMon)","Caregivers (shift staff)","P",false,false,""],
["Cole Johnson","MedTech — Tower (FriMon)","Caregivers (shift staff)","P",false,false,""],
["Roseline Cooper","Caregiver — MC overnight (FriMon)","Caregivers (shift staff)","P",false,false,""],
["Monique Lopez","Caregiver — Tower Fri+Sat doubles","Caregivers (shift staff)","P",false,false,""],
["Gloria Williford","MedTech — MC Fri+Sat doubles","Caregivers (shift staff)","P",false,false,""],
["Sarah Carroll","Caregiver — Tower (ThuMon)","Caregivers (shift staff)","P",false,false,""],
["Luke Hogan","Caregiver — Tower (ThuMon)","Caregivers (shift staff)","P",false,false,""],
["Gina Williams","Caregiver — Tower (ThuMon)","Caregivers (shift staff)","P",false,false,""],
["Jen Higdon","Caregiver — Tower M/W/F AM","Caregivers (shift staff)","P",false,false,""],
["Mary Kariuki","Caregiver — Tower SatMon + Wed PM","Caregivers (shift staff)","P",false,false,""],
["CeCe Lassey","Caregiver — Tower Sun/Mon doubles + Tue PM","Caregivers (shift staff)","P",false,false,""],
["Paty Doran","MedTech / CCG — Tower Sun/Mon only","Caregivers (shift staff)","P",false,false,"Paty, Patti, or Patricia?"],
["Ezekiel Huerta","Caregiver PRN — Tower","Caregivers (shift staff)","P",false,false,""],
["Maia Baker","MedTech PRN — Memory Care","Caregivers (shift staff)","P",false,false,"Is she still employed?"],
]
};
const CAREGIVER_DEPT = "Caregivers (shift staff)";
const STORAGE_KEY = "cascades-staff-editor-v2";
// -----------------------------------------------------------------------------
// State
// -----------------------------------------------------------------------------
let state = pickInitialState();
function pickInitialState() {
// Priority: baked-in state from a previous "Save to File" (if it's the newest),
// then this-browser localStorage, then the original roster.
const embedded = (typeof window !== "undefined" && window.__SAVED_STATE__) || null;
const local = load();
if (embedded && local) {
const emT = new Date(embedded.savedAt || 0).getTime();
const loT = new Date(local.savedAt || 0).getTime();
return emT >= loT ? embedded : local;
}
return embedded || 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 original Howard-provided roster? Your edits will be lost.")) return;
state = rebuild(INITIAL);
save(); render();
}
// -----------------------------------------------------------------------------
// Rendering
// -----------------------------------------------------------------------------
function render() {
const board = document.getElementById("board");
board.innerHTML = "";
// Ensure every person's dept still exists; if not, put them in an Unsorted dept
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));
// Add-person row
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;
const isCg = dept === CAREGIVER_DEPT;
state.people.push({
id: nextId(), name, title, dept,
access: isCg ? "P" : "D+P",
outside: !isCg, alis: false, notes: ""
});
save(); render();
};
list.appendChild(add);
det.appendChild(list);
board.appendChild(det);
}
updateStatus();
}
function personRow(p) {
const row = document.createElement("div");
row.className = "person" + (p.dept === CAREGIVER_DEPT ? " caregiver" : "");
row.dataset.id = p.id;
// Only the grip handle is draggable — a draggable row would steal mousedown
// from the contenteditable fields and break clicking inside the notes.
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>
`;
// Wire handlers (delegation would work too but this keeps it local)
row.querySelectorAll("[contenteditable]").forEach(el => {
el.addEventListener("blur", onFieldBlur);
el.addEventListener("keydown", e => {
// Enter blurs name/title, but in notes we allow multi-line
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);
// Grip is the only drag initiator
const grip = row.querySelector(".grip");
grip.addEventListener("dragstart", onDragStart);
grip.addEventListener("dragend", onDragEnd);
return row;
}
function accessSeg(p) {
// D / P / D+P are the real values. "—" is a clear / not-yet-decided option.
// Users can also click a selected button again to deselect it.
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;
document.getElementById("status").textContent =
`${total} people (${staff} staff + ${cg} caregiver) · ${outside} outside-access · ${alis} ALIS · ${state.departments.length} departments`;
}
// -----------------------------------------------------------------------------
// 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(); 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
// -----------------------------------------------------------------------------
function saveToFile() {
// Timestamp the state and persist to localStorage as well
state.savedAt = new Date().toISOString();
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch(_) {}
// Build a clone of the document, inject/update the embedded-state <script>,
// strip transient UI bits, and serialize.
const clone = document.documentElement.cloneNode(true);
const headClone = clone.querySelector("head");
let sEl = clone.querySelector("#saved-state");
if (!sEl) {
sEl = document.createElement("script");
sEl.id = "saved-state";
headClone.appendChild(sEl);
}
// Escape any close-script token that might appear in a notes field
const json = JSON.stringify(state).replace(/<\/scr(?=ipt)/gi, "<\\/scr");
sEl.textContent = "window.__SAVED_STATE__ = " + json + ";";
// Empty the rendered board — it's rebuilt from state on load, so we don't
// need to carry the DOM. Also clear any transient input/UI state.
const bc = clone.querySelector("#board"); if (bc) bc.innerHTML = "";
const nd = clone.querySelector("#newDept"); if (nd) nd.removeAttribute("value");
const st = clone.querySelector("#savedTag"); if (st) st.textContent = "loaded";
clone.querySelectorAll(".dragging, .drop-over").forEach(n => {
n.classList.remove("dragging"); n.classList.remove("drop-over");
});
const html = "<!doctype html>\n" + clone.outerHTML;
downloadBlob("cascades-staff-editor.html", html, "text/html");
document.getElementById("savedTag").textContent = "file saved " + new Date().toLocaleTimeString();
}
function exportJSON() {
const data = JSON.stringify(state, null, 2);
downloadBlob(`cascades-staff-${stamp()}.json`, data, "application/json");
}
function exportCSV() {
const cols = ["Department","Name","Title / Role","Access","Outside Access","ALIS","Notes"];
const rows = [cols];
for (const dept of state.departments) {
for (const p of state.people.filter(x => x.dept === dept)) {
rows.push([dept, p.name, p.title, p.access,
p.outside ? "Y":"N", p.alis ? "Y":"N", p.notes || ""]);
}
}
const csv = rows.map(r => r.map(csvEscape).join(",")).join("\r\n");
downloadBlob(`cascades-staff-${stamp()}.csv`, csv, "text/csv");
}
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 csvEscape(v) {
v = String(v ?? "");
return /[",\r\n]/.test(v) ? `"${v.replace(/"/g,'""')}"` : v;
}
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) {
// If the click doesn't land on a text position inside this element
// (e.g. the user clicked past the end of the sentence or in the padding),
// place the caret at the end so typing and backspace just work.
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, "\\'"); }
// -----------------------------------------------------------------------------
// Go
// -----------------------------------------------------------------------------
render();
</script>
</body>
</html>