Add a per-user sort order for the technician table
Settings has a "Sort rows by" choice: the technician list order (the default, as before), technician name, Total open, Closed today or any status column. Counts sort busiest first with ties by name, and the sorted column's header gets an arrow. The browser does the sorting; the server only validates and saves tableSort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
80a5e2c965
commit
872a7f17c3
@ -133,6 +133,7 @@ The full security model, its limits and the open items are in [docs/SECURITY.md]
|
||||
- **Timing:** how often the status table recounts (2 to 60 minutes) and how long closed tickets stay in the feed (1 to 24 hours). The closed check itself always runs every minute.
|
||||
- **Weather location**, from the list of campuses in `src/places.js`.
|
||||
- **Which statuses count as closed for the feed and ding.** Starts as `STATUS_NAMES`. SDP closes a resolved ticket on its own after a few days, so ticking **Resolved** gives the alert when the ticket is resolved instead. With Resolved ticked, a ticket's time in the feed is its resolve time, so the automatic close later doesn't ding again.
|
||||
- **Table order:** sort the technician table by technician name, Total open, Closed today or any status column. Counts sort busiest first. The default keeps the order of the technician list (A to Z when watching everyone), and the sorted column's header has an arrow.
|
||||
- **What's shown:** the stat tiles, weather card and closed feed can be hidden (the ding still plays with the feed hidden), and so can individual status columns.
|
||||
|
||||
Settings are saved to the person's Zoho account on the server, so they follow them to any browser. Only the values that differ from `.env` are stored, so someone who never changed the region picks up later changes to `.env`. **Reset to defaults** goes back to `.env`. The closed and open status rules for the status table, Closed today and the unassigned count aren't per person; they stay in `.env`.
|
||||
|
||||
@ -40,6 +40,7 @@ Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-s
|
||||
- **2026-10-15:** **Weather locations are a fixed list** (`src/places.js`), not free zip codes, so a browser can't make the server look up arbitrary places and the per-zip cache stays small. The `.env` zip is added to the list if it's missing.
|
||||
- **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open.
|
||||
- **2026-10-19:** **The closed feed's statuses are per user (`feedStatuses`, v2.1.1).** This narrows the decision above: `STATUS_NAMES` still defines open and closed for the status table, Closed today and the unassigned count, and is the default for `feedStatuses`. Our SDP closes a resolved ticket on its own after 2 or 3 days if it isn't reopened, so watching only Closed gave the ding days late. When Resolved is in a user's list, the feed times a ticket by `resolved_time` before `completed_time`, so the automatic close (new `completed_time`, old `resolved_time`) falls outside the check window and doesn't ding again. A reopen and re-resolve gives a new `resolved_time`, which counts as a fresh close. An empty list is refused, because the feed would then take any status. Before this, an empty `STATUS_NAMES` made the feed take any status; now it falls back to `Closed,Resolved,Canceled` like the rest.
|
||||
- **2026-10-19:** **Table order is a saved setting (`tableSort`), sorted in the browser.** Values are `list`, `name`, `open`, `closedToday` or `status:<name>`; the server only validates the value. A status sort is saved by name and compared without case, so it survives that status having no tickets for a while (the rows then fall back to A to Z). Counts sort descending with ties by name, and there's no direction choice; add one if anyone asks. It's in Settings rather than clickable headers so a wallboard keeps its order after a reload.
|
||||
- **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person.
|
||||
- **2026-10-15:** **Sign-in is limited by email domain (`ALLOWED_EMAIL_DOMAINS`).** Any Zoho account can approve our client, and before this, an outside account got a session and `/api/session` handed it the `.env` site, group and technician names (ticket queries failed with their token). Prod v2.0.0 had this gap until v2.1.0 was deployed on 2026-10-16. The callback now refuses other domains and revokes their token, `requireSession` re-checks every call so older cookies die too, and the server won't start without the setting. Students don't have Zoho accounts, so the domain is enough for now. Next step: check the user is an SDP technician at sign-in, which probably needs a users/technicians read scope.
|
||||
- **2026-10-16:** **No ticket subjects, anywhere (FERPA).** Subjects can name students or describe their situation. The closed feed and the unassigned query no longer put `subject` in `fields_required`, and the objects the server returns are built field by field, so a subject SDP sends anyway (if it ever ignores `fields_required`) is dropped before it leaves the server. Feed cards show the ticket number, technician and closing time. Don't add `subject`, `description` or any other free-text field to a query or response without a new review; to identify a ticket, use its number and open it in SDP.
|
||||
|
||||
@ -37,3 +37,4 @@ A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zo
|
||||
- Live test on 2026-10-15: per-user sign-in, data, Sign out and the IP access list all work behind NPM.
|
||||
- Per-user settings (2026-10-15, `v2.1.0`): ⚙ Settings and a first-visit setup prompt; sites, groups, technicians with suggestions from recent SDP tickets; table refresh and feed length; weather location from a campus list; hidden panels and status columns. Saved server side by Zoho email, so they follow the user between browsers.
|
||||
- Per-user closed statuses for the feed and ding (2026-10-19, `v2.1.1`), so Resolved can ding without waiting days for SDP's automatic close. Tested with stubs only; check against real SDP that resolved tickets carry `resolved_time` and that the later automatic close doesn't ding again.
|
||||
- Per-user table order (2026-10-19, `v2.1.1`): sort the technician table by name, Total open, Closed today or a status column. Sorting happens in the browser; only the choice is saved.
|
||||
|
||||
@ -313,6 +313,9 @@ th {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
vertical-align: bottom;
|
||||
}
|
||||
th[aria-sort] { color: var(--ink); }
|
||||
th[aria-sort="descending"]::after { content: " ↓"; }
|
||||
th[aria-sort="ascending"]::after { content: " ↑"; }
|
||||
th .swatch {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
@ -942,11 +945,28 @@ function renderStats(wl) {
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// rows by the user's tableSort: counts busiest first, names A to Z, ties by name.
|
||||
// 'list' keeps the server's order (the technician list as entered, or A to Z)
|
||||
function sortTechs(techs, sort) {
|
||||
if (sort === 'list') return techs;
|
||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||
if (sort === 'name') return techs.slice().sort(byName);
|
||||
const status = sort.startsWith('status:') ? sort.slice(7).toLowerCase() : null;
|
||||
// counts are keyed by SDP's spelling; the saved name may differ in case
|
||||
const value = t => status
|
||||
? Object.entries(t.counts).reduce((n, [k, v]) => k.toLowerCase() === status ? n + v : n, 0)
|
||||
: t[sort] || 0;
|
||||
return techs.slice().sort((a, b) => value(b) - value(a) || byName(a, b));
|
||||
}
|
||||
|
||||
function renderWorkload(wl) {
|
||||
// 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 sort = data.settings.tableSort || 'list';
|
||||
const techs = sortTechs(wl.technicians || [], sort);
|
||||
// marks the column the rows are sorted by
|
||||
const sorted = key => key.toLowerCase() === sort.toLowerCase() ?' aria-sort="' + (key === 'name' ? 'ascending' : 'descending') + '"' : '';
|
||||
|
||||
const note = wl.updatedAt
|
||||
? 'Updated ' + clockTime(wl.updatedAt) + (wl.truncated ? ' · partial: ' + wl.truncated : '')
|
||||
@ -964,9 +984,9 @@ function renderWorkload(wl) {
|
||||
: `<td class="num zero ${cls || ''}">·</td>`;
|
||||
|
||||
// totals come right after the name so they stay visible when the status columns scroll off
|
||||
const head = '<tr><th scope="col">Technician</th>'
|
||||
+ '<th scope="col" class="total">Total open</th><th scope="col" class="closed">Closed today</th>'
|
||||
+ statuses.map(s => `<th scope="col"${s.color ? ` style="--status-color:${s.color}"` : ''}><span class="swatch"></span>${esc(s.name)}</th>`).join('')
|
||||
const head = `<tr><th scope="col"${sorted('name')}>Technician</th>`
|
||||
+ `<th scope="col" class="total"${sorted('open')}>Total open</th><th scope="col" class="closed"${sorted('closedToday')}>Closed today</th>`
|
||||
+ statuses.map(s => `<th scope="col"${sorted('status:' + s.name)}${s.color ? ` style="--status-color:${s.color}"` : ''}><span class="swatch"></span>${esc(s.name)}</th>`).join('')
|
||||
+ '</tr>';
|
||||
|
||||
const rows = techs.map(t => `<tr${flashTechs.has(t.name) ? ' class="flash"' : ''}>
|
||||
@ -1188,6 +1208,19 @@ function renderSettings() {
|
||||
}
|
||||
const feedStatuses = new Set(lower(d.feedStatuses));
|
||||
|
||||
// a saved status sort stays on the list even if that status has no tickets right now
|
||||
const sortStatuses = statusNames.slice();
|
||||
if (d.tableSort.startsWith('status:') && !lower(sortStatuses).includes(d.tableSort.slice(7).toLowerCase())) {
|
||||
sortStatuses.push(d.tableSort.slice(7));
|
||||
}
|
||||
const sortChoices = [
|
||||
['list', d.technicians.length ? 'Technician list order' : 'Technician name (default)'],
|
||||
['name', 'Technician name'],
|
||||
['open', 'Total open'],
|
||||
['closedToday', 'Closed today'],
|
||||
...sortStatuses.map(n => ['status:' + n, n])
|
||||
];
|
||||
|
||||
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">
|
||||
@ -1231,6 +1264,16 @@ function renderSettings() {
|
||||
<div class="checks">${feedChoices.map(n => `<label><input type="checkbox" data-feed-status="${esc(n)}"${feedStatuses.has(n.toLowerCase()) ? ' checked' : ''}>${esc(n)}</label>`).join('')}</div>
|
||||
<div class="hint">Tick Resolved to hear about tickets when they're resolved, not days later when they close on their own. They won't ding again when they close.</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Table order</legend>
|
||||
<div class="field">
|
||||
<label for="set-sort">Sort rows by</label>
|
||||
<select id="set-sort" data-sort>
|
||||
${sortChoices.map(([value, label]) => `<option value="${esc(value)}"${value.toLowerCase() === d.tableSort.toLowerCase() ? ' selected' : ''}>${esc(label)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="hint">Counts sort busiest first, names A to Z.</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Status columns</legend>
|
||||
${statusNames.length
|
||||
@ -1295,6 +1338,7 @@ $('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.sort !== undefined) draft.tableSort = 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);
|
||||
@ -1332,8 +1376,8 @@ async function saveSettings(btn, body) {
|
||||
const input = $('add-' + field);
|
||||
if (input && input.value.trim()) addName(field, input);
|
||||
}
|
||||
const { sites, groups, technicians, workloadMinutes, feedHours, weatherZip, hiddenStatuses, hiddenPanels, feedStatuses } = draft;
|
||||
body = { sites, groups, technicians, workloadMinutes, feedHours, hiddenStatuses, hiddenPanels, feedStatuses };
|
||||
const { sites, groups, technicians, workloadMinutes, feedHours, weatherZip, hiddenStatuses, hiddenPanels, feedStatuses, tableSort } = draft;
|
||||
body = { sites, groups, technicians, workloadMinutes, feedHours, hiddenStatuses, hiddenPanels, feedStatuses, tableSort };
|
||||
if (data.places.length && weatherZip) body.weatherZip = weatherZip;
|
||||
}
|
||||
|
||||
|
||||
@ -89,6 +89,7 @@ const defaults = {
|
||||
feedHours: 12, // how long a closed ticket stays in the feed
|
||||
weatherZip: OWM_ZIP_CODE || null,
|
||||
hiddenStatuses: [], // status columns left out of the table
|
||||
tableSort: 'list', // see TABLE_SORTS in settings.js
|
||||
// statuses that put a ticket in the closed feed and ding; some people want Resolved,
|
||||
// which comes days before SDP closes a ticket on its own
|
||||
feedStatuses: closedStatuses,
|
||||
|
||||
@ -21,19 +21,29 @@ const PANELS = ['stats', 'weather', 'feed'];
|
||||
|
||||
const LIST_FIELDS = ['sites', 'groups', 'technicians', 'hiddenStatuses', 'feedStatuses'];
|
||||
|
||||
// how the technician table's rows can be ordered, besides 'status:<name>' for a status column.
|
||||
// 'list' is the technician list as entered, or A to Z when watching everyone
|
||||
const TABLE_SORTS = ['list', 'name', 'open', 'closedToday'];
|
||||
|
||||
class SettingsError extends Error {}
|
||||
|
||||
// trims, collapses spaces and drops case-insensitive repeats; rejects anything that isn't a plain name
|
||||
// trims and collapses spaces; rejects anything that isn't a plain name
|
||||
function cleanName(item, field) {
|
||||
if (typeof item !== 'string') throw new SettingsError(`${field} must be a list of names`);
|
||||
const name = item.replace(/\s+/g, ' ').trim();
|
||||
if (name.length > LIMITS.nameLength) throw new SettingsError(`"${name.slice(0, 40)}…" is too long`);
|
||||
if (/[\u0000-\u001f\u007f]/.test(name)) throw new SettingsError(`${field} has an invalid name`);
|
||||
return name;
|
||||
}
|
||||
|
||||
// cleans each name and drops blanks and case-insensitive repeats
|
||||
function cleanList(value, field) {
|
||||
if (!Array.isArray(value)) throw new SettingsError(`${field} must be a list`);
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') throw new SettingsError(`${field} must be a list of names`);
|
||||
const name = item.replace(/\s+/g, ' ').trim();
|
||||
const name = cleanName(item, field);
|
||||
if (!name) continue;
|
||||
if (name.length > LIMITS.nameLength) throw new SettingsError(`"${name.slice(0, 40)}…" is too long`);
|
||||
if (/[\u0000-\u001f\u007f]/.test(name)) throw new SettingsError(`${field} has an invalid name`);
|
||||
if (seen.has(name.toLowerCase())) continue;
|
||||
seen.add(name.toLowerCase());
|
||||
out.push(name);
|
||||
@ -49,6 +59,15 @@ function cleanNumber(value, field) {
|
||||
return n;
|
||||
}
|
||||
|
||||
function cleanSort(value) {
|
||||
if (TABLE_SORTS.includes(value)) return value;
|
||||
if (typeof value === 'string' && value.startsWith('status:')) {
|
||||
const name = cleanName(value.slice('status:'.length), 'tableSort');
|
||||
if (name) return 'status:' + name;
|
||||
}
|
||||
throw new SettingsError(`tableSort must be one of ${TABLE_SORTS.join(', ')} or a status column`);
|
||||
}
|
||||
|
||||
const sameValue =(a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
// Per-user settings, kept on the server and keyed by the email Zoho gave at sign-in, so they
|
||||
@ -71,6 +90,7 @@ function createUserSettings({ dataDir, defaults, places }) {
|
||||
for (const field of ['workloadMinutes', 'feedHours']) {
|
||||
if (input[field] !== undefined) clean[field] = cleanNumber(input[field], field);
|
||||
}
|
||||
if (input.tableSort !== undefined) clean.tableSort = cleanSort(input.tableSort);
|
||||
if (input.weatherZip !== undefined) {
|
||||
if (!zips.has(input.weatherZip)) throw new SettingsError('Pick a weather location from the list');
|
||||
clean.weatherZip = input.weatherZip;
|
||||
@ -141,6 +161,7 @@ function createUserSettings({ dataDir, defaults, places }) {
|
||||
module.exports = {
|
||||
LIMITS,
|
||||
PANELS,
|
||||
TABLE_SORTS,
|
||||
SettingsError,
|
||||
createUserSettings
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user