diff --git a/src/components/workers/PultList.jsx b/src/components/workers/PultList.jsx index 80d45f5..0ffb16f 100644 --- a/src/components/workers/PultList.jsx +++ b/src/components/workers/PultList.jsx @@ -126,11 +126,13 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails {row.kindLabel} {row.title} {row.stale.stale && {row.stale.label}} + {row.statusLabel && {row.statusLabel}}
{row.stale.soon && {row.stale.label}} {row.claimedByLabel && übernommen von {row.claimedByLabel}} - {row.ageLabel && {row.ageLabel}} + {/* Abgeschlossen: was daraus wurde, wer es tat und wann (das Alter ab Anlage führte dort in die Irre) */} + {row.doneLine ? {row.doneLine} : row.ageLabel && {row.ageLabel}} {info && {info}} {row.sourceHref && ( Anzeige öffnen diff --git a/src/lib/workersPult.js b/src/lib/workersPult.js index 8f81182..577f37d 100644 --- a/src/lib/workersPult.js +++ b/src/lib/workersPult.js @@ -2,6 +2,8 @@ // Kein React, kein Appwrite, kein window/document beim Import: unter Node testbar (loadModule). Nichts hier versendet etwas: // der Mensch schickt die Antwort selbst ab (Plattform oder eigenes Mailprogramm) und markiert sie danach als gesendet. +import { roleOf } from './workersRoster' + const MINUTE_MS = 60 * 1000 const HOUR_MS = 60 * MINUTE_MS const DAY_MS = 24 * HOUR_MS @@ -33,6 +35,13 @@ const KOLLEGE = 'Kollege' const WARTEND = ['offen', 'in_arbeit'] // der Dienst setzt nur diese beiden auf veraltet const BEARBEITBAR = ['offen', 'in_arbeit', 'veraltet'] // in diesen Status kann ein Mensch den Eintrag abschließen +// Abgeschlossene Einträge (Reiter „Erledigt“): Bezeichnung und Badge-Ton. gesendet fällt grün auf, verworfen und erledigt bleiben neutral. +const DONE_VIEW = Object.freeze({ + gesendet: Object.freeze({ label: 'Gesendet', tone: 'green' }), + verworfen: Object.freeze({ label: 'Verworfen', tone: 'muted' }), + erledigt: Object.freeze({ label: 'Erledigt', tone: 'muted' }), +}) + const MAIL_SUBJECT_MAX = 78 const MAIL_BODY_MAX = 1800 @@ -117,6 +126,19 @@ export function employeeName(by, employees) { return listedName(by, employees) ?? KOLLEGE } +/** + * Wer einen Eintrag abgeschlossen hat, als Satzteil: `mensch:` über die Mitarbeiterliste („von Anna“, unbekannt „von Kollege“), + * eine Kennung wie `max` (Worker, der automatisch gesendet hat, P4) mit dem Namen aus dem Rollen-Register („von Max (automatisch)“), + * jede andere nichtleere Kennung „automatisch“. Ohne doneBy bleibt der Satzteil leer. Wichtig: ein Worker darf nie als „Kollege“ erscheinen, + * deshalb geht doneBy nicht durch listedName (das jede Kennung ohne Präfix als Nutzer-ID liest). + */ +function doneByPhrase(by, employees) { + if (typeof by !== 'string' || by === '') return '' + if (by.startsWith(MENSCH)) return `von ${employeeName(by, employees)}` + const role = roleOf(by) + return role ? `von ${role.name} (automatisch)` : 'automatisch' +} + /** * Zeilenmodell eines Pult-Eintrags (GET /pult: Eintrag plus `prospect`). me = { id, name } des Angemeldeten, employees = Mitarbeiterliste. * Rechte nach Status (offen, in_arbeit, veraltet sind bearbeitbar) und Art (nur Antwort-Arten sind sendbar); die Zeile sperrt @@ -143,12 +165,23 @@ export function rowModel(item, { me, nowMs, employees }) { mail = mailtoHref({ to: prospect.email, subject: prospect.name ? `Re: ${prospect.name}` : '', body: draft }) } + // Nur abgeschlossene Einträge: Ergebnis, wer es getan hat und wann (doneAt). Sonst leer, die Zeile zeigt dann das Alter ab Anlage. + const doneView = Object.hasOwn(DONE_VIEW, item.status) ? DONE_VIEW[item.status] : null + const doneByLabel = doneView ? doneByPhrase(item.doneBy, employees) : '' + const doneAgeLabel = doneView ? ageLabel(item.doneAt, nowMs) : '' + const doneLine = doneView ? [[doneView.label, doneByLabel].filter(Boolean).join(' '), doneAgeLabel].filter(Boolean).join(' · ') : '' + return { id: item.$id, kind: item.kind, kindLabel: PULT_KIND_LABELS[item.kind] ?? item.kind, title: item.title, ageLabel: ageLabel(item.createdAt, nowMs), + statusLabel: doneView ? doneView.label : '', + statusTone: doneView ? doneView.tone : '', + doneByLabel, + doneAgeLabel, + doneLine, stale: staleView(item, nowMs), canClaim: item.status === 'offen' || item.status === 'veraltet', canSend: sendable && editable, diff --git a/tests/unit/workersPult.test.mjs b/tests/unit/workersPult.test.mjs index 681b11f..61b359f 100644 --- a/tests/unit/workersPult.test.mjs +++ b/tests/unit/workersPult.test.mjs @@ -185,6 +185,57 @@ test('rowModel: Mail-Link nur bei mail_antwort mit gültiger Adresse, Betreff "R assert.equal(rowModel({ ...base, kind: 'plattform_antwort', prospect: { email: 'x@verein.example', name: 'Verein' } }, ctx).mailtoHref, '') // Plattform-Antwort geht über den Quell-Link }) +// Endprüfung P3 (W5): der Reiter „Erledigt“ zeigt, was aus einem Eintrag wurde, wer es tat und wann +test('rowModel: abgeschlossene Einträge zeigen Ergebnis, Person und Zeitpunkt; drei Status ergeben drei Bezeichnungen', () => { + const base = { $id: 'a', kind: 'plattform_antwort', title: 'T', draft: 'D', createdAt: iso(-4 * DAY), doneBy: 'mensch:u2', doneAt: iso(-2 * HOUR) } + const ctx = { me: { id: 'u1', name: 'Justin' }, nowMs: NOW, employees: [{ userId: 'u2', displayName: 'Anna' }] } + const out = {} + for (const status of ['gesendet', 'verworfen', 'erledigt']) { + const m = rowModel({ ...base, status }, ctx) + out[status] = [m.statusLabel, m.statusTone, m.doneByLabel, m.doneAgeLabel, m.doneLine] + } + assert.deepEqual(out, { + gesendet: ['Gesendet', 'green', 'von Anna', 'vor 2 Std.', 'Gesendet von Anna · vor 2 Std.'], + verworfen: ['Verworfen', 'muted', 'von Anna', 'vor 2 Std.', 'Verworfen von Anna · vor 2 Std.'], + erledigt: ['Erledigt', 'muted', 'von Anna', 'vor 2 Std.', 'Erledigt von Anna · vor 2 Std.'], + }) + assert.equal(new Set(Object.values(out).map((v) => v[0])).size, 3) + // das Alter ab Anlage bleibt im Modell (Alter-Feld unverändert), die Zeile zeigt aber die Abschlusszeit + assert.equal(rowModel({ ...base, status: 'gesendet' }, ctx).ageLabel, 'vor 4 Tagen') +}) + +test('rowModel: doneBy "mensch:" über die Mitarbeiterliste, ein Worker wie "max" wird nie zu "Kollege"', () => { + const base = { $id: 'a', kind: 'mail_antwort', status: 'gesendet', title: 'T', createdAt: iso(-DAY), doneAt: iso(-5 * MIN) } + const ctx = { me: null, nowMs: NOW, employees: [{ userId: 'u1', displayName: 'Justin' }] } + assert.equal(rowModel({ ...base, doneBy: 'mensch:u1' }, ctx).doneByLabel, 'von Justin') + assert.equal(rowModel({ ...base, doneBy: 'mensch:u9' }, ctx).doneByLabel, 'von Kollege') // unbekannte Person + assert.equal(rowModel({ ...base, doneBy: 'mensch:u1' }, { ...ctx, employees: [] }).doneByLabel, 'von Kollege') + assert.equal(rowModel({ ...base, doneBy: 'max' }, ctx).doneByLabel, 'von Max (automatisch)') // P4: Max sendet automatisch + assert.equal(rowModel({ ...base, doneBy: 'clara' }, ctx).doneLine, 'Gesendet von Clara (automatisch) · vor 5 Min.') + assert.ok(!rowModel({ ...base, doneBy: 'max' }, ctx).doneLine.includes('Kollege')) + // unbekannte Kennungen (auch eine, die wie ein Objekt-Schlüssel aussieht) sind weder Mensch noch Worker + for (const by of ['system', 'constructor', '__proto__', 'u1']) assert.equal(rowModel({ ...base, doneBy: by }, ctx).doneByLabel, 'automatisch', by) + // ohne doneBy kein Satzteil + for (const by of [undefined, null, '', 7]) assert.deepEqual([rowModel({ ...base, doneBy: by }, ctx).doneByLabel, rowModel({ ...base, doneBy: by }, ctx).doneLine], ['', 'Gesendet · vor 5 Min.'], String(by)) +}) + +test('rowModel: fehlendes oder kaputtes doneAt gibt keine Zeitangabe, offene Zeilen bleiben unverändert', () => { + const base = { $id: 'a', kind: 'plattform_antwort', status: 'verworfen', title: 'T', createdAt: iso(-DAY), doneBy: 'mensch:u1' } + const ctx = { me: null, nowMs: NOW, employees: [{ userId: 'u1', displayName: 'Justin' }] } + for (const doneAt of [undefined, null, '', 'gestern']) { + const m = rowModel({ ...base, doneAt }, ctx) + assert.deepEqual([m.doneAgeLabel, m.doneLine], ['', 'Verworfen von Justin'], String(doneAt)) + } + // offene, übernommene und veraltete Einträge: keine Abschlussfelder, auch wenn doneBy/doneAt (aus einer Altlast) gesetzt wären + for (const status of ['offen', 'in_arbeit', 'veraltet']) { + const m = rowModel({ ...base, status, doneAt: iso(-HOUR), claimedBy: 'mensch:u1' }, ctx) + assert.deepEqual([m.statusLabel, m.statusTone, m.doneByLabel, m.doneAgeLabel, m.doneLine], ['', '', '', '', ''], status) + assert.equal(m.ageLabel, 'vor 1 Tag') + } + // unbekannter Status (neuer Dienst) und Objekt-Schlüssel als Status + for (const status of ['unbekannt', 'constructor', undefined]) assert.equal(rowModel({ ...base, status }, ctx).statusLabel, '', String(status)) +}) + test('employeeName: "mensch:"-Präfix weg, Nachschlagen über userId, sonst "Kollege"', () => { const employees = [{ userId: 'u2', displayName: 'Kenso' }, { userId: 'u3', displayName: '' }, { userId: 'u4' }] assert.equal(employeeName('mensch:u2', employees), 'Kenso') diff --git a/tests/unit/workersPultUi.test.mjs b/tests/unit/workersPultUi.test.mjs index 1486dcd..c967509 100644 --- a/tests/unit/workersPultUi.test.mjs +++ b/tests/unit/workersPultUi.test.mjs @@ -357,3 +357,32 @@ test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text, assert.match(html, /