A per-event queue board for a repair cafe. Customers sign items in on a shared tablet; volunteers drag repairs through Queue, In progress and Done on a dashboard, then export the day's records as CSV. Zero npm dependencies: node:http plus JSON files on disk, no build step. Categories and house rules are set in config.json. Events are deleted 7 days after creation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
545 lines
17 KiB
JavaScript
545 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
const slug = location.pathname.split('/').filter(Boolean)[0];
|
|
const POLL_MS = 4000;
|
|
|
|
const COLUMNS = [
|
|
{ status: 'queue', title: 'Queue' },
|
|
{ status: 'in-progress', title: 'In progress' },
|
|
{ status: 'done', title: 'Done' },
|
|
];
|
|
|
|
const board = document.getElementById('board');
|
|
const statusLine = document.getElementById('status-line');
|
|
|
|
let event = null;
|
|
let categories = [];
|
|
const colorOf = new Map();
|
|
const columnEls = new Map(); // status -> { section, cards, count, sort }
|
|
let drag = null;
|
|
|
|
/* --------------------------------------------------------------- utilities */
|
|
|
|
const byId = (id) => event.repairs.find((r) => r.id === id);
|
|
const isIncomplete = (r) => r.status === 'done' && !r.outcome;
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function formatTime(iso) {
|
|
const d = new Date(iso);
|
|
return d.toLocaleString(undefined, {
|
|
day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
function sortKey(status) {
|
|
return localStorage.getItem(`sort:${slug}:${status}`) || 'time';
|
|
}
|
|
|
|
function setSortKey(status, value) {
|
|
localStorage.setItem(`sort:${slug}:${status}`, value);
|
|
}
|
|
|
|
function sortRepairs(list, key) {
|
|
const byTime = (a, b) => a.createdAt.localeCompare(b.createdAt);
|
|
if (key === 'category') {
|
|
return list.slice().sort((a, b) => a.category.localeCompare(b.category) || byTime(a, b));
|
|
}
|
|
return list.slice().sort(byTime);
|
|
}
|
|
|
|
function showStatus(message, isError) {
|
|
statusLine.textContent = message || '';
|
|
statusLine.style.color = isError ? '#a33c33' : '#555';
|
|
}
|
|
|
|
/* --------------------------------------------------------------- API calls */
|
|
|
|
async function api(path, options) {
|
|
const res = await fetch(path, options);
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || 'The server said no.');
|
|
return data;
|
|
}
|
|
|
|
async function patchRepair(id, patch) {
|
|
try {
|
|
const updated = await api(`/api/events/${slug}/repairs/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(patch),
|
|
});
|
|
const index = event.repairs.findIndex((r) => r.id === id);
|
|
if (index >= 0) event.repairs[index] = updated;
|
|
showStatus('');
|
|
render();
|
|
return updated;
|
|
} catch (err) {
|
|
showStatus(err.message, true);
|
|
await refresh(); // put the board back to whatever the server actually has
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
const data = await api(`/api/events/${slug}`);
|
|
event = data;
|
|
render();
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ render */
|
|
|
|
function buildColumns() {
|
|
const template = document.getElementById('column-template');
|
|
for (const column of COLUMNS) {
|
|
const section = template.content.firstElementChild.cloneNode(true);
|
|
section.dataset.status = column.status;
|
|
section.querySelector('h2').textContent = column.title;
|
|
|
|
const select = section.querySelector('.sort');
|
|
select.value = sortKey(column.status);
|
|
select.addEventListener('change', () => {
|
|
setSortKey(column.status, select.value);
|
|
render();
|
|
});
|
|
|
|
board.append(section);
|
|
columnEls.set(column.status, {
|
|
section,
|
|
cards: section.querySelector('.cards'),
|
|
count: section.querySelector('.count'),
|
|
});
|
|
}
|
|
}
|
|
|
|
function buildCard(repair) {
|
|
const card = el('div', 'card');
|
|
card.dataset.id = repair.id;
|
|
if (isIncomplete(repair)) card.classList.add('incomplete');
|
|
|
|
const top = el('div', 'card-top');
|
|
const title = el('div', 'card-title', repair.item);
|
|
title.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
openDetail(repair.id);
|
|
});
|
|
const grip = el('span', 'card-grip', '⠿');
|
|
grip.title = 'Drag to move';
|
|
top.append(title, grip);
|
|
card.append(top);
|
|
|
|
card.append(el('div', 'card-line', repair.customer));
|
|
|
|
if (repair.status === 'in-progress' && repair.repairer) {
|
|
const line = el('div', 'card-line');
|
|
line.append(el('b', null, 'Repairer: '), document.createTextNode(repair.repairer));
|
|
card.append(line);
|
|
}
|
|
|
|
if (repair.status === 'done') {
|
|
const line = el('div', 'card-line');
|
|
line.append(el('b', null, 'Result: '), document.createTextNode(repair.outcome || 'not recorded'));
|
|
card.append(line);
|
|
}
|
|
|
|
const chip = el('span', 'chip', repair.category);
|
|
chip.style.background = colorOf.get(repair.category) || '#6d6862';
|
|
card.append(chip);
|
|
|
|
return card;
|
|
}
|
|
|
|
function render() {
|
|
if (!event) return;
|
|
|
|
document.getElementById('event-name').textContent = event.name;
|
|
document.title = `Dashboard — ${event.name}`;
|
|
|
|
for (const column of COLUMNS) {
|
|
const { cards, count } = columnEls.get(column.status);
|
|
const list = sortRepairs(event.repairs.filter((r) => r.status === column.status), sortKey(column.status));
|
|
|
|
const scroll = cards.scrollTop;
|
|
cards.replaceChildren();
|
|
for (const repair of list) cards.append(buildCard(repair));
|
|
if (list.length === 0) cards.append(el('p', 'empty-note', 'Nothing here yet.'));
|
|
cards.scrollTop = scroll;
|
|
|
|
count.textContent = String(list.length);
|
|
}
|
|
|
|
// Keep an open detail overlay in step with whatever just changed.
|
|
if (detailId && detail.open) renderDetail();
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ drag */
|
|
|
|
// Custom pointer dragging rather than HTML5 drag-and-drop, so this works on a
|
|
// tablet as well as a laptop.
|
|
function onPointerDown(e) {
|
|
if (e.button !== undefined && e.button !== 0) return;
|
|
const card = e.target.closest('.card');
|
|
if (!card || !board.contains(card)) return;
|
|
if (e.target.closest('.card-title')) return; // the title opens the detail view
|
|
|
|
// A mouse can grab the card anywhere. A finger has to use the grip, so that
|
|
// sliding a finger up the column still scrolls it.
|
|
if (e.pointerType !== 'mouse' && !e.target.closest('.card-grip')) return;
|
|
|
|
drag = {
|
|
card,
|
|
id: card.dataset.id,
|
|
pointerId: e.pointerId,
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
offsetX: 0,
|
|
offsetY: 0,
|
|
started: false,
|
|
placeholder: null,
|
|
};
|
|
|
|
window.addEventListener('pointermove', onPointerMove);
|
|
window.addEventListener('pointerup', onPointerUp);
|
|
window.addEventListener('pointercancel', onPointerUp);
|
|
}
|
|
|
|
function startDrag(e) {
|
|
const { card } = drag;
|
|
const rect = card.getBoundingClientRect();
|
|
|
|
const placeholder = el('div', 'placeholder');
|
|
placeholder.style.height = `${rect.height}px`;
|
|
card.after(placeholder);
|
|
drag.placeholder = placeholder;
|
|
|
|
drag.offsetX = drag.startX - rect.left;
|
|
drag.offsetY = drag.startY - rect.top;
|
|
card.style.setProperty('--drag-width', `${rect.width}px`);
|
|
card.classList.add('dragging');
|
|
document.body.style.cursor = 'grabbing';
|
|
drag.started = true;
|
|
moveCard(e);
|
|
}
|
|
|
|
function moveCard(e) {
|
|
drag.card.style.left = `${e.clientX - drag.offsetX}px`;
|
|
drag.card.style.top = `${e.clientY - drag.offsetY}px`;
|
|
}
|
|
|
|
// Nudge a column that the pointer is hovering near the edge of, so you can drag
|
|
// into a part of a long list that is off screen.
|
|
function autoScroll(container, y) {
|
|
const rect = container.getBoundingClientRect();
|
|
const zone = 48;
|
|
if (y < rect.top + zone) container.scrollTop -= 12;
|
|
else if (y > rect.bottom - zone) container.scrollTop += 12;
|
|
}
|
|
|
|
function placeholderTarget(x, y) {
|
|
const under = document.elementFromPoint(x, y);
|
|
const container = under && under.closest ? under.closest('.cards') : null;
|
|
if (!container) return null;
|
|
|
|
autoScroll(container, y);
|
|
|
|
const siblings = [...container.querySelectorAll('.card')].filter((c) => c !== drag.card);
|
|
let before = null;
|
|
for (const sibling of siblings) {
|
|
const rect = sibling.getBoundingClientRect();
|
|
if (y < rect.top + rect.height / 2) { before = sibling; break; }
|
|
}
|
|
return { container, before };
|
|
}
|
|
|
|
function onPointerMove(e) {
|
|
if (!drag) return;
|
|
if (!drag.started) {
|
|
if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 6) return;
|
|
startDrag(e);
|
|
}
|
|
e.preventDefault();
|
|
moveCard(e);
|
|
|
|
const target = placeholderTarget(e.clientX, e.clientY);
|
|
if (target) {
|
|
const { container, before } = target;
|
|
if (before) container.insertBefore(drag.placeholder, before);
|
|
else container.append(drag.placeholder);
|
|
}
|
|
}
|
|
|
|
function endDrag() {
|
|
const { card, placeholder } = drag;
|
|
card.classList.remove('dragging');
|
|
card.style.left = card.style.top = '';
|
|
card.style.removeProperty('--drag-width');
|
|
if (placeholder) placeholder.remove();
|
|
document.body.style.cursor = '';
|
|
|
|
window.removeEventListener('pointermove', onPointerMove);
|
|
window.removeEventListener('pointerup', onPointerUp);
|
|
window.removeEventListener('pointercancel', onPointerUp);
|
|
}
|
|
|
|
async function onPointerUp(e) {
|
|
if (!drag) return;
|
|
const wasDragging = drag.started;
|
|
const id = drag.id;
|
|
const targetStatus = drag.placeholder
|
|
? drag.placeholder.closest('.column').dataset.status
|
|
: null;
|
|
|
|
endDrag();
|
|
drag = null;
|
|
|
|
if (!wasDragging) return; // a stray click on a card changes nothing
|
|
if (e.type === 'pointercancel') return render();
|
|
|
|
const repair = byId(id);
|
|
if (!repair || !targetStatus || targetStatus === repair.status) return render();
|
|
|
|
await applyMove(repair, targetStatus);
|
|
}
|
|
|
|
async function applyMove(repair, targetStatus) {
|
|
if (targetStatus === 'in-progress') {
|
|
// Pulled back out of Done: whoever was repairing it still is, so don't ask
|
|
// again. Coming up from the queue we do ask, since that may be a hand-over.
|
|
if (repair.status === 'done' && repair.repairer) {
|
|
return patchRepair(repair.id, { status: targetStatus }).catch(() => {});
|
|
}
|
|
const answer = await askRepairer(repair.repairer || '');
|
|
if (answer === null) return render();
|
|
return patchRepair(repair.id, { status: targetStatus, repairer: answer }).catch(() => {});
|
|
}
|
|
|
|
if (targetStatus === 'done') {
|
|
const answer = await askDone(repair);
|
|
if (answer === null) return render();
|
|
const patch = { status: targetStatus };
|
|
if (answer.filled) {
|
|
patch.outcome = answer.outcome;
|
|
patch.toolsUsed = answer.toolsUsed;
|
|
if (answer.note) patch.addNote = answer.note;
|
|
}
|
|
return patchRepair(repair.id, patch).catch(() => {});
|
|
}
|
|
|
|
return patchRepair(repair.id, { status: targetStatus }).catch(() => {});
|
|
}
|
|
|
|
board.addEventListener('pointerdown', onPointerDown);
|
|
|
|
/* ---------------------------------------------------------------- dialogs */
|
|
|
|
// Cancel and Skip are plain buttons that close the dialog themselves, so the
|
|
// only submit button left is Save — which makes Save the one Enter triggers.
|
|
for (const button of document.querySelectorAll('dialog [data-close]')) {
|
|
button.addEventListener('click', () => button.closest('dialog').close(button.dataset.close));
|
|
}
|
|
|
|
function openDialog(dialog) {
|
|
return new Promise((resolve) => {
|
|
dialog.addEventListener('close', () => resolve(dialog.returnValue), { once: true });
|
|
dialog.showModal();
|
|
});
|
|
}
|
|
|
|
// Resolves with the volunteer's name, or null if the move was cancelled.
|
|
async function askRepairer(current) {
|
|
const dialog = document.getElementById('repairer-dialog');
|
|
const input = document.getElementById('repairer-input');
|
|
input.value = current;
|
|
const result = openDialog(dialog);
|
|
input.focus();
|
|
input.select();
|
|
return (await result) === 'save' ? input.value.trim() : null;
|
|
}
|
|
|
|
// Resolves with the recorded result, {filled:false} if skipped, or null if cancelled.
|
|
async function askDone(repair) {
|
|
const dialog = document.getElementById('done-dialog');
|
|
const tools = document.getElementById('tools-input');
|
|
const note = document.getElementById('done-note');
|
|
const radios = [...dialog.querySelectorAll('input[name="outcome"]')];
|
|
|
|
for (const radio of radios) radio.checked = radio.value === repair.outcome;
|
|
tools.value = repair.toolsUsed || '';
|
|
note.value = '';
|
|
|
|
const result = openDialog(dialog);
|
|
const first = radios.find((r) => r.checked) || radios[0];
|
|
first.focus();
|
|
|
|
const action = await result;
|
|
if (action === 'save') {
|
|
const chosen = radios.find((r) => r.checked);
|
|
return {
|
|
filled: true,
|
|
outcome: chosen ? chosen.value : null,
|
|
toolsUsed: tools.value.trim(),
|
|
note: note.value.trim(),
|
|
};
|
|
}
|
|
if (action === 'skip') return { filled: false };
|
|
return null;
|
|
}
|
|
|
|
/* ---------------------------------------------------- detail overlay fields */
|
|
|
|
const detail = document.getElementById('detail');
|
|
let detailId = null;
|
|
|
|
// A value that looks like plain text until you click it, then becomes an input.
|
|
function editable(value, placeholder, makeEditor, onSave) {
|
|
const view = el('div', 'editable');
|
|
const setText = (text) => {
|
|
view.textContent = text || placeholder;
|
|
view.classList.toggle('empty', !text);
|
|
};
|
|
setText(value);
|
|
|
|
view.addEventListener('click', () => {
|
|
const editor = makeEditor(value);
|
|
let done = false;
|
|
|
|
const finish = async (save) => {
|
|
if (done) return;
|
|
done = true;
|
|
const next = editor.value.trim();
|
|
editor.replaceWith(view);
|
|
if (save && next !== (value || '')) await onSave(next);
|
|
else setText(value);
|
|
};
|
|
|
|
editor.addEventListener('blur', () => finish(true));
|
|
editor.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') { e.preventDefault(); finish(false); }
|
|
if (e.key === 'Enter' && editor.tagName !== 'TEXTAREA') { e.preventDefault(); finish(true); }
|
|
});
|
|
if (editor.tagName === 'SELECT') editor.addEventListener('change', () => finish(true));
|
|
|
|
view.replaceWith(editor);
|
|
editor.focus();
|
|
if (editor.select) editor.select();
|
|
});
|
|
|
|
return view;
|
|
}
|
|
|
|
function textEditor(value) {
|
|
const input = el('input');
|
|
input.type = 'text';
|
|
input.value = value || '';
|
|
return input;
|
|
}
|
|
|
|
function areaEditor(value) {
|
|
const area = el('textarea');
|
|
area.value = value || '';
|
|
area.rows = 4;
|
|
return area;
|
|
}
|
|
|
|
function selectEditor(options, allowBlank, blankLabel) {
|
|
return (value) => {
|
|
const select = el('select');
|
|
if (allowBlank) select.append(new Option(blankLabel, ''));
|
|
for (const option of options) select.append(new Option(option, option));
|
|
select.value = value || '';
|
|
return select;
|
|
};
|
|
}
|
|
|
|
// `wide` fields span both columns of the overlay grid.
|
|
function detailField(label, view, wide) {
|
|
const wrap = el('div', `detail-field${wide ? ' wide' : ''}`);
|
|
wrap.append(el('label', null, label), view);
|
|
return wrap;
|
|
}
|
|
|
|
function renderDetail() {
|
|
const repair = byId(detailId);
|
|
if (!repair) return detail.close();
|
|
|
|
document.getElementById('detail-title').textContent = repair.item;
|
|
|
|
const save = (field) => (value) => patchRepair(repair.id, { [field]: value }).catch(() => {});
|
|
|
|
const fields = document.getElementById('detail-fields');
|
|
fields.replaceChildren(
|
|
detailField('Item', editable(repair.item, '—', textEditor, save('item'))),
|
|
detailField('Customer', editable(repair.customer, '—', textEditor, save('customer'))),
|
|
detailField('Problem', editable(repair.problem, 'No description given', areaEditor, save('problem')), true),
|
|
detailField('Category', editable(repair.category, '—', selectEditor(categories, false), save('category'))),
|
|
detailField('Status', editable(repair.status, '—', selectEditor(COLUMNS.map((c) => c.status), false), save('status'))),
|
|
detailField('Repairer', editable(repair.repairer, 'Nobody yet', textEditor, save('repairer'))),
|
|
detailField('Result', editable(repair.outcome, 'Not recorded', selectEditor(['fixed', 'partially fixed', 'not fixed'], true, 'Not recorded'), save('outcome'))),
|
|
detailField('Tools / consumables', editable(repair.toolsUsed, 'None recorded', textEditor, save('toolsUsed'))),
|
|
detailField('Signed in at', el('div', 'editable readonly', formatTime(repair.createdAt)))
|
|
);
|
|
|
|
const list = document.getElementById('notes-list');
|
|
list.replaceChildren();
|
|
for (const note of repair.notes) {
|
|
const li = el('li');
|
|
li.append(el('time', null, formatTime(note.at)), document.createTextNode(note.text));
|
|
list.append(li);
|
|
}
|
|
if (repair.notes.length === 0) list.append(el('li', 'empty-note', 'No notes yet.'));
|
|
}
|
|
|
|
function openDetail(id) {
|
|
detailId = id;
|
|
renderDetail();
|
|
detail.showModal();
|
|
}
|
|
|
|
document.getElementById('detail-close').addEventListener('click', () => detail.close());
|
|
detail.addEventListener('close', () => { detailId = null; });
|
|
|
|
document.getElementById('add-note').addEventListener('click', async () => {
|
|
const box = document.getElementById('new-note');
|
|
const text = box.value.trim();
|
|
if (!text || !detailId) return;
|
|
await patchRepair(detailId, { addNote: text }).catch(() => {});
|
|
box.value = '';
|
|
});
|
|
|
|
/* ------------------------------------------------------------------- boot */
|
|
|
|
document.getElementById('intake-link').href = `/${slug}`;
|
|
document.getElementById('csv-link').href = `/${slug}/export.csv`;
|
|
|
|
function busy() {
|
|
return drag !== null || document.querySelector('dialog[open]') !== null;
|
|
}
|
|
|
|
async function init() {
|
|
const config = await api('/api/config');
|
|
categories = config.categories.map((c) => c.name);
|
|
for (const category of config.categories) colorOf.set(category.name, category.color);
|
|
|
|
buildColumns();
|
|
await refresh();
|
|
|
|
if (event.expiresAt) {
|
|
document.getElementById('expiry').textContent =
|
|
`deleted after ${new Date(event.expiresAt).toLocaleDateString()}`;
|
|
}
|
|
|
|
// Two volunteers on two laptops should see roughly the same board. Pause
|
|
// while someone is mid-drag or has a dialog open so nothing jumps.
|
|
setInterval(() => {
|
|
if (busy()) return;
|
|
refresh().catch(() => showStatus('Lost contact with the server.', true));
|
|
}, POLL_MS);
|
|
}
|
|
|
|
init().catch((err) => showStatus(err.message, true));
|