Add settings dialog and first-visit setup prompt
A gear button and a setup prompt edit the saved settings, with name suggestions, and apply them without a reload. Panels and status columns can be hidden; the closed watcher and ding keep running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9479b122fa
commit
53c519ed62
@ -519,6 +519,122 @@ body > footer p + p { margin-top: 4px; }
|
|||||||
.verify td, .verify th { text-align: left; }
|
.verify td, .verify th { text-align: left; }
|
||||||
.verify td.subject-cell { white-space: normal; min-width: 260px; }
|
.verify td.subject-cell { white-space: normal; min-width: 260px; }
|
||||||
|
|
||||||
|
/* ---- settings ---- */
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
max-width: 640px;
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
padding: 0;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
dialog::backdrop { background: hsla(210, 18%, 4%, 0.7); }
|
||||||
|
|
||||||
|
.settings-head { padding: 20px 24px 4px; }
|
||||||
|
.settings-head h2 { margin: 0; font-size: 17px; font-weight: 600; }
|
||||||
|
.settings-head p { margin: 6px 0 0; color: var(--muted); font-size: 14px; }
|
||||||
|
|
||||||
|
.settings-body { padding: 4px 24px; }
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
legend, .field > label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.field + .field { margin-top: 14px; }
|
||||||
|
.hint { margin-top: 4px; font-size: 12px; color: var(--faint); }
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
background: var(--ground);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.chips:focus-within { border-color: var(--signal); }
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 4px 2px 9px;
|
||||||
|
background: var(--card-raised);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.chip button {
|
||||||
|
padding: 0 5px;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.chip button:hover { background: none; color: var(--alert); }
|
||||||
|
.chips input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row { display: flex; flex-wrap: wrap; gap: 14px 24px; }
|
||||||
|
.row .field + .field { margin-top: 0; }
|
||||||
|
|
||||||
|
input[type="number"], select {
|
||||||
|
padding: 7px 9px;
|
||||||
|
background: var(--ground);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
input[type="number"] { width: 90px; }
|
||||||
|
input[type="number"]:focus, select:focus { outline: none; border-color: var(--signal); }
|
||||||
|
|
||||||
|
.checks { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 14px; }
|
||||||
|
.checks label { display: flex; align-items: center; gap: 7px; cursor: pointer; }
|
||||||
|
.checks input { accent-color: var(--signal); }
|
||||||
|
|
||||||
|
.settings-error {
|
||||||
|
margin: 16px 24px 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-left: 3px solid var(--alert);
|
||||||
|
background: var(--ground);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-foot {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.settings-foot .spacer { flex: 1; }
|
||||||
|
button.secondary { background: var(--card-raised); color: var(--ink); border: 1px solid var(--rule); }
|
||||||
|
button.secondary:hover { background: var(--rule); }
|
||||||
|
button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.board.solo { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
|
||||||
@media (max-width: 1040px) {
|
@media (max-width: 1040px) {
|
||||||
.board { grid-template-columns: minmax(0, 1fr); }
|
.board { grid-template-columns: minmax(0, 1fr); }
|
||||||
.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
@ -552,6 +668,7 @@ body > footer p + p { margin-top: 4px; }
|
|||||||
<span class="track"></span>Sound on close
|
<span class="track"></span>Sound on close
|
||||||
</button>
|
</button>
|
||||||
<button class="quiet" id="checkBtn" onclick="checkNow(this)" hidden>Refresh now</button>
|
<button class="quiet" id="checkBtn" onclick="checkNow(this)" hidden>Refresh now</button>
|
||||||
|
<button class="quiet" id="settingsBtn" onclick="openSettings('edit')" hidden>⚙ Settings</button>
|
||||||
<span class="user" id="user" hidden></span>
|
<span class="user" id="user" hidden></span>
|
||||||
<button class="quiet" id="signOutBtn" onclick="signOut(this)" hidden>Sign out</button>
|
<button class="quiet" id="signOutBtn" onclick="signOut(this)" hidden>Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
@ -562,6 +679,8 @@ body > footer p + p { margin-top: 4px; }
|
|||||||
<div id="main"></div>
|
<div id="main"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<dialog id="settings" aria-labelledby="settingsTitle"></dialog>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
@ -602,6 +721,8 @@ let flashTechs = new Set();
|
|||||||
// the browser builds the closed feed now; the server only answers each check with the user's token
|
// the browser builds the closed feed now; the server only answers each check with the user's token
|
||||||
const feed = new Map();
|
const feed = new Map();
|
||||||
let feedReady = false;
|
let feedReady = false;
|
||||||
|
// bumped when settings change, so a check that started under the old settings is dropped
|
||||||
|
let generation = 0;
|
||||||
let timers = [];
|
let timers = [];
|
||||||
let signInProblem = null;
|
let signInProblem = null;
|
||||||
|
|
||||||
@ -675,20 +796,29 @@ async function start() {
|
|||||||
checkClosed();
|
checkClosed();
|
||||||
checkWorkload();
|
checkWorkload();
|
||||||
loadWeather();
|
loadWeather();
|
||||||
timers.push(
|
startTimers();
|
||||||
|
// first visit: offer the setup prompt; the dashboard loads with the defaults behind it
|
||||||
|
if (!data.settingsSaved && data.canSaveSettings) openSettings('setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimers() {
|
||||||
|
timers.forEach(clearInterval);
|
||||||
|
timers = [
|
||||||
setInterval(checkClosed, data.checkMinutes * 60 * 1000),
|
setInterval(checkClosed, data.checkMinutes * 60 * 1000),
|
||||||
setInterval(checkWorkload, data.workloadMinutes * 60 * 1000),
|
setInterval(checkWorkload, data.settings.workloadMinutes * 60 * 1000),
|
||||||
setInterval(loadWeather, 10 * 60 * 1000) // server caches for 10 min, no point asking sooner
|
setInterval(loadWeather, 10 * 60 * 1000) // server caches for 10 min, no point asking sooner
|
||||||
);
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkClosed() {
|
async function checkClosed() {
|
||||||
// the whole feed on first load, then a short window that overlaps the last check
|
// the whole feed on first load, then a short window that overlaps the last check
|
||||||
const minutes = feedReady ? data.windowMinutes : data.feedHours * 60;
|
const minutes = feedReady ? data.windowMinutes : data.settings.feedHours * 60;
|
||||||
|
const gen = generation;
|
||||||
try {
|
try {
|
||||||
const result = await api('/api/closed?minutes=' + minutes);
|
const result = await api('/api/closed?minutes=' + minutes);
|
||||||
|
if (gen !== generation) return;
|
||||||
for (const t of result.tickets) feed.set(String(t.id), t);
|
for (const t of result.tickets) feed.set(String(t.id), t);
|
||||||
const cutoff = Date.now() - data.feedHours * 60 * 60 * 1000;
|
const cutoff = Date.now() - data.settings.feedHours * 60 * 60 * 1000;
|
||||||
for (const [id, t] of feed) {
|
for (const [id, t] of feed) {
|
||||||
if (t.closedAt < cutoff) feed.delete(id);
|
if (t.closedAt < cutoff) feed.delete(id);
|
||||||
}
|
}
|
||||||
@ -705,8 +835,10 @@ async function checkClosed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkWorkload() {
|
async function checkWorkload() {
|
||||||
|
const gen = generation;
|
||||||
try {
|
try {
|
||||||
const result = await api('/api/workload');
|
const result = await api('/api/workload');
|
||||||
|
if (gen !== generation) return;
|
||||||
data.workload = result.workload;
|
data.workload = result.workload;
|
||||||
data.unassigned = result.unassigned;
|
data.unassigned = result.unassigned;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -726,6 +858,7 @@ function render() {
|
|||||||
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
|
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
|
||||||
const live = data.auth === 'connected';
|
const live = data.auth === 'connected';
|
||||||
$('checkBtn').hidden = !live;
|
$('checkBtn').hidden = !live;
|
||||||
|
$('settingsBtn').hidden = !live;
|
||||||
$('signOutBtn').hidden = !live;
|
$('signOutBtn').hidden = !live;
|
||||||
$('user').hidden = !live;
|
$('user').hidden = !live;
|
||||||
const user = data.user || {};
|
const user = data.user || {};
|
||||||
@ -733,10 +866,10 @@ function render() {
|
|||||||
$('user').title = user.email || '';
|
$('user').title = user.email || '';
|
||||||
|
|
||||||
const wl = data.workload || {};
|
const wl = data.workload || {};
|
||||||
const s = data.settings || { sites: [], groups: [], technicians: [] };
|
const s = data.settings || { sites: [], groups: [], technicians: [], hiddenPanels: [] };
|
||||||
$('meta').innerHTML = !live ? '' : [
|
$('meta').innerHTML = !live ? '' : [
|
||||||
'Closed tickets checked every ' + data.checkMinutes + ' min',
|
'Closed tickets checked every ' + data.checkMinutes + ' min',
|
||||||
'Status counts every ' + data.workloadMinutes + ' min',
|
'Status counts every ' + s.workloadMinutes + ' min',
|
||||||
s.technicians.length ? 'Watching ' + s.technicians.length + ' technicians' : 'Watching all technicians',
|
s.technicians.length ? 'Watching ' + s.technicians.length + ' technicians' : 'Watching all technicians',
|
||||||
s.sites.length ? 'Sites: ' + s.sites.join(', ') : '',
|
s.sites.length ? 'Sites: ' + s.sites.join(', ') : '',
|
||||||
s.groups.length ? 'Groups: ' + s.groups.join(', ') : '',
|
s.groups.length ? 'Groups: ' + s.groups.join(', ') : '',
|
||||||
@ -766,13 +899,13 @@ function render() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.auth === 'connected') {
|
if (data.auth === 'connected') {
|
||||||
html += renderStats(wl);
|
const shown = panel => !s.hiddenPanels.includes(panel);
|
||||||
html += `<div class="board">
|
if (shown('stats')) html += renderStats(wl);
|
||||||
|
// the watcher and its ding keep running with the feed hidden; only the list goes
|
||||||
|
const side = (shown('weather') ? renderWeather() : '') + (shown('feed') ? renderFeed() : '');
|
||||||
|
html += `<div class="board${side ? '' : ' solo'}">
|
||||||
${renderWorkload(wl)}
|
${renderWorkload(wl)}
|
||||||
<div class="side">
|
${side ? `<div class="side">${side}</div>` : ''}
|
||||||
${renderWeather()}
|
|
||||||
${renderFeed()}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
html += renderUnassignedList(data.unassigned || {});
|
html += renderUnassignedList(data.unassigned || {});
|
||||||
}
|
}
|
||||||
@ -816,7 +949,9 @@ function renderStats(wl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderWorkload(wl) {
|
function renderWorkload(wl) {
|
||||||
const statuses = wl.statuses || [];
|
// hidden columns only leave the table; Total open still counts them
|
||||||
|
const hidden = new Set(data.settings.hiddenStatuses.map(n => n.toLowerCase()));
|
||||||
|
const statuses = (wl.statuses || []).filter(st => !hidden.has(st.name.toLowerCase()));
|
||||||
const techs = wl.technicians || [];
|
const techs = wl.technicians || [];
|
||||||
|
|
||||||
const note = wl.updatedAt
|
const note = wl.updatedAt
|
||||||
@ -945,7 +1080,7 @@ function renderFeed() {
|
|||||||
: '<div class="empty"><p>Nothing closed yet.</p></div>';
|
: '<div class="empty"><p>Nothing closed yet.</p></div>';
|
||||||
|
|
||||||
return `<section class="section">
|
return `<section class="section">
|
||||||
<div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.feedHours}h</span></div>
|
<div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.settings.feedHours}h</span></div>
|
||||||
${list}
|
${list}
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
@ -998,6 +1133,244 @@ async function loadWeather() {
|
|||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- settings: saved on the server per Zoho user, so they follow people between browsers ----
|
||||||
|
|
||||||
|
const LIST_FIELDS = { sites: 'Sites', groups: 'Groups', technicians: 'Technicians' };
|
||||||
|
const PANEL_LABELS = { stats: 'Stat tiles', weather: 'Weather', feed: 'Recently closed feed' };
|
||||||
|
|
||||||
|
let names = null; // suggestions from /api/names, fetched the first time settings open
|
||||||
|
let draft = null;
|
||||||
|
let settingsMode = 'edit'; // 'setup' on first visit
|
||||||
|
let settingsError = null;
|
||||||
|
|
||||||
|
const lower = list => list.map(n => n.toLowerCase());
|
||||||
|
|
||||||
|
function openSettings(mode) {
|
||||||
|
settingsMode = mode;
|
||||||
|
settingsError = null;
|
||||||
|
draft = JSON.parse(JSON.stringify(data.settings));
|
||||||
|
renderSettings();
|
||||||
|
if (!$('settings').open) $('settings').showModal();
|
||||||
|
loadNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNames() {
|
||||||
|
if (names) return;
|
||||||
|
try {
|
||||||
|
names = await api('/api/names');
|
||||||
|
if ($('settings').open) renderSettings();
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSettings() {
|
||||||
|
const d = draft;
|
||||||
|
const lim = data.settingsLimits;
|
||||||
|
const setup = settingsMode === 'setup';
|
||||||
|
// keep the cursor in the list being typed into across re-renders
|
||||||
|
const focused = document.activeElement && document.activeElement.dataset.add;
|
||||||
|
|
||||||
|
const chipList = field => {
|
||||||
|
const taken = new Set(lower(d[field]));
|
||||||
|
const options = ((names && names[field]) || []).filter(n => !taken.has(n.toLowerCase()));
|
||||||
|
return `<div class="field">
|
||||||
|
<label for="add-${field}">${LIST_FIELDS[field]}</label>
|
||||||
|
<div class="chips">
|
||||||
|
${d[field].map((n, i) => `<span class="chip">${esc(n)}<button type="button" data-remove="${field}" data-index="${i}" aria-label="Remove ${esc(n)}">×</button></span>`).join('')}
|
||||||
|
<input id="add-${field}" data-add="${field}" list="names-${field}" autocomplete="off"
|
||||||
|
placeholder="${d[field].length ? 'Add another…' : 'All ' + field + '. Type to add one'}">
|
||||||
|
</div>
|
||||||
|
<datalist id="names-${field}">${options.map(n => `<option value="${esc(n)}">`).join('')}</datalist>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// columns from the last count, plus any hidden ones that didn't show up this time
|
||||||
|
const statusNames = ((data.workload && data.workload.statuses) || []).map(st => st.name);
|
||||||
|
for (const n of d.hiddenStatuses) if (!lower(statusNames).includes(n.toLowerCase())) statusNames.push(n);
|
||||||
|
const hiddenStatuses = new Set(lower(d.hiddenStatuses));
|
||||||
|
|
||||||
|
const body = !data.canSaveSettings
|
||||||
|
? `<p class="settings-error">Zoho didn't share your email at sign-in, so settings can't be saved. Sign out and in again.</p>`
|
||||||
|
: `<div class="settings-body">
|
||||||
|
<fieldset>
|
||||||
|
<legend>What to watch</legend>
|
||||||
|
${Object.keys(LIST_FIELDS).map(chipList).join('')}
|
||||||
|
<div class="hint">Leave a list empty to include everyone. Suggestions come from recent tickets; any name can be typed, but it has to match ServiceDesk Plus exactly.</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Timing</legend>
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="set-workload">Recount status table every (min)</label>
|
||||||
|
<input type="number" id="set-workload" data-num="workloadMinutes" min="${lim.workloadMinutes.min}" max="${lim.workloadMinutes.max}" value="${d.workloadMinutes}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="set-feed">Keep closed tickets for (hours)</label>
|
||||||
|
<input type="number" id="set-feed" data-num="feedHours" min="${lim.feedHours.min}" max="${lim.feedHours.max}" value="${d.feedHours}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
${data.places.length ? `<fieldset>
|
||||||
|
<legend>Weather</legend>
|
||||||
|
<div class="field">
|
||||||
|
<label for="set-zip">Location</label>
|
||||||
|
<select id="set-zip" data-zip>
|
||||||
|
${d.weatherZip ? '' : '<option value="" selected>Choose a location…</option>'}
|
||||||
|
${data.places.map(p => `<option value="${esc(p.zip)}"${p.zip === d.weatherZip ? ' selected' : ''}>${esc(p.label)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</fieldset>` : ''}
|
||||||
|
<fieldset>
|
||||||
|
<legend>Show on the dashboard</legend>
|
||||||
|
<div class="checks">
|
||||||
|
${lim.panels.map(p => `<label><input type="checkbox" data-panel="${p}"${d.hiddenPanels.includes(p) ? '' : ' checked'}>${PANEL_LABELS[p] || p}</label>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="hint">The ding for newly closed tickets plays even with the feed hidden.</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Status columns</legend>
|
||||||
|
${statusNames.length
|
||||||
|
? `<div class="checks">${statusNames.map(n => `<label><input type="checkbox" data-status="${esc(n)}"${hiddenStatuses.has(n.toLowerCase()) ? '' : ' checked'}>${esc(n)}</label>`).join('')}</div>
|
||||||
|
<div class="hint">Hidden columns still count toward Total open.</div>`
|
||||||
|
: '<div class="hint">The columns appear here after the first status count.</div>'}
|
||||||
|
</fieldset>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const foot = setup
|
||||||
|
? `<span class="spacer"></span>
|
||||||
|
<button type="button" class="secondary" data-action="defaults">Use the defaults</button>
|
||||||
|
<button type="button" data-action="save">Save</button>`
|
||||||
|
: `<button type="button" class="quiet" data-action="reset"${data.canSaveSettings ? '' : ' disabled'}>Reset to defaults</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button type="button" class="secondary" data-action="cancel">Cancel</button>
|
||||||
|
<button type="button" data-action="save"${data.canSaveSettings ? '' : ' disabled'}>Save</button>`;
|
||||||
|
|
||||||
|
$('settings').innerHTML = `<div class="settings-head">
|
||||||
|
<h2 id="settingsTitle">${setup ? 'Set up your dashboard' : 'Settings'}</h2>
|
||||||
|
<p>${setup
|
||||||
|
? 'Pick what this dashboard watches. It starts with the help desk defaults, and you can change it any time with ⚙ Settings.'
|
||||||
|
: 'Saved to your Zoho account, so they follow you to any browser you sign in on.'}</p>
|
||||||
|
</div>
|
||||||
|
${settingsError ? `<div class="settings-error">${esc(settingsError)}</div>` : ''}
|
||||||
|
${body}
|
||||||
|
<div class="settings-foot">${foot}</div>`;
|
||||||
|
|
||||||
|
if (focused) $('add-' + focused).focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addName(field, input) {
|
||||||
|
const name = input.value.replace(/\s+/g, ' ').trim().replace(/,$/, '');
|
||||||
|
input.value = '';
|
||||||
|
if (!name || lower(draft[field]).includes(name.toLowerCase())) return;
|
||||||
|
// take the suggestion's spelling when only the case differs
|
||||||
|
const match = ((names && names[field]) || []).find(n => n.toLowerCase() === name.toLowerCase());
|
||||||
|
draft[field].push(match || name);
|
||||||
|
renderSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('settings').addEventListener('keydown', e => {
|
||||||
|
const field = e.target.dataset.add;
|
||||||
|
if (!field) return;
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
addName(field, e.target);
|
||||||
|
} else if (e.key === 'Backspace' && !e.target.value && draft[field].length) {
|
||||||
|
draft[field].pop();
|
||||||
|
renderSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// picking a suggestion from the list fills the input in one go; add it straight away
|
||||||
|
$('settings').addEventListener('input', e => {
|
||||||
|
const field = e.target.dataset.add;
|
||||||
|
if (!field || (e.inputType && e.inputType !== 'insertReplacementText')) return;
|
||||||
|
if (((names && names[field]) || []).includes(e.target.value)) addName(field, e.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('settings').addEventListener('change', e => {
|
||||||
|
const t = e.target;
|
||||||
|
if (t.dataset.num) draft[t.dataset.num] = Number(t.value);
|
||||||
|
if (t.dataset.zip !== undefined) draft.weatherZip = t.value;
|
||||||
|
if (t.dataset.panel) {
|
||||||
|
draft.hiddenPanels = draft.hiddenPanels.filter(p => p !== t.dataset.panel);
|
||||||
|
if (!t.checked) draft.hiddenPanels.push(t.dataset.panel);
|
||||||
|
}
|
||||||
|
if (t.dataset.status) {
|
||||||
|
draft.hiddenStatuses = draft.hiddenStatuses.filter(n => n.toLowerCase() !== t.dataset.status.toLowerCase());
|
||||||
|
if (!t.checked) draft.hiddenStatuses.push(t.dataset.status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('settings').addEventListener('click', e => {
|
||||||
|
const remove = e.target.closest('[data-remove]');
|
||||||
|
if (remove) {
|
||||||
|
draft[remove.dataset.remove].splice(Number(remove.dataset.index), 1);
|
||||||
|
renderSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = e.target.closest('[data-action]');
|
||||||
|
if (!action) return;
|
||||||
|
if (action.dataset.action === 'cancel') $('settings').close();
|
||||||
|
if (action.dataset.action === 'save') saveSettings(action);
|
||||||
|
if (action.dataset.action === 'defaults') saveSettings(action, {});
|
||||||
|
if (action.dataset.action === 'reset') saveSettings(action, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// body null resets to the defaults; {} keeps the defaults but stops the setup prompt
|
||||||
|
async function saveSettings(btn, body) {
|
||||||
|
if (body === undefined) {
|
||||||
|
// a name typed but not yet added with Enter still counts
|
||||||
|
for (const field of Object.keys(LIST_FIELDS)) {
|
||||||
|
const input = $('add-' + field);
|
||||||
|
if (input && input.value.trim()) addName(field, input);
|
||||||
|
}
|
||||||
|
const { sites, groups, technicians, workloadMinutes, feedHours, weatherZip, hiddenStatuses, hiddenPanels } = draft;
|
||||||
|
body = { sites, groups, technicians, workloadMinutes, feedHours, hiddenStatuses, hiddenPanels };
|
||||||
|
if (data.places.length && weatherZip) body.weatherZip = weatherZip;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const reply = await api('/api/settings', body === null
|
||||||
|
? { method: 'DELETE' }
|
||||||
|
: { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
$('settings').close();
|
||||||
|
applySettings(reply);
|
||||||
|
} catch (err) {
|
||||||
|
if (data.auth !== 'connected') return $('settings').close();
|
||||||
|
settingsError = err.message;
|
||||||
|
renderSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings(reply) {
|
||||||
|
const before = data.settings;
|
||||||
|
Object.assign(data, reply);
|
||||||
|
const after = data.settings;
|
||||||
|
const changed = (...fields) => fields.some(f => JSON.stringify(before[f]) !== JSON.stringify(after[f]));
|
||||||
|
if (changed('sites', 'groups', 'technicians', 'feedHours')) generation++;
|
||||||
|
|
||||||
|
if (changed('sites', 'groups', 'technicians', 'feedHours')) {
|
||||||
|
// a different region or feed length: start the feed over, quietly, like a fresh page load
|
||||||
|
feed.clear();
|
||||||
|
data.tickets = [];
|
||||||
|
seen = new Set();
|
||||||
|
feedReady = false;
|
||||||
|
firstLoad = true;
|
||||||
|
checkClosed();
|
||||||
|
}
|
||||||
|
if (changed('sites', 'groups', 'technicians')) {
|
||||||
|
data.workload = {};
|
||||||
|
data.unassigned = {};
|
||||||
|
checkWorkload();
|
||||||
|
}
|
||||||
|
if (changed('workloadMinutes')) startTimers();
|
||||||
|
if (changed('weatherZip')) {
|
||||||
|
weather = null;
|
||||||
|
loadWeather();
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
start();
|
start();
|
||||||
setInterval(render, 20000); // re-age the rails between checks
|
setInterval(render, 20000); // re-age the rails between checks
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user