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>
226 lines
11 KiB
JavaScript
226 lines
11 KiB
JavaScript
// 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 }
|
||
}
|