Files
tickte-system/src/lib/workersPult.js
T
ClaudeandClaude Sonnet 5.5 4ec3022df4 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>
2026-10-18 10:44:21 +00:00

226 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Reine Logik des Reiters „Freigabe“ (Pult): Filter, Alter, Veraltet-Anzeige, sichere Links, Zeilenmodell, Kopieren & Öffnen.
// 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
// Pult-Arten des Dienstes (Übersicht §4.11) mit ihrem deutschen Namen
export const PULT_KIND_LABELS = Object.freeze({
plattform_antwort: 'Antwort auf Plattform-Anfrage',
mail_antwort: 'Antwort per E-Mail',
vorschau_freigabe: 'Vorschau freigeben',
unsicher: 'Unsicher – bitte prüfen',
beschwerde: 'Beschwerde',
preisfrage: 'Preisfrage',
mensch_gewuenscht: 'Mensch gewünscht',
bau_fehlgeschlagen: 'Bau fehlgeschlagen',
})
// Nur diese Arten sind Antworten, die ein Mensch absendet; alle anderen schließt er mit „Erledigt“ ab
export const SENDABLE_KINDS = Object.freeze(['plattform_antwort', 'mail_antwort'])
// Die drei Reiter der Liste; `statuses` geht unverändert als Komma-Liste an GET /pult
export const PULT_FILTERS = Object.freeze([
Object.freeze({ id: 'offen', label: 'Wartet', statuses: 'offen,in_arbeit' }),
Object.freeze({ id: 'veraltet', label: 'Veraltet', statuses: 'veraltet' }),
Object.freeze({ id: 'erledigt', label: 'Erledigt', statuses: 'gesendet,verworfen,erledigt' }),
])
const MENSCH = 'mensch:' // Präfix von claimedBy/doneBy: mensch:<userId>
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
function timeMs(iso) {
return typeof iso === 'string' && iso !== '' ? Date.parse(iso) : NaN
}
/**
* Veraltet-Anzeige eines Eintrags: veraltet (Status veraltet oder staleAt erreicht, nur bei offen/in_arbeit), bald veraltet
* (staleAt in weniger als 24 Stunden: „läuft in N Std. ab“, aufgerundet) oder nichts.
*/
export function staleView(item, nowMs) {
if (item.status === 'veraltet') return { stale: true, soon: false, label: 'veraltet' }
if (!WARTEND.includes(item.status)) return { stale: false, soon: false, label: '' }
const staleAt = timeMs(item.staleAt)
if (Number.isNaN(staleAt)) return { stale: false, soon: false, label: '' }
const remaining = staleAt - nowMs
if (remaining <= 0) return { stale: true, soon: false, label: 'veraltet' }
if (remaining < DAY_MS) return { stale: false, soon: true, label: `läuft in ${Math.ceil(remaining / HOUR_MS)} Std. ab` }
return { stale: false, soon: false, label: '' }
}
/** Alter für die Zeile: gerade eben / vor 5 Min. / vor 3 Std. / vor 1 Tag / vor 2 Tagen; kaputte Zeit ergibt ''. */
export function ageLabel(iso, nowMs) {
const then = timeMs(iso)
if (Number.isNaN(then)) return ''
const age = nowMs - then // negativ, wenn die Uhr des Rechners nachgeht: gilt als gerade eben
if (age < MINUTE_MS) return 'gerade eben'
if (age < HOUR_MS) return `vor ${Math.floor(age / MINUTE_MS)} Min.`
if (age < DAY_MS) return `vor ${Math.floor(age / HOUR_MS)} Std.`
const days = Math.floor(age / DAY_MS)
return days === 1 ? 'vor 1 Tag' : `vor ${days} Tagen`
}
/**
* Eine Adresse, die man in einem neuen Tab öffnen darf: nur https, ohne Zugangsdaten in der Adresse (`https://www.x.de@evil.example`
* sieht nach x.de aus und führt woandershin). Ergebnis ist die vom Browser normalisierte Adresse, sonst null.
*/
export function safeExternalUrl(url) {
if (typeof url !== 'string') return null
let parsed
try {
parsed = new URL(url)
} catch {
return null
}
if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') return null
return parsed.href
}
// Genau eine Adresse: ein '@', links und rechts etwas, kein Leerraum (CR/LF eingeschlossen) und keine Zeichen, die in der
// mailto-Adresse einen zweiten Empfänger, weitere Kopfzeilen oder ein anderes Schema einschleusen würden (? & % , ; : # / \ < > ").
// Ein führendes „mailto:“ fällt über den Doppelpunkt heraus.
const MAIL_ADDRESS = /^[^\s@?&%,;:#/\\<>"\u0000-\u001f\u007f]+@[^\s@?&%,;:#/\\<>"\u0000-\u001f\u007f]+$/
/**
* mailto-Link für „Antwort per E-Mail“. `to` muss genau eine gültige Adresse sein, sonst kommt '' (die Oberfläche zeigt dann keinen
* Mail-Link). Betreff einzeilig und höchstens 78 Zeichen; Text nur bis 1800 Zeichen, sonst entfällt er (zu lange Links öffnet
* das Mailprogramm nicht). Betreff und Text sind mit encodeURIComponent kodiert, Zeilenumbrüche im Text als %0D%0A (RFC 6068).
*/
export function mailtoHref({ to, subject, body } = {}) {
if (typeof to !== 'string' || !MAIL_ADDRESS.test(to)) return ''
const params = []
const singleLine = String(subject ?? '').replace(/[\s\u0000-\u001f\u007f]+/g, ' ').trim()
const cut = Array.from(singleLine).slice(0, MAIL_SUBJECT_MAX).join('').trim()
if (cut !== '') params.push(`subject=${encodeURIComponent(cut)}`)
const text = String(body ?? '')
if (text.trim() !== '' && text.length <= MAIL_BODY_MAX) params.push(`body=${encodeURIComponent(text.replace(/\r\n|\r|\n/g, '\r\n'))}`)
return `mailto:${to}${params.length > 0 ? `?${params.join('&')}` : ''}`
}
// Anzeigename zu einer Kennung `mensch:<userId>` aus der Mitarbeiterliste ({ userId, displayName }); null, wenn keiner da ist
function listedName(by, employees) {
if (typeof by !== 'string' || by === '') return null
const userId = by.startsWith(MENSCH) ? by.slice(MENSCH.length) : by
const found = Array.isArray(employees) ? employees.find((e) => e && e.userId === userId) : undefined
return typeof found?.displayName === 'string' && found.displayName !== '' ? found.displayName : null
}
/** Name zu einer Kennung `mensch:<userId>` über die Mitarbeiterliste; unbekannt oder ohne Namen: „Kollege“. */
export function employeeName(by, employees) {
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.
* Rechte nach Status (offen, in_arbeit, veraltet sind bearbeitbar) und Art (nur Antwort-Arten sind sendbar); die Zeile sperrt
* nichts wegen eines Kollegen, der übernommen hat: der Dienst entscheidet (409 mit deutschem Satz).
*/
export function rowModel(item, { me, nowMs, employees }) {
const editable = BEARBEITBAR.includes(item.status)
const sendable = SENDABLE_KINDS.includes(item.kind)
const prospect = item.prospect ?? null
// Übernommen ist nur ein Eintrag in Arbeit; bei veraltet steht der frühere Bearbeiter noch drin, ohne etwas zu halten
const held = item.status === 'in_arbeit' && typeof item.claimedBy === 'string' && item.claimedBy !== ''
const claimedByMe = held && me != null && me.id != null && item.claimedBy === `${MENSCH}${me.id}`
const claimedByOther = held && !claimedByMe
let claimedByLabel = ''
if (held) {
const ownName = claimedByMe && typeof me.name === 'string' && me.name !== '' ? me.name : null // eigener Name, falls die Liste ihn nicht kennt
claimedByLabel = listedName(item.claimedBy, employees) ?? ownName ?? KOLLEGE
}
const draft = typeof item.draft === 'string' ? item.draft : ''
let mail = ''
if (item.kind === 'mail_antwort' && prospect !== null && prospect.email) {
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,
canDiscard: editable,
canDone: !sendable && editable,
claimedByMe,
claimedByOther,
claimedByLabel,
copyText: draft,
sourceHref: safeExternalUrl(item.sourceUrl),
mailtoHref: mail,
prospect,
}
}
/**
* „Kopieren & öffnen“: startet das Kopieren und öffnet danach sofort die Quelle, ohne dazwischen zu warten (der Browser erlaubt
* das Öffnen nur im Zug der Nutzer-Geste). Erst danach wird auf das Kopieren gewartet. `copy(text)` liefert ein Versprechen, `open(url)`
* öffnet den Tab. Ergebnis: copied = das Versprechen von copy wurde erfüllt; opened = open wurde mit einer sicheren Adresse aufgerufen
* und hat nicht geworfen (der Rückgabewert zählt nicht: window.open mit noopener liefert immer null). Ein Fehler beim Kopieren ändert
* das Öffnen nicht und umgekehrt.
*/
export async function copyAndOpen({ text, url, copy, open }) {
let copied
try {
copied = Promise.resolve(copy(text)).then(() => true, () => false)
} catch {
copied = Promise.resolve(false)
}
let opened = false
const href = safeExternalUrl(url)
if (href !== null) {
try {
open(href)
opened = true
} catch {
opened = false
}
}
return { copied: await copied, opened }
}