Workers P3-Endprüfung W5: Reiter Erledigt zeigt Ergebnis, Person und Zeitpunkt
Abgeschlossene Zeilen tragen jetzt ein Badge (Gesendet grün, Verworfen/Erledigt neutral) und die Zeile "Gesendet von Anna · vor 2 Std." aus doneBy/doneAt statt des Alters ab Anlage. Menschen (mensch:<id>) werden über die Mitarbeiterliste aufgelöst, ein Worker wie max über das Rollen-Register als "Max (automatisch)" und nie als "Kollege". Offene Zeilen bleiben unverändert. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
62e7994508
commit
4ec3022df4
4 files changed
+116
-1
No files matched your search
@@ -126,11 +126,13 @@ function PultRow({ api, item, row, text, onText, refresh, onFinished, onDetails
|
|||||||
<Badge tone="blue">{row.kindLabel}</Badge>
|
<Badge tone="blue">{row.kindLabel}</Badge>
|
||||||
<strong className="wk-pult-title">{row.title}</strong>
|
<strong className="wk-pult-title">{row.title}</strong>
|
||||||
{row.stale.stale && <Badge tone="orange">{row.stale.label}</Badge>}
|
{row.stale.stale && <Badge tone="orange">{row.stale.label}</Badge>}
|
||||||
|
{row.statusLabel && <Badge tone={row.statusTone}>{row.statusLabel}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<div className="wk-pult-meta">
|
<div className="wk-pult-meta">
|
||||||
{row.stale.soon && <span>{row.stale.label}</span>}
|
{row.stale.soon && <span>{row.stale.label}</span>}
|
||||||
{row.claimedByLabel && <span>übernommen von {row.claimedByLabel}</span>}
|
{row.claimedByLabel && <span>übernommen von {row.claimedByLabel}</span>}
|
||||||
{row.ageLabel && <span>{row.ageLabel}</span>}
|
{/* Abgeschlossen: was daraus wurde, wer es tat und wann (das Alter ab Anlage führte dort in die Irre) */}
|
||||||
|
{row.doneLine ? <span>{row.doneLine}</span> : row.ageLabel && <span>{row.ageLabel}</span>}
|
||||||
{info && <span>{info}</span>}
|
{info && <span>{info}</span>}
|
||||||
{row.sourceHref && (
|
{row.sourceHref && (
|
||||||
<a className="link" href={row.sourceHref} target="_blank" rel="noopener noreferrer">Anzeige öffnen</a>
|
<a className="link" href={row.sourceHref} target="_blank" rel="noopener noreferrer">Anzeige öffnen</a>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// Kein React, kein Appwrite, kein window/document beim Import: unter Node testbar (loadModule). Nichts hier versendet etwas:
|
// 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.
|
// 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 MINUTE_MS = 60 * 1000
|
||||||
const HOUR_MS = 60 * MINUTE_MS
|
const HOUR_MS = 60 * MINUTE_MS
|
||||||
const DAY_MS = 24 * HOUR_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 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
|
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_SUBJECT_MAX = 78
|
||||||
const MAIL_BODY_MAX = 1800
|
const MAIL_BODY_MAX = 1800
|
||||||
|
|
||||||
@@ -117,6 +126,19 @@ export function employeeName(by, employees) {
|
|||||||
return listedName(by, employees) ?? KOLLEGE
|
return listedName(by, employees) ?? KOLLEGE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wer einen Eintrag abgeschlossen hat, als Satzteil: `mensch:<userId>` ü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.
|
* 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
|
* 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 })
|
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 {
|
return {
|
||||||
id: item.$id,
|
id: item.$id,
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
kindLabel: PULT_KIND_LABELS[item.kind] ?? item.kind,
|
kindLabel: PULT_KIND_LABELS[item.kind] ?? item.kind,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
ageLabel: ageLabel(item.createdAt, nowMs),
|
ageLabel: ageLabel(item.createdAt, nowMs),
|
||||||
|
statusLabel: doneView ? doneView.label : '',
|
||||||
|
statusTone: doneView ? doneView.tone : '',
|
||||||
|
doneByLabel,
|
||||||
|
doneAgeLabel,
|
||||||
|
doneLine,
|
||||||
stale: staleView(item, nowMs),
|
stale: staleView(item, nowMs),
|
||||||
canClaim: item.status === 'offen' || item.status === 'veraltet',
|
canClaim: item.status === 'offen' || item.status === 'veraltet',
|
||||||
canSend: sendable && editable,
|
canSend: sendable && editable,
|
||||||
|
|||||||
@@ -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
|
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:<id>" ü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"', () => {
|
test('employeeName: "mensch:"-Präfix weg, Nachschlagen über userId, sonst "Kollege"', () => {
|
||||||
const employees = [{ userId: 'u2', displayName: 'Kenso' }, { userId: 'u3', displayName: '' }, { userId: 'u4' }]
|
const employees = [{ userId: 'u2', displayName: 'Kenso' }, { userId: 'u3', displayName: '' }, { userId: 'u4' }]
|
||||||
assert.equal(employeeName('mensch:u2', employees), 'Kenso')
|
assert.equal(employeeName('mensch:u2', employees), 'Kenso')
|
||||||
|
|||||||
@@ -357,3 +357,32 @@ test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text,
|
|||||||
assert.match(html, /<button type="button" class="on" aria-pressed="true">Wartet<\/button><button type="button" aria-pressed="false">Veraltet<\/button><button type="button" aria-pressed="false">Erledigt<\/button>/)
|
assert.match(html, /<button type="button" class="on" aria-pressed="true">Wartet<\/button><button type="button" aria-pressed="false">Veraltet<\/button><button type="button" aria-pressed="false">Erledigt<\/button>/)
|
||||||
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Endprüfung P3 (W5): Reiter „Erledigt“ – was wurde aus dem Eintrag, wer, wann
|
||||||
|
test('PultList (gerendert): abgeschlossene Einträge zeigen Ergebnis, Person und Zeit der Erledigung statt des Alters ab Anlage (W5)', async () => {
|
||||||
|
const { render } = await loadList()
|
||||||
|
const now = Date.now()
|
||||||
|
const iso = (ms) => new Date(ms).toISOString()
|
||||||
|
const show = (items) => {
|
||||||
|
globalThis.__pultFeed = { items, loading: false, error: null, refresh: async () => {} }
|
||||||
|
globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Anna' }]
|
||||||
|
return render({ api: {}, me: { id: 'u1', name: 'Justin' } })
|
||||||
|
}
|
||||||
|
const base = { kind: 'plattform_antwort', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 4 * 24 * 3600e3), doneBy: 'mensch:u2', doneAt: iso(now - 2 * 3600e3) }
|
||||||
|
try {
|
||||||
|
const html = show([
|
||||||
|
{ ...base, $id: 'a', status: 'gesendet' },
|
||||||
|
{ ...base, $id: 'b', status: 'verworfen', title: 'Zweite' },
|
||||||
|
{ ...base, $id: 'c', status: 'erledigt', kind: 'unsicher', title: 'Dritte', doneBy: 'max' },
|
||||||
|
])
|
||||||
|
assert.ok(html.includes('<span class="badge green">Gesendet</span>'))
|
||||||
|
assert.ok(html.includes('<span class="badge">Verworfen</span>')); assert.ok(html.includes('<span class="badge">Erledigt</span>'))
|
||||||
|
assert.ok(html.includes('<span>Gesendet von Anna · vor 2 Std.</span>')); assert.ok(html.includes('<span>Verworfen von Anna · vor 2 Std.</span>'))
|
||||||
|
assert.ok(html.includes('<span>Erledigt von Max (automatisch) · vor 2 Std.</span>'))
|
||||||
|
assert.ok(!html.includes('vor 4 Tagen')) // das Alter ab Anlage steht bei Abgeschlossenem nicht mehr da
|
||||||
|
assert.ok(!html.includes('Kollege'))
|
||||||
|
// offene Zeile: wie bisher Alter ab Anlage, kein Ergebnis-Badge
|
||||||
|
const open = show([{ ...base, $id: 'd', status: 'offen', doneBy: undefined, doneAt: undefined }])
|
||||||
|
assert.ok(open.includes('<span>vor 4 Tagen</span>')); assert.ok(!open.includes('Gesendet')); assert.ok(!open.includes('badge green'))
|
||||||
|
} finally { delete globalThis.__pultFeed; delete globalThis.__pultEmployees }
|
||||||
|
})
|
||||||
Reference in new issue
Block a user