Hustler-Pixelwelt T9: Dein Pult (Szene, Klemmbrett mit Umschlägen, Brief mit den bisherigen Aktionen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b3fdf5d97
commit
5b5bb740c2
10 files changed
+2146
-701
No files matched your search
+121
-455
@@ -1,439 +1,87 @@
|
||||
// „Dein Pult“ (Hustler-Pixelwelt T9, Spec E11/E13/E14): der Bildschirm Freigabe. Die Szene (pult/PultSzene.jsx) füllt den Raum, davor liegt das Klemmbrett
|
||||
// (pult/Klemmbrett.jsx) mit den Umschlägen der Einträge, die ein Mensch absendet, übernimmt oder abschließt; ein Klick auf einen Umschlag öffnet den Brief
|
||||
// (pult/Brief.jsx) mit den bisherigen Aktionen. Nichts hier versendet selbst etwas: „Kopieren & öffnen“ bringt die Antwort in die Zwischenablage und öffnet die
|
||||
// Anzeige, abgeschickt wird dort von Hand. Aufbau und Klassen wie Freigabe.dc.html (Zeichenfläche) mit hk- davor; Wurzel .hk-pult.
|
||||
// Die Datei bleibt PultList.jsx (Name und Default-Export wie bisher), sie hält Zustand und Fluss: Reiter, bearbeitete Texte und Feldtexte je Eintrag (sie überleben
|
||||
// Schließen des Briefs, Filterwechsel und Neuladen), die Meldung nach einem Abschluss samt Fokus, den offenen Brief, das Interessenten-Panel, „Pult zuklappen“.
|
||||
//
|
||||
// Props: api (Dienst-API), me = { id, name } des Angemeldeten, kpis (Kennzahlen aus dem Zustand: pultOpen steuert die Zahl am Reiter „Wartet“, im Kopf und die Boten-
|
||||
// Schleife), onBack() („◂ Hochhaus“). workers (Hustler-Tabelle) reicht die Seite durch, das Pult braucht sie nicht: ein Pult-Eintrag nennt keinen Hustler.
|
||||
// Kein CSS-Import und keine Bilder (die Stile hängt WorkersPage ein), Fremdtext nur als Textknoten.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Badge from '../ui/Badge'
|
||||
import Button from '../ui/Button'
|
||||
import EmptyState from '../ui/EmptyState'
|
||||
import { copyTextChecked } from '../CopyableCredential'
|
||||
import { useEmployees } from '../../hooks/useEmployees'
|
||||
import { usePultFeed } from '../../hooks/usePultFeed'
|
||||
import { reiterZahl, wartetZahl, botenLaufen } from '../../lib/hustlerPult'
|
||||
import { activeControl, restoreFocus } from '../../lib/workersFocus'
|
||||
import {
|
||||
ACTION_FAILED, PULT_FILTERS, REPLY_TEXT_MAX, actionErrorText, charCount, copyAndOpen, mailtoHref, pasteDoneText, rowModel, validateReplyText,
|
||||
} from '../../lib/workersPult'
|
||||
import { PULT_FILTERS, rowModel } from '../../lib/workersPult'
|
||||
import Brief from './pult/Brief'
|
||||
import Klemmbrett from './pult/Klemmbrett'
|
||||
import PultSzene from './pult/PultSzene'
|
||||
import ProspectPanel from './ProspectPanel'
|
||||
|
||||
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“, „Wirklich freigeben?“ bzw. „Wirklich bauen?“ nach dem ersten Klick
|
||||
// Kosten und Wirkung von „Vorschau bauen“ (Rückfrage und Hinweis am Knopf); bewusst ohne „nur wenn der Interessent Ja gesagt hat“: der Knopf gilt gerade für unklare Antworten
|
||||
const BUILD_HINT = 'Startet einen Opus-Lauf und baut die Vorschau, als hätte der Interessent Ja gesagt.'
|
||||
const NO_FIELDS = Object.freeze({}) // Eintrag ohne bearbeitete Felder
|
||||
|
||||
// Das document des Browsers; unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
|
||||
const browserDocument = () => (typeof document === 'undefined' ? null : document)
|
||||
// Selektor des Textfelds eines aufgeklappten Feldes (Antwort oder Einfügen)
|
||||
const PANEL_FIELD = '.wk-pult-reply textarea, .wk-pult-paste textarea'
|
||||
|
||||
// Leerzustand je Filter
|
||||
const EMPTY = {
|
||||
offen: { title: 'Nichts wartet auf dich', hint: 'Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.' },
|
||||
veraltet: { title: 'Nichts ist veraltet', hint: 'Einträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.' },
|
||||
erledigt: { title: 'Noch nichts erledigt', hint: 'Gesendete, verworfene und erledigte Einträge stehen hier.' },
|
||||
}
|
||||
|
||||
// Neuer Tab ohne Rückbezug auf diese Seite
|
||||
const openTab = (url) => window.open(url, '_blank', 'noopener,noreferrer')
|
||||
|
||||
// Meldung nach „Kopieren & öffnen“: ok nur, wenn alles geklappt hat, was die Zeile anbietet (ohne Anzeigen-Adresse gibt es nur Kopieren)
|
||||
function copyNote(result, hasUrl) {
|
||||
if (!hasUrl) {
|
||||
return result.copied
|
||||
? { kind: 'ok', text: 'Kopiert.' }
|
||||
: { kind: 'err', text: 'Kopieren hat nicht geklappt. Bitte den Text von Hand markieren und kopieren.' }
|
||||
}
|
||||
if (result.copied && result.opened) return { kind: 'ok', text: 'Kopiert und geöffnet.' }
|
||||
if (result.copied) return { kind: 'err', text: 'Kopiert, aber die Anzeige ließ sich nicht öffnen. Ein Pop-up-Blocker kann der Grund sein.' }
|
||||
if (result.opened) return { kind: 'err', text: 'Geöffnet, aber das Kopieren hat nicht geklappt. Bitte den Text von Hand kopieren.' }
|
||||
return { kind: 'err', text: 'Kopieren und Öffnen haben nicht geklappt.' }
|
||||
}
|
||||
|
||||
// Zugänglicher Name für ein Bedienelement, das in jeder Zeile wiederkehrt: das sichtbare Wort steht vorn (WCAG 2.5.3 „Beschriftung im
|
||||
// Namen“), dahinter der Titel der Zeile, damit eine Liste aller „Details“-Knöpfe im Screenreader unterscheidbar ist. Ohne Titel
|
||||
// (leer oder fehlend) gilt der schlichte Name, nie „… zu undefined“.
|
||||
function rowName(label, title, joiner = ': ') {
|
||||
const t = typeof title === 'string' ? title.trim() : ''
|
||||
return t === '' ? label : `${label}${joiner}${t}`
|
||||
}
|
||||
|
||||
// Aufgeklapptes Feld unter den Aktionen: „Als Mensch antworten“ (.wk-pult-reply) und „Antwort des Kunden einfügen“ (.wk-pult-paste).
|
||||
// Textfeld, Zähler (Zeichen wie der Dienst sie zählt, höchstens REPLY_TEXT_MAX) und zwei Knöpfe; die Prüfung macht die Zeile beim Absenden.
|
||||
// Esc (aus dem ganzen Feld heraus, nicht nur aus dem Textfeld) und „Abbrechen“ schließen das Feld, der eingegebene Text bleibt in der Liste erhalten.
|
||||
// Während einer Anfrage ist beides gesperrt: das Feld soll nicht mitten im Senden verschwinden.
|
||||
function TextPanel({ className, label, hint, value, onChange, submitLabel, onSubmit, onClose, busy }) {
|
||||
const count = charCount(value)
|
||||
// preventDefault: die Handy-Leiste (App.jsx) schließt bei Esc nur, wenn das Ereignis nicht behandelt wurde
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key !== 'Escape' || busy) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
return (
|
||||
<div className={className} onKeyDown={onKeyDown}>
|
||||
{hint && <p className="wk-hint">{hint}</p>}
|
||||
<textarea className="textarea" value={value} onChange={(e) => onChange(e.target.value)} aria-label={label} autoFocus />
|
||||
<div className="wk-pult-panel-foot">
|
||||
<span className={count > REPLY_TEXT_MAX ? 'wk-pult-count over' : 'wk-pult-count'}>{count} / {REPLY_TEXT_MAX}</span>
|
||||
<div className="wk-pult-panel-btns">
|
||||
<Button type="button" onClick={onClose} disabled={busy}>Abbrechen</Button>
|
||||
<Button type="button" variant="primary" onClick={onSubmit} disabled={busy}>{submitLabel}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Eine Zeile des Pults. Die Zeile hält nur, was ihr gehört (läuft gerade etwas, Rückfrage, Meldung); der bearbeitete Text liegt in der
|
||||
// Liste, damit er einen Wechsel des Filters und das Neuladen überlebt. api.pultAction führt aus, der Dienst entscheidet über Rechte und
|
||||
// Reihenfolge (409 mit deutschem Satz); danach wird die Liste neu geholt.
|
||||
function PultRow({ api, item, row, text, fields, onText, onField, refresh, onFinished, onDetails }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [confirming, setConfirming] = useState(null) // null | 'discard' | 'approve' | 'build': welche Rückfrage gerade gilt
|
||||
const [open, setOpen] = useState(null) // null | 'reply' | 'paste': welches Feld aufgeklappt ist
|
||||
const [note, setNote] = useState(null) // { kind: 'ok' | 'err', text }
|
||||
const busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick)
|
||||
const aliveRef = useRef(true)
|
||||
const actionsRef = useRef(null) // die Aktionsgruppe: darin sucht close() den Knopf, der ein Feld geöffnet hat
|
||||
const rowRef = useRef(null) // die Zeile selbst (tabIndex -1): letzter Ort für den Fokus, wenn der Knopf nach der Aktion weg ist
|
||||
const returnTo = useRef(null) // { control, panel }: wer beim Start der Aktion den Fokus hatte und welches Feld damals offen war
|
||||
const wasBusy = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
return () => { aliveRef.current = false }
|
||||
}, [])
|
||||
|
||||
// Die Rückfrage vor dem Verwerfen, Freigeben und Bauen verfällt nach 4 s
|
||||
useEffect(() => {
|
||||
if (confirming === null) return undefined
|
||||
const timer = window.setTimeout(() => setConfirming(null), CONFIRM_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [confirming])
|
||||
|
||||
// Fokus nach einer Aktion (Endprüfung W3): die gesperrten Knöpfe verlieren den Fokus an <body>. Ist die Anfrage vorbei (busy wieder false, die Knöpfe sind
|
||||
// frei), geht er zurück: an den Knopf, der ihn hatte; ist der weg, an das offene Feld, an den Knopf, der das Feld öffnete, sonst an die Zeile.
|
||||
// Hat die Person den Fokus inzwischen selbst woanders, bleibt er dort. Eine Zeile, die die Liste verlassen hat, gibt ihn nicht mehr zurück: das tut die
|
||||
// Meldung oben (PultList).
|
||||
useEffect(() => {
|
||||
if (busy) {
|
||||
wasBusy.current = true
|
||||
return
|
||||
}
|
||||
if (!wasBusy.current) return
|
||||
wasBusy.current = false
|
||||
const memory = returnTo.current
|
||||
returnTo.current = null
|
||||
if (!memory?.control) return // beim Start hatte nichts den Fokus: es ging nichts verloren
|
||||
const row = rowRef.current
|
||||
restoreFocus(browserDocument(), [
|
||||
memory.control,
|
||||
row?.querySelector(PANEL_FIELD),
|
||||
memory.panel ? actionsRef.current?.querySelector(`[data-opens="${memory.panel}"]`) : null,
|
||||
row,
|
||||
])
|
||||
}, [busy])
|
||||
|
||||
const begin = () => {
|
||||
if (busyRef.current) return false
|
||||
busyRef.current = true
|
||||
returnTo.current = { control: activeControl(browserDocument()), panel: open } // ohne Fokus beim Start (Safari fokussiert Knöpfe nicht) gibt es nichts zurückzugeben
|
||||
setBusy(true)
|
||||
setConfirming(null)
|
||||
setNote(null)
|
||||
return true
|
||||
}
|
||||
const end = () => {
|
||||
busyRef.current = false
|
||||
if (aliveRef.current) setBusy(false)
|
||||
}
|
||||
|
||||
// Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb oben bei der Liste und nicht in der Zeile.
|
||||
// done: Meldung bei Erfolg, als Text oder als Funktion der Antwort des Dienstes. action: Name der Aktion für den Fehlertext (bei reply,
|
||||
// approve-preview und start-build heißt eine Zeitüberschreitung oder eine Fehlerseite des Proxys (502/504) „Kann gesendet sein – Pult neu laden.“, D14).
|
||||
// Ergebnis: ob es geklappt hat.
|
||||
const run = async (call, done, finished, action) => {
|
||||
if (!begin()) return false
|
||||
let ok = false
|
||||
try {
|
||||
const result = await call()
|
||||
const message = typeof done === 'function' ? done(result) : done
|
||||
if (finished) onFinished(row.id, `„${row.title}“ ${message}`, returnTo.current?.control != null)
|
||||
else if (aliveRef.current) setNote({ kind: 'ok', text: message })
|
||||
ok = true
|
||||
} catch (err) {
|
||||
if (aliveRef.current) setNote({ kind: 'err', text: actionErrorText(err, action) })
|
||||
}
|
||||
await refresh()
|
||||
end()
|
||||
return ok
|
||||
}
|
||||
|
||||
// Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
|
||||
// Kopiert wird mit der strengen Fassung (copyTextChecked): scheitern Clipboard-API und Notweg, meldet die Zeile das ehrlich,
|
||||
// statt „Kopiert“ zu zeigen, während noch der alte Inhalt der Zwischenablage drinliegt.
|
||||
// Danach wird der Eintrag best-effort übernommen; ein Kollege, der schon dran ist, erscheint als Hinweis.
|
||||
const copyOpen = async () => {
|
||||
if (!begin()) return
|
||||
const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyTextChecked, open: openTab })
|
||||
let next = copyNote(result, row.sourceHref !== null)
|
||||
if (!row.claimedByMe) {
|
||||
try {
|
||||
await api.pultAction(row.id, 'claim')
|
||||
} catch (err) {
|
||||
const hint = err?.status === 409 && row.claimedByOther ? `Wird schon von ${row.claimedByLabel} bearbeitet.` : err?.message || ACTION_FAILED
|
||||
next = { kind: 'err', text: `${next.text} ${hint}` }
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
if (aliveRef.current) setNote(next)
|
||||
end()
|
||||
}
|
||||
|
||||
const discard = () => {
|
||||
if (confirming !== 'discard') {
|
||||
setConfirming('discard')
|
||||
return
|
||||
}
|
||||
run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
|
||||
}
|
||||
|
||||
// Vorschau freigeben: der erste Klick fragt nach, der zweite binnen 4 s reiht den Link an den Interessenten ein (Max sendet im Sendefenster).
|
||||
// Nur mit gültiger Vorschau-Adresse (https, Vorschau-Server): was der Mensch nicht ansehen kann, gibt er nicht frei (Endprüfung W5).
|
||||
const approve = () => {
|
||||
if (!row.previewHref) return // ohne gültige Adresse sieht der Mensch die Vorschau nicht: der Knopf ist gesperrt, das hier fängt jeden anderen Weg ab
|
||||
if (confirming !== 'approve') {
|
||||
setConfirming('approve')
|
||||
return
|
||||
}
|
||||
run(() => api.pultAction(row.id, 'approve-preview'), 'freigegeben. Max sendet den Link im nächsten Sendefenster.', true, 'approve-preview')
|
||||
}
|
||||
|
||||
// Bau starten wie bei einem „Ja“ des Interessenten (für Einträge „unsicher“): ein Opus-Lauf, der sich nicht zurücknehmen lässt. Wie beim Freigeben
|
||||
// fragt der erste Klick nach, der zweite binnen 4 s startet den Bau.
|
||||
const startBuild = () => {
|
||||
if (confirming !== 'build') {
|
||||
setConfirming('build')
|
||||
return
|
||||
}
|
||||
run(() => api.pultAction(row.id, 'start-build'), 'wird gebaut.', true, 'start-build')
|
||||
}
|
||||
|
||||
// Die Felder „Als Mensch antworten“ und „Antwort einfügen“ sind getrennt: die Antwort ist mit dem Entwurf (oder dem bearbeiteten Text)
|
||||
// vorbelegt, das Einfüge-Feld beginnt leer. Beide liegen in der Liste, damit der Text einen Wechsel des Filters überlebt.
|
||||
const replyValue = fields.reply ?? text
|
||||
const pasteValue = fields.paste ?? ''
|
||||
const toggle = (which) => setOpen((current) => (current === which ? null : which))
|
||||
// Feld schließen (Esc, „Abbrechen“) und den Fokus an den Knopf zurückgeben, der es geöffnet hat: der bleibt eingehängt, der Fokus kann sofort dorthin.
|
||||
// Der Knopf trägt seine Kennung in data-opens (Button reicht sie weiter; ein ref ginge an Button vorbei).
|
||||
const close = (which) => {
|
||||
setOpen(null)
|
||||
actionsRef.current?.querySelector(`[data-opens="${which}"]`)?.focus()
|
||||
}
|
||||
|
||||
const sendReply = () => {
|
||||
const check = validateReplyText(replyValue)
|
||||
if (!check.ok) {
|
||||
setNote({ kind: 'err', text: check.error })
|
||||
return
|
||||
}
|
||||
run(() => api.pultAction(row.id, 'reply', { text: check.text }), 'gesendet.', true, 'reply')
|
||||
}
|
||||
|
||||
// Eingefügte Kundenantwort: bei Erfolg wird das Feld geleert und zugeklappt (der Eintrag bleibt in der Liste, Nora sortiert die Antwort ein)
|
||||
const sendPaste = () => {
|
||||
const check = validateReplyText(pasteValue)
|
||||
if (!check.ok) {
|
||||
setNote({ kind: 'err', text: check.error })
|
||||
return
|
||||
}
|
||||
run(async () => {
|
||||
const result = await api.pultAction(row.id, 'paste', { text: check.text })
|
||||
onField(row.id, 'paste', '')
|
||||
if (aliveRef.current) setOpen(null)
|
||||
return result
|
||||
}, pasteDoneText, false, 'paste')
|
||||
}
|
||||
|
||||
const hasActions = row.canSend || row.canClaim || row.canDone || row.canDiscard || row.canReply || row.canApprovePreview || row.canStartBuild || row.canPaste
|
||||
|
||||
const { prospect } = row
|
||||
const place = prospect ? [prospect.postalCode, prospect.city].filter(Boolean).join(' ') : ''
|
||||
const info = [place, prospect && prospect.score != null ? `Score ${prospect.score}` : ''].filter(Boolean).join(' · ')
|
||||
// Der Mail-Link trägt den Text aus dem Feld (auch Bearbeitetes), nicht den gespeicherten Entwurf
|
||||
const mail = row.kind === 'mail_antwort' && prospect && prospect.email
|
||||
? mailtoHref({ to: prospect.email, subject: prospect.name ? `Re: ${prospect.name}` : '', body: text })
|
||||
: ''
|
||||
|
||||
return (
|
||||
<li className="wk-pult-row" ref={rowRef} tabIndex={-1}>
|
||||
<div className="wk-pult-head">
|
||||
<Badge tone="blue">{row.kindLabel}</Badge>
|
||||
<strong className="wk-pult-title">{row.title}</strong>
|
||||
{row.stale.stale && <Badge tone="orange">{row.stale.label}</Badge>}
|
||||
{row.statusLabel && <Badge tone={row.statusTone}>{row.statusLabel}</Badge>}
|
||||
</div>
|
||||
<div className="wk-pult-meta">
|
||||
{row.stale.soon && <span>{row.stale.label}</span>}
|
||||
{row.claimedByLabel && <span>übernommen von {row.claimedByLabel}</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>}
|
||||
{row.sourceHref && (
|
||||
<a className="link" href={row.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Anzeige öffnen', row.title)}>Anzeige öffnen</a>
|
||||
)}
|
||||
{prospect && (
|
||||
<button type="button" className="link" onClick={() => onDetails(prospect.$id)} aria-label={rowName('Details', row.title, ' zu ')}>Details</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.originalText && (
|
||||
<details className="wk-pult-original">
|
||||
<summary>Originalanfrage</summary>
|
||||
<p className="wk-pult-original-text">{item.originalText}</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{row.canSend ? (
|
||||
<textarea
|
||||
className="textarea wk-pult-draft"
|
||||
value={text}
|
||||
onChange={(e) => onText(row.id, e.target.value)}
|
||||
aria-label={rowName('Entwurf der Antwort', row.title)}
|
||||
/>
|
||||
) : (
|
||||
item.draft && (
|
||||
<details className="wk-pult-original">
|
||||
<summary>Entwurf</summary>
|
||||
<p className="wk-pult-original-text">{item.draft}</p>
|
||||
</details>
|
||||
)
|
||||
)}
|
||||
|
||||
{hasActions && (
|
||||
<div className="row-actions wk-pult-actions" role="group" aria-label={rowName('Aktionen', row.title)} ref={actionsRef}>
|
||||
{row.canSend && row.kind === 'mail_antwort' && (
|
||||
mail
|
||||
? <a className="btn btn-primary" href={mail}>E-Mail schreiben</a>
|
||||
: <span className="wk-hint">Keine gültige E-Mail-Adresse hinterlegt.</span>
|
||||
)}
|
||||
{row.canSend && row.kind !== 'mail_antwort' && (
|
||||
<Button type="button" variant="primary" onClick={copyOpen} disabled={busy}>
|
||||
{row.sourceHref ? 'Kopieren & öffnen' : 'Kopieren'}
|
||||
</Button>
|
||||
)}
|
||||
{row.canClaim && (
|
||||
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'claim'), 'Du kümmerst dich darum.', false)} disabled={busy}>
|
||||
Ich kümmere mich
|
||||
</Button>
|
||||
)}
|
||||
{row.canSend && (
|
||||
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'sent', { text }), 'als gesendet markiert.', true)} disabled={busy || text.trim() === ''}>
|
||||
Als gesendet markieren
|
||||
</Button>
|
||||
)}
|
||||
{row.canReply && (
|
||||
<Button type="button" onClick={() => toggle('reply')} aria-expanded={open === 'reply'} data-opens="reply" disabled={busy}>
|
||||
Als Mensch antworten
|
||||
</Button>
|
||||
)}
|
||||
{row.canApprovePreview && (
|
||||
<>
|
||||
{row.previewHref
|
||||
? <a className="btn" href={row.previewHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Vorschau ansehen', row.title)}>Vorschau ansehen</a>
|
||||
: <span className="wk-hint">Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich – bitte den Entwurf prüfen oder verwerfen.</span>}
|
||||
<Button type="button" variant="primary" onClick={approve} disabled={busy || !row.previewHref}>
|
||||
{confirming === 'approve' ? 'Wirklich freigeben?' : 'Freigeben & senden'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{row.canStartBuild && (
|
||||
<>
|
||||
<Button type="button" onClick={startBuild} disabled={busy} title={BUILD_HINT}>
|
||||
{confirming === 'build' ? 'Wirklich bauen?' : 'Vorschau bauen'}
|
||||
</Button>
|
||||
{confirming === 'build' && <span className="wk-hint">{BUILD_HINT}</span>}
|
||||
</>
|
||||
)}
|
||||
{row.canPaste && (
|
||||
<Button type="button" onClick={() => toggle('paste')} aria-expanded={open === 'paste'} data-opens="paste" disabled={busy}>
|
||||
Antwort des Kunden einfügen
|
||||
</Button>
|
||||
)}
|
||||
{row.canDone && (
|
||||
<Button type="button" onClick={() => run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={busy}>
|
||||
Erledigt
|
||||
</Button>
|
||||
)}
|
||||
{row.canDiscard && (
|
||||
<>
|
||||
<Button type="button" variant="danger" onClick={discard} disabled={busy}>
|
||||
{confirming === 'discard' ? 'Wirklich verwerfen?' : 'Verwerfen'}
|
||||
</Button>
|
||||
{/* Eine Vorschau-Freigabe wird nur durch Freigeben gesendet; Verwerfen schließt sie, ohne dass etwas hinausgeht (D20) */}
|
||||
{row.kind === 'vorschau_freigabe' && <span className="wk-hint">„Verwerfen“ sendet die Vorschau nicht.</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open === 'reply' && row.canReply && (
|
||||
<TextPanel
|
||||
className="wk-pult-reply"
|
||||
label={rowName('Text der Antwort', row.title)}
|
||||
value={replyValue}
|
||||
onChange={(value) => onField(row.id, 'reply', value)}
|
||||
submitLabel="Jetzt senden"
|
||||
onSubmit={sendReply}
|
||||
onClose={() => close('reply')}
|
||||
busy={busy}
|
||||
/>
|
||||
)}
|
||||
{open === 'paste' && row.canPaste && (
|
||||
<TextPanel
|
||||
className="wk-pult-paste"
|
||||
label={rowName('Antwort des Kunden', row.title)}
|
||||
hint="Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf."
|
||||
value={pasteValue}
|
||||
onChange={(value) => onField(row.id, 'paste', value)}
|
||||
submitLabel="Einfügen"
|
||||
onSubmit={sendPaste}
|
||||
onClose={() => close('paste')}
|
||||
busy={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{note && <div className={`msg ${note.kind}`} role={note.kind === 'err' ? 'alert' : 'status'}>{note.text}</div>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Reiter „Freigabe“: die Einträge des Pults, die ein Mensch absendet, übernimmt oder abschließt. Nichts hier versendet selbst etwas:
|
||||
// „Kopieren & öffnen“ bringt die Antwort in die Zwischenablage und öffnet die Anzeige, abgeschickt wird dort von Hand.
|
||||
// me = { id, name } des Angemeldeten.
|
||||
export default function PultList({ api, me }) {
|
||||
export default function PultList({ api, me, kpis, onBack }) {
|
||||
const [filterId, setFilterId] = useState('offen')
|
||||
const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes
|
||||
const [fields, setFields] = useState({}) // Texte der Felder „Als Mensch antworten“ (reply) und „Antwort einfügen“ (paste) je Eintrag
|
||||
const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (seine Zeile ist dann schon weg)
|
||||
const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (sein Umschlag ist dann schon weg)
|
||||
const [spaet, setSpaet] = useState(null) // { kind, text }: Ergebnis einer Aktion, die erst nach dem Schließen ihres Briefs antwortete
|
||||
const [focusAsk, setFocusAsk] = useState(0) // zählt die Abschlüsse, bei denen die Person den Fokus auf einem Knopf hatte: auch eine gleiche Meldung zweimal nacheinander holt ihn
|
||||
const finishedRef = useRef(null)
|
||||
const [briefId, setBriefId] = useState(null) // Eintrag, dessen Brief offen ist (null = keiner)
|
||||
const openerRef = useRef(null) // der Umschlag, der den Brief geöffnet hat: dorthin geht der Fokus beim Schließen
|
||||
const backFocus = useRef(false) // true: die Person hat den Brief geschlossen, der Fokus soll an den Umschlag zurück
|
||||
const [detailId, setDetailId] = useState(null) // Interessent, dessen Panel offen ist (null = keins)
|
||||
const [zu, setZu] = useState(false) // „Pult zuklappen“: nur die Szene bleibt
|
||||
const listeRef = useRef(null)
|
||||
const filter = PULT_FILTERS.find((f) => f.id === filterId) ?? PULT_FILTERS[0]
|
||||
const { items, loading, error, refresh } = usePultFeed({ api, statuses: filter.statuses })
|
||||
const { employees } = useEmployees()
|
||||
const nowMs = Date.now()
|
||||
|
||||
// Verlässt eine Zeile die Liste, hängt ihr Knopf mit dem Fokus ab. Die Meldung oben nimmt ihn (tabIndex -1), wenn er sonst nirgends liegt:
|
||||
// Verlässt ein Eintrag die Liste, schließt sein Brief und die Knöpfe mit dem Fokus hängen ab. Die Meldung nimmt ihn (tabIndex -1), wenn er sonst nirgends liegt:
|
||||
// Tastatur- und Screenreader-Nutzer bleiben in der Nähe der Liste und hören die Bestätigung (Endprüfung W3).
|
||||
useEffect(() => {
|
||||
if (focusAsk === 0) return
|
||||
restoreFocus(browserDocument(), [finishedRef.current])
|
||||
}, [focusAsk])
|
||||
|
||||
// Schließt die Person den Brief selbst (Schließen, Esc, Hintergrund), geht der Fokus an den Umschlag zurück (sonst an den ersten Umschlag der Liste)
|
||||
useEffect(() => {
|
||||
if (briefId !== null || !backFocus.current) return
|
||||
backFocus.current = false
|
||||
const opener = openerRef.current
|
||||
openerRef.current = null
|
||||
const target = opener && opener.isConnected !== false && typeof opener.focus === 'function'
|
||||
? opener
|
||||
: listeRef.current?.querySelector?.('.hk-umschlag')
|
||||
if (target && typeof target.focus === 'function') target.focus({ preventScroll: true })
|
||||
}, [briefId])
|
||||
|
||||
const chooseFilter = (id) => {
|
||||
setFilterId(id)
|
||||
setFinished('')
|
||||
setSpaet(null)
|
||||
}
|
||||
const setText = useCallback((id, value) => setDrafts((d) => ({ ...d, [id]: value })), [])
|
||||
const setField = useCallback((id, which, value) => setFields((f) => ({ ...f, [id]: { ...f[id], [which]: value } })), [])
|
||||
const oeffne = (id, event) => {
|
||||
openerRef.current = event?.currentTarget ?? activeControl(browserDocument())
|
||||
setSpaet(null)
|
||||
setBriefId(id)
|
||||
}
|
||||
const schliesse = () => {
|
||||
backFocus.current = true
|
||||
setBriefId(null)
|
||||
}
|
||||
// Der Eintrag verlässt die Liste: sein Brief schließt (war inzwischen ein anderer offen, bleibt der), die Meldung steht oben und holt sich den Fokus
|
||||
const onFinished = useCallback((id, message, hadFocus) => {
|
||||
backFocus.current = false
|
||||
setBriefId((current) => (current === id ? null : current))
|
||||
setFinished(message)
|
||||
if (hadFocus) setFocusAsk((n) => n + 1)
|
||||
setDrafts((d) => {
|
||||
@@ -445,66 +93,84 @@ export default function PultList({ api, me }) {
|
||||
return rest
|
||||
})
|
||||
}, [])
|
||||
const onSpaet = useCallback((note) => setSpaet(note), [])
|
||||
|
||||
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
|
||||
const offen = briefId === null ? null : rows.find(({ row }) => row.id === briefId) ?? null
|
||||
// Ein Brief, dessen Eintrag nicht mehr in der Liste steht (ein Kollege war schneller), schließt sich: auf einem alten Stand wird nichts mehr gesendet
|
||||
useEffect(() => {
|
||||
if (briefId !== null && !loading && offen === null) {
|
||||
backFocus.current = true
|
||||
setBriefId(null)
|
||||
}
|
||||
})
|
||||
|
||||
let body
|
||||
if (loading) {
|
||||
body = <div className="loading">Wird geladen …</div>
|
||||
} else if (rows.length === 0 && error) {
|
||||
body = (
|
||||
<EmptyState
|
||||
title="Das Pult ist gerade nicht erreichbar"
|
||||
hint={<p>{error}</p>}
|
||||
action={<Button type="button" onClick={() => refresh()}>Neu laden</Button>}
|
||||
/>
|
||||
)
|
||||
} else if (rows.length === 0) {
|
||||
const empty = EMPTY[filter.id] ?? EMPTY.offen
|
||||
body = <EmptyState title={empty.title} hint={<p>{empty.hint}</p>} />
|
||||
} else {
|
||||
body = (
|
||||
<ul className="wk-pult-list">
|
||||
{rows.map(({ item, row }) => (
|
||||
<PultRow
|
||||
key={row.id}
|
||||
api={api}
|
||||
item={item}
|
||||
row={row}
|
||||
text={Object.hasOwn(drafts, row.id) ? drafts[row.id] : row.copyText}
|
||||
fields={Object.hasOwn(fields, row.id) ? fields[row.id] : NO_FIELDS}
|
||||
onText={setText}
|
||||
onField={setField}
|
||||
refresh={refresh}
|
||||
onFinished={onFinished}
|
||||
onDetails={setDetailId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
// Zahlen: nur der offene Reiter kennt die Länge seiner Liste; „Wartet“ sonst kpis.pultOpen (ein Abruf für drei Reiter wäre zu viel, Spec E11)
|
||||
const geladen = loading || (error && items.length === 0) ? null : items.length
|
||||
const zahlen = Object.fromEntries(PULT_FILTERS.map((f) => [f.id, reiterZahl(f.id, { aktivId: filter.id, kpis, geladen })]))
|
||||
const kopfZahl = wartetZahl(kpis, filter.id, geladen)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="wk-pult-filters">
|
||||
<div className="seg" role="group" aria-label="Filter">
|
||||
{PULT_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
className={f.id === filter.id ? 'on' : undefined}
|
||||
aria-pressed={f.id === filter.id}
|
||||
onClick={() => chooseFilter(f.id)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="hk-pult">
|
||||
<PultSzene laeuft={botenLaufen(kpis)} />
|
||||
|
||||
<div className="hk-pult-ui">
|
||||
<div className="hk-pult-kopf">
|
||||
<button type="button" className="hk-knopf hk-pult-knopf" onClick={() => { if (typeof onBack === 'function') onBack() }}>
|
||||
<span className="hk-ic hk-ic-zurueck" aria-hidden="true" />Hochhaus
|
||||
</button>
|
||||
<div className="hk-messing hk-pult-schild">
|
||||
<span className="hk-ic hk-ic-briefkasten" aria-hidden="true" />
|
||||
<h1 className="hk-pult-h1">Freigabe</h1>
|
||||
{kopfZahl !== null && (
|
||||
<span className="hk-pult-zahl"><span className="hk-nur-sr">Wartet auf dich:</span>{kopfZahl}</span>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="hk-knopf hk-pult-knopf hk-pult-zuklappen" onClick={() => setZu((z) => !z)}>
|
||||
<span className="hk-ic hk-ic-art-mail" aria-hidden="true" />{zu ? 'Pult aufklappen' : 'Pult zuklappen'}
|
||||
</button>
|
||||
</div>
|
||||
{!zu && (
|
||||
<div className="hk-pult-unten" ref={listeRef}>
|
||||
<Klemmbrett
|
||||
filterId={filter.id}
|
||||
onFilter={chooseFilter}
|
||||
zahlen={zahlen}
|
||||
laden={loading}
|
||||
fehler={error}
|
||||
rows={rows}
|
||||
onNeuLaden={refresh}
|
||||
meldung={finished}
|
||||
meldungRef={finishedRef}
|
||||
spaet={spaet}
|
||||
onOeffne={oeffne}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{finished && <div className="msg ok" role="status" tabIndex={-1} ref={finishedRef}>{finished}</div>}
|
||||
{error && rows.length > 0 && <div className="msg warn" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
|
||||
{body}
|
||||
{detailId && <ProspectPanel api={api} prospectId={detailId} onClose={() => setDetailId(null)} />}
|
||||
</>
|
||||
|
||||
{offen && (
|
||||
<Brief
|
||||
key={offen.row.id}
|
||||
api={api}
|
||||
item={offen.item}
|
||||
row={offen.row}
|
||||
text={Object.hasOwn(drafts, offen.row.id) ? drafts[offen.row.id] : offen.row.copyText}
|
||||
fields={Object.hasOwn(fields, offen.row.id) ? fields[offen.row.id] : NO_FIELDS}
|
||||
onText={setText}
|
||||
onField={setField}
|
||||
refresh={refresh}
|
||||
onFinished={onFinished}
|
||||
onClose={schliesse}
|
||||
onDetails={setDetailId}
|
||||
onSpaet={onSpaet}
|
||||
/>
|
||||
)}
|
||||
{detailId && (
|
||||
<div className="hk-woms">
|
||||
<ProspectPanel api={api} prospectId={detailId} onClose={() => setDetailId(null)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
// Der Brief des Pults (Hustler-Pixelwelt T9, Spec E11/E13/E14): Dialog über dem Klemmbrett mit dem Inhalt des bisherigen Zeilenbausteins PultRow – alle Zustände
|
||||
// (busy, confirming, open, note), alle Aktionen über api.pultAction, alle Texte, Rückfragen (4 s), Kopieren & öffnen, Mail-Link, Antworten-/Einfügen-Feld mit Zähler
|
||||
// und 8000-Grenze, Original und Entwurf – nur mit neuem Markup (Brief-Papier, Stempel-Knöpfe, Klassen der Vorlage Freigabe.dc.html mit hk-). Welche Knöpfe erscheinen,
|
||||
// entscheidet allein rowModel (src/lib/workersPult.js): canClaim, canSend, canReply, canPaste, canApprovePreview, canStartBuild, canDone, canDiscard,
|
||||
// sourceHref, previewHref, mailtoHref, claimedByOther. Der Brief hält nur, was ihm gehört (läuft gerade etwas, Rückfrage, offenes Feld, Meldung); der bearbeitete
|
||||
// Text und die Feldtexte liegen in PultList, damit sie Schließen, Filterwechsel und Neuladen überleben. api.pultAction führt aus, der Dienst entscheidet über Rechte
|
||||
// und Reihenfolge (409 mit deutschem Satz); danach wird die Liste neu geholt.
|
||||
//
|
||||
// Dialog: role="dialog" aria-modal, Fokus beim Öffnen auf den ersten Knopf („Schließen“), Tab bleibt im Brief, Esc schließt erst ein offenes Feld, dann den Brief
|
||||
// (während einer Anfrage bleibt ein offenes Feld stehen). Schließt der Brief nach einem Abschluss (onFinished), steht die Meldung in der Liste und nimmt den Fokus;
|
||||
// schließt die Person ihn selbst (onClose), gibt PultList den Fokus an den Umschlag zurück. Eine Aktion, die nach dem Schließen noch antwortet, meldet ihr Ergebnis
|
||||
// über onSpaet an die Liste (sonst ginge ein Fehler wie „Kann gesendet sein – Pult neu laden.“ verloren).
|
||||
// Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, Fremdtext nur als Textknoten.
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { copyTextChecked } from '../../CopyableCredential'
|
||||
import { umschlagModell } from '../../../lib/hustlerPult'
|
||||
import { activeControl, restoreFocus } from '../../../lib/workersFocus'
|
||||
import {
|
||||
ACTION_FAILED, REPLY_TEXT_MAX, actionErrorText, charCount, copyAndOpen, mailtoHref, pasteDoneText, validateReplyText,
|
||||
} from '../../../lib/workersPult'
|
||||
import Marken from './Marken'
|
||||
|
||||
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“, „Wirklich freigeben?“ bzw. „Wirklich bauen?“ nach dem ersten Klick
|
||||
// Kosten und Wirkung von „Vorschau bauen“ (Rückfrage und Hinweis am Knopf); bewusst ohne „nur wenn der Interessent Ja gesagt hat“: der Knopf gilt gerade für unklare Antworten
|
||||
const BUILD_HINT = 'Startet einen Opus-Lauf und baut die Vorschau, als hätte der Interessent Ja gesagt.'
|
||||
|
||||
// Das document des Browsers; unter Node (Tests, Server) gibt es keins, dann bleibt der Fokus unangetastet
|
||||
const browserDocument = () => (typeof document === 'undefined' ? null : document)
|
||||
// Selektor des Textfelds eines aufgeklappten Feldes (Antwort oder Einfügen)
|
||||
const PANEL_FIELD = '.hk-feld-panel textarea'
|
||||
|
||||
// Neuer Tab ohne Rückbezug auf diese Seite
|
||||
const openTab = (url) => window.open(url, '_blank', 'noopener,noreferrer')
|
||||
|
||||
// Meldung nach „Kopieren & öffnen“: ok nur, wenn alles geklappt hat, was der Brief anbietet (ohne Anzeigen-Adresse gibt es nur Kopieren)
|
||||
function copyNote(result, hasUrl) {
|
||||
if (!hasUrl) {
|
||||
return result.copied
|
||||
? { kind: 'ok', text: 'Kopiert.' }
|
||||
: { kind: 'err', text: 'Kopieren hat nicht geklappt. Bitte den Text von Hand markieren und kopieren.' }
|
||||
}
|
||||
if (result.copied && result.opened) return { kind: 'ok', text: 'Kopiert und geöffnet.' }
|
||||
if (result.copied) return { kind: 'err', text: 'Kopiert, aber die Anzeige ließ sich nicht öffnen. Ein Pop-up-Blocker kann der Grund sein.' }
|
||||
if (result.opened) return { kind: 'err', text: 'Geöffnet, aber das Kopieren hat nicht geklappt. Bitte den Text von Hand kopieren.' }
|
||||
return { kind: 'err', text: 'Kopieren und Öffnen haben nicht geklappt.' }
|
||||
}
|
||||
|
||||
// Zugänglicher Name für ein Bedienelement, das in jedem Brief wiederkehrt: das sichtbare Wort steht vorn (WCAG 2.5.3 „Beschriftung im
|
||||
// Namen“), dahinter der Titel des Eintrags, damit eine Liste aller „Anzeige öffnen“-Links im Screenreader unterscheidbar ist. Ohne Titel
|
||||
// (leer oder fehlend) gilt der schlichte Name, nie „… zu undefined“.
|
||||
function rowName(label, title, joiner = ': ') {
|
||||
const t = typeof title === 'string' ? title.trim() : ''
|
||||
return t === '' ? label : `${label}${joiner}${t}`
|
||||
}
|
||||
|
||||
// Stempel-Knopf der Vorlage (stempel + Art: haupt | normal | gruen | rot, stempel-frage während der Rückfrage, Icon ic-…). Alles Weitere (onClick, disabled, title,
|
||||
// aria-expanded, data-opens) geht unverändert an den Knopf.
|
||||
function Stempel({ art, icon, frage = false, aus = false, children, ...rest }) {
|
||||
const klasse = `hk-stempel hk-stempel-${art}${frage ? ' hk-stempel-frage' : ''}${aus ? ' hk-stempel-aus' : ''}`
|
||||
return (
|
||||
<button type="button" className={klasse} {...rest}>
|
||||
{icon && <span className={`hk-ic hk-ic-${icon}`} aria-hidden="true" />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Aufgeklapptes Feld unter den Aktionen: „Als Mensch antworten“ (hk-feld-antwort) und „Antwort des Kunden einfügen“ (hk-feld-einfuegen).
|
||||
// Beschriftung, Textfeld, Hinweis, Zähler (Zeichen wie der Dienst sie zählt, höchstens REPLY_TEXT_MAX) und zwei Knöpfe; die Prüfung macht der Brief beim Absenden.
|
||||
// Esc (aus dem ganzen Feld heraus, nicht nur aus dem Textfeld) und „Abbrechen“ schließen das Feld, der eingegebene Text bleibt in der Liste erhalten.
|
||||
// Während einer Anfrage ist beides gesperrt: das Feld soll nicht mitten im Senden verschwinden.
|
||||
function TextPanel({ className, caption, label, hint, value, onChange, submitLabel, onSubmit, onClose, busy }) {
|
||||
const count = charCount(value)
|
||||
const fieldRef = useRef(null)
|
||||
const panelRef = useRef(null)
|
||||
// Fokus ins Textfeld beim Aufklappen, ohne dass die Seite springt; danach rollt das ganze Feld (mit Zähler und Knöpfen) nur bei Bedarf ins Bild
|
||||
useEffect(() => {
|
||||
const el = fieldRef.current
|
||||
if (!el || typeof el.focus !== 'function') return
|
||||
el.focus({ preventScroll: true })
|
||||
const panel = panelRef.current
|
||||
if (panel && typeof panel.scrollIntoView === 'function') panel.scrollIntoView({ block: 'nearest' })
|
||||
}, [])
|
||||
// preventDefault: die Handy-Leiste (App.jsx) schließt bei Esc nur, wenn das Ereignis nicht behandelt wurde
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key !== 'Escape' || busy) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
return (
|
||||
<div className={`hk-feld-panel ${className}`} onKeyDown={onKeyDown} ref={panelRef}>
|
||||
<label className="hk-feld-label">
|
||||
<span className="hk-feld-titel">{caption}</span>
|
||||
<textarea className="hk-feld" value={value} onChange={(e) => onChange(e.target.value)} aria-label={label} ref={fieldRef} />
|
||||
</label>
|
||||
{hint && <p className="hk-brief-hinweis">{hint}</p>}
|
||||
<div className="hk-feld-fuss">
|
||||
<span className={count > REPLY_TEXT_MAX ? 'hk-zaehler hk-zaehler-ueber' : 'hk-zaehler'}>{count} / {REPLY_TEXT_MAX}</span>
|
||||
<div className="hk-feld-knoepfe">
|
||||
<Stempel art="normal" onClick={onClose} disabled={busy}>Abbrechen</Stempel>
|
||||
<Stempel art="haupt" icon="gesendet" onClick={onSubmit} disabled={busy}>{submitLabel}</Stempel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Props: api (pultAction), item (Eintrag des Dienstes: Original, Entwurf), row (rowModel), text (Entwurf oder bearbeiteter Text), fields ({ reply, paste }),
|
||||
// onText(id, value), onField(id, which, value), refresh() (Liste neu holen), onFinished(id, message, hadFocus) (der Eintrag verlässt die Liste: PultList schließt den Brief,
|
||||
// zeigt die Meldung und nimmt den Fokus), onClose() (die Person schließt), onDetails(prospectId), onSpaet(note) (Ergebnis, das nach dem Schließen eintrifft)
|
||||
export default function Brief({ api, item, row, text, fields, onText, onField, refresh, onFinished, onClose, onDetails, onSpaet }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [confirming, setConfirming] = useState(null) // null | 'discard' | 'approve' | 'build': welche Rückfrage gerade gilt
|
||||
const [open, setOpen] = useState(null) // null | 'reply' | 'paste': welches Feld aufgeklappt ist
|
||||
const [note, setNote] = useState(null) // { kind: 'ok' | 'err', text }
|
||||
const busyRef = useRef(false) // sperrt schon im selben Takt, in dem busy noch nicht greift (Doppelklick)
|
||||
const aliveRef = useRef(true)
|
||||
const actionsRef = useRef(null) // die Aktionsgruppe: darin sucht close() den Knopf, der ein Feld geöffnet hat
|
||||
const briefRef = useRef(null) // der Brief selbst (tabIndex -1): letzter Ort für den Fokus, wenn der Knopf nach der Aktion weg ist
|
||||
const ersterRef = useRef(null) // „Schließen“: Fokus beim Öffnen
|
||||
const returnTo = useRef(null) // { control, panel }: wer beim Start der Aktion den Fokus hatte und welches Feld damals offen war
|
||||
const wasBusy = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
return () => { aliveRef.current = false }
|
||||
}, [])
|
||||
|
||||
// Beim Öffnen: Fokus auf den ersten Knopf (Spec E14)
|
||||
useEffect(() => {
|
||||
const erster = ersterRef.current
|
||||
if (erster && typeof erster.focus === 'function') erster.focus({ preventScroll: true })
|
||||
}, [])
|
||||
|
||||
// Die Rückfrage vor dem Verwerfen, Freigeben und Bauen verfällt nach 4 s
|
||||
useEffect(() => {
|
||||
if (confirming === null) return undefined
|
||||
const timer = window.setTimeout(() => setConfirming(null), CONFIRM_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [confirming])
|
||||
|
||||
// Fokus nach einer Aktion (Endprüfung W3): die gesperrten Knöpfe verlieren den Fokus an <body>. Ist die Anfrage vorbei (busy wieder false, die Knöpfe sind
|
||||
// frei), geht er zurück: an den Knopf, der ihn hatte; ist der weg, an das offene Feld, an den Knopf, der das Feld öffnete, sonst an den Brief.
|
||||
// Hat die Person den Fokus inzwischen selbst woanders, bleibt er dort. Ein Brief, der nach einem Abschluss geschlossen hat, gibt ihn nicht mehr zurück:
|
||||
// das tut die Meldung in der Liste (PultList).
|
||||
useEffect(() => {
|
||||
if (busy) {
|
||||
wasBusy.current = true
|
||||
return
|
||||
}
|
||||
if (!wasBusy.current) return
|
||||
wasBusy.current = false
|
||||
const memory = returnTo.current
|
||||
returnTo.current = null
|
||||
if (!memory?.control) return // beim Start hatte nichts den Fokus: es ging nichts verloren
|
||||
const brief = briefRef.current
|
||||
restoreFocus(browserDocument(), [
|
||||
memory.control,
|
||||
brief?.querySelector(PANEL_FIELD),
|
||||
memory.panel ? actionsRef.current?.querySelector(`[data-opens="${memory.panel}"]`) : null,
|
||||
brief,
|
||||
])
|
||||
}, [busy])
|
||||
|
||||
const begin = () => {
|
||||
if (busyRef.current) return false
|
||||
busyRef.current = true
|
||||
returnTo.current = { control: activeControl(browserDocument()), panel: open } // ohne Fokus beim Start (Safari fokussiert Knöpfe nicht) gibt es nichts zurückzugeben
|
||||
setBusy(true)
|
||||
setConfirming(null)
|
||||
setNote(null)
|
||||
return true
|
||||
}
|
||||
const end = () => {
|
||||
busyRef.current = false
|
||||
if (aliveRef.current) setBusy(false)
|
||||
}
|
||||
// Meldung des Briefs; ist er schon zu, geht sie an die Liste
|
||||
const report = (next) => {
|
||||
if (aliveRef.current) setNote(next)
|
||||
else if (typeof onSpaet === 'function') onSpaet(next)
|
||||
}
|
||||
|
||||
// Aktion am Dienst. finished: der Eintrag verlässt die Liste, die Erfolgsmeldung steht deshalb bei der Liste und nicht im Brief.
|
||||
// done: Meldung bei Erfolg, als Text oder als Funktion der Antwort des Dienstes. action: Name der Aktion für den Fehlertext (bei reply,
|
||||
// approve-preview und start-build heißt eine Zeitüberschreitung oder eine Fehlerseite des Proxys (502/504) „Kann gesendet sein – Pult neu laden.“, D14).
|
||||
// Ergebnis: ob es geklappt hat.
|
||||
const run = async (call, done, finished, action) => {
|
||||
if (!begin()) return false
|
||||
let ok = false
|
||||
try {
|
||||
const result = await call()
|
||||
const message = typeof done === 'function' ? done(result) : done
|
||||
if (finished) onFinished(row.id, `„${row.title}“ ${message}`, aliveRef.current && returnTo.current?.control != null)
|
||||
else report({ kind: 'ok', text: message })
|
||||
ok = true
|
||||
} catch (err) {
|
||||
report({ kind: 'err', text: actionErrorText(err, action) })
|
||||
}
|
||||
await refresh()
|
||||
end()
|
||||
return ok
|
||||
}
|
||||
|
||||
// Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
|
||||
// Kopiert wird mit der strengen Fassung (copyTextChecked): scheitern Clipboard-API und Notweg, meldet der Brief das ehrlich,
|
||||
// statt „Kopiert“ zu zeigen, während noch der alte Inhalt der Zwischenablage drinliegt.
|
||||
// Danach wird der Eintrag best-effort übernommen; ein Kollege, der schon dran ist, erscheint als Hinweis.
|
||||
const copyOpen = async () => {
|
||||
if (!begin()) return
|
||||
const result = await copyAndOpen({ text, url: row.sourceHref, copy: copyTextChecked, open: openTab })
|
||||
let next = copyNote(result, row.sourceHref !== null)
|
||||
if (!row.claimedByMe) {
|
||||
try {
|
||||
await api.pultAction(row.id, 'claim')
|
||||
} catch (err) {
|
||||
const hint = err?.status === 409 && row.claimedByOther ? `Wird schon von ${row.claimedByLabel} bearbeitet.` : err?.message || ACTION_FAILED
|
||||
next = { kind: 'err', text: `${next.text} ${hint}` }
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
report(next)
|
||||
end()
|
||||
}
|
||||
|
||||
const discard = () => {
|
||||
if (confirming !== 'discard') {
|
||||
setConfirming('discard')
|
||||
return
|
||||
}
|
||||
run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
|
||||
}
|
||||
|
||||
// Vorschau freigeben: der erste Klick fragt nach, der zweite binnen 4 s reiht den Link an den Interessenten ein (Max sendet im Sendefenster).
|
||||
// Nur mit gültiger Vorschau-Adresse (https, Vorschau-Server): was der Mensch nicht ansehen kann, gibt er nicht frei (Endprüfung W5).
|
||||
const approve = () => {
|
||||
if (!row.previewHref) return // ohne gültige Adresse sieht der Mensch die Vorschau nicht: der Knopf ist gesperrt, das hier fängt jeden anderen Weg ab
|
||||
if (confirming !== 'approve') {
|
||||
setConfirming('approve')
|
||||
return
|
||||
}
|
||||
run(() => api.pultAction(row.id, 'approve-preview'), 'freigegeben. Max sendet den Link im nächsten Sendefenster.', true, 'approve-preview')
|
||||
}
|
||||
|
||||
// Bau starten wie bei einem „Ja“ des Interessenten (für Einträge „unsicher“): ein Opus-Lauf, der sich nicht zurücknehmen lässt. Wie beim Freigeben
|
||||
// fragt der erste Klick nach, der zweite binnen 4 s startet den Bau.
|
||||
const startBuild = () => {
|
||||
if (confirming !== 'build') {
|
||||
setConfirming('build')
|
||||
return
|
||||
}
|
||||
run(() => api.pultAction(row.id, 'start-build'), 'wird gebaut.', true, 'start-build')
|
||||
}
|
||||
|
||||
// Die Felder „Als Mensch antworten“ und „Antwort einfügen“ sind getrennt: die Antwort ist mit dem Entwurf (oder dem bearbeiteten Text)
|
||||
// vorbelegt, das Einfüge-Feld beginnt leer. Beide liegen in der Liste, damit der Text Schließen und einen Wechsel des Filters überlebt.
|
||||
const replyValue = fields.reply ?? text
|
||||
const pasteValue = fields.paste ?? ''
|
||||
const toggle = (which) => setOpen((current) => (current === which ? null : which))
|
||||
// Feld schließen (Esc, „Abbrechen“) und den Fokus an den Knopf zurückgeben, der es geöffnet hat: der bleibt eingehängt, der Fokus kann sofort dorthin.
|
||||
// Der Knopf trägt seine Kennung in data-opens.
|
||||
const close = (which) => {
|
||||
setOpen(null)
|
||||
actionsRef.current?.querySelector(`[data-opens="${which}"]`)?.focus()
|
||||
}
|
||||
|
||||
const sendReply = () => {
|
||||
const check = validateReplyText(replyValue)
|
||||
if (!check.ok) {
|
||||
setNote({ kind: 'err', text: check.error })
|
||||
return
|
||||
}
|
||||
run(() => api.pultAction(row.id, 'reply', { text: check.text }), 'gesendet.', true, 'reply')
|
||||
}
|
||||
|
||||
// Eingefügte Kundenantwort: bei Erfolg wird das Feld geleert und zugeklappt (der Eintrag bleibt in der Liste, Nora sortiert die Antwort ein)
|
||||
const sendPaste = () => {
|
||||
const check = validateReplyText(pasteValue)
|
||||
if (!check.ok) {
|
||||
setNote({ kind: 'err', text: check.error })
|
||||
return
|
||||
}
|
||||
run(async () => {
|
||||
const result = await api.pultAction(row.id, 'paste', { text: check.text })
|
||||
onField(row.id, 'paste', '')
|
||||
if (aliveRef.current) setOpen(null)
|
||||
return result
|
||||
}, pasteDoneText, false, 'paste')
|
||||
}
|
||||
|
||||
const hasActions = row.canSend || row.canClaim || row.canDone || row.canDiscard || row.canReply || row.canApprovePreview || row.canStartBuild || row.canPaste
|
||||
|
||||
const { prospect } = row
|
||||
const m = umschlagModell(row)
|
||||
// Der Mail-Link trägt den Text aus dem Feld (auch Bearbeitetes), nicht den gespeicherten Entwurf
|
||||
const mail = row.kind === 'mail_antwort' && prospect && prospect.email
|
||||
? mailtoHref({ to: prospect.email, subject: prospect.name ? `Re: ${prospect.name}` : '', body: text })
|
||||
: ''
|
||||
const original = typeof item.originalText === 'string' ? item.originalText : ''
|
||||
const draft = typeof item.draft === 'string' ? item.draft : ''
|
||||
|
||||
// Tasten am Brief: Esc schließt erst ein offenes Feld (das Textfeld schließt sich selbst, hier gilt es für den Rest des Briefs), dann den Brief;
|
||||
// Tab wandert im Kreis durch die Bedienelemente des Briefs
|
||||
const taste = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (open !== null) {
|
||||
if (!busy) close(open)
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const brief = briefRef.current
|
||||
if (!brief || typeof brief.querySelectorAll !== 'function') return
|
||||
const items = [...brief.querySelectorAll('button, textarea, a[href]')].filter((el) => !el.disabled)
|
||||
if (items.length === 0) return
|
||||
const doc = browserDocument()
|
||||
const aktiv = doc ? doc.activeElement : null
|
||||
if (!e.shiftKey && aktiv === items[items.length - 1]) {
|
||||
e.preventDefault()
|
||||
items[0].focus()
|
||||
} else if (e.shiftKey && (aktiv === items[0] || aktiv === brief)) {
|
||||
e.preventDefault()
|
||||
items[items.length - 1].focus()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hk-brief-ueber">
|
||||
<button type="button" tabIndex={-1} className="hk-brief-hinten" aria-label="Brief schließen" onClick={() => onClose()} />
|
||||
<section
|
||||
ref={briefRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={rowName(m.artLabel, row.title)}
|
||||
tabIndex={-1}
|
||||
className="hk-brief"
|
||||
onKeyDown={taste}
|
||||
>
|
||||
<div className="hk-brief-inhalt">
|
||||
<div className="hk-brief-kopf">
|
||||
{/* „Schließen“ steht im Quelltext zuerst (erster Knopf: Fokus beim Öffnen, Anfang der Fokusfalle) und sitzt per CSS rechts oben */}
|
||||
<button type="button" className="hk-knopf hk-brief-zu" ref={ersterRef} aria-label="Schließen" onClick={() => onClose()}>
|
||||
<span className="hk-ic hk-ic-schliessen" aria-hidden="true" />
|
||||
</button>
|
||||
<div className="hk-brief-titelblock">
|
||||
<div className="hk-brief-art"><span className={`hk-ic hk-ic-${m.icon}`} aria-hidden="true" />{m.artLabel}</div>
|
||||
<h2 className="hk-brief-titel">{m.titel}</h2>
|
||||
<div className="hk-brief-meta">
|
||||
{m.info && <span>{m.info}</span>}
|
||||
{m.alter && <span>{m.alter}</span>}
|
||||
<Marken m={m} />
|
||||
{row.sourceHref && (
|
||||
<a className="hk-link" href={row.sourceHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Anzeige öffnen', row.title)}>Anzeige öffnen</a>
|
||||
)}
|
||||
{prospect && (
|
||||
<button type="button" className="hk-link" onClick={() => onDetails(prospect.$id)} aria-label={rowName('Interessent', row.title, ' zu ')}>Interessent</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{original !== '' && (
|
||||
<>
|
||||
<h3 className="hk-brief-h">Originalanfrage</h3>
|
||||
<blockquote className="hk-brief-zitat">{original}</blockquote>
|
||||
</>
|
||||
)}
|
||||
|
||||
{row.canSend ? (
|
||||
<label className="hk-feld-label">
|
||||
<span className="hk-feld-titel">Entwurf der Antwort</span>
|
||||
<textarea
|
||||
className="hk-feld hk-brief-entwurf"
|
||||
value={text}
|
||||
onChange={(e) => onText(row.id, e.target.value)}
|
||||
aria-label={rowName('Entwurf der Antwort', row.title)}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
draft !== '' && (
|
||||
<>
|
||||
<h3 className="hk-brief-h">Entwurf</h3>
|
||||
<p className="hk-brief-lesen">{draft}</p>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{hasActions ? (
|
||||
<>
|
||||
<h3 className="hk-brief-h hk-brief-h-aktionen">Aktionen</h3>
|
||||
<div className="hk-aktionen" role="group" aria-label={rowName('Aktionen', row.title)} ref={actionsRef}>
|
||||
{row.canSend && row.kind === 'mail_antwort' && (
|
||||
mail
|
||||
? <a className="hk-stempel hk-stempel-haupt" href={mail}><span className="hk-ic hk-ic-art-mail" aria-hidden="true" />E-Mail schreiben</a>
|
||||
: <span className="hk-brief-hinweis">Keine gültige E-Mail-Adresse hinterlegt.</span>
|
||||
)}
|
||||
{row.canSend && row.kind !== 'mail_antwort' && (
|
||||
<Stempel art="haupt" icon="kopieren" onClick={copyOpen} disabled={busy}>
|
||||
{row.sourceHref ? 'Kopieren & öffnen' : 'Kopieren'}
|
||||
</Stempel>
|
||||
)}
|
||||
{row.canApprovePreview && (
|
||||
row.previewHref
|
||||
? <a className="hk-stempel hk-stempel-normal" href={row.previewHref} target="_blank" rel="noopener noreferrer" aria-label={rowName('Vorschau ansehen', row.title)}><span className="hk-ic hk-ic-ansehen" aria-hidden="true" />Vorschau ansehen</a>
|
||||
: <span className="hk-brief-hinweis">Keine gültige Vorschau-Adresse hinterlegt. Freigabe nicht möglich – bitte den Entwurf prüfen oder verwerfen.</span>
|
||||
)}
|
||||
{row.canClaim && (
|
||||
<Stempel art="normal" icon="uebernehmen" onClick={() => run(() => api.pultAction(row.id, 'claim'), 'Du kümmerst dich darum.', false)} disabled={busy}>
|
||||
Ich kümmere mich
|
||||
</Stempel>
|
||||
)}
|
||||
{row.canSend && (
|
||||
<Stempel
|
||||
art="gruen"
|
||||
icon="gesendet"
|
||||
aus={text.trim() === ''}
|
||||
onClick={() => run(() => api.pultAction(row.id, 'sent', { text }), 'als gesendet markiert.', true)}
|
||||
disabled={busy || text.trim() === ''}
|
||||
>
|
||||
Als gesendet markieren
|
||||
</Stempel>
|
||||
)}
|
||||
{row.canReply && (
|
||||
<Stempel art="normal" icon="antworten" onClick={() => toggle('reply')} aria-expanded={open === 'reply'} data-opens="reply" disabled={busy}>
|
||||
Als Mensch antworten
|
||||
</Stempel>
|
||||
)}
|
||||
{row.canApprovePreview && (
|
||||
<Stempel art="gruen" icon="gesendet" frage={confirming === 'approve'} onClick={approve} disabled={busy || !row.previewHref}>
|
||||
{confirming === 'approve' ? 'Wirklich freigeben?' : 'Freigeben & senden'}
|
||||
</Stempel>
|
||||
)}
|
||||
{row.canStartBuild && (
|
||||
<>
|
||||
<Stempel art="normal" icon="bauen" frage={confirming === 'build'} onClick={startBuild} disabled={busy} title={BUILD_HINT}>
|
||||
{confirming === 'build' ? 'Wirklich bauen?' : 'Vorschau bauen'}
|
||||
</Stempel>
|
||||
{confirming === 'build' && <span className="hk-brief-hinweis hk-brief-hinweis-bau">{BUILD_HINT}</span>}
|
||||
</>
|
||||
)}
|
||||
{row.canPaste && (
|
||||
<Stempel art="normal" icon="einfuegen" onClick={() => toggle('paste')} aria-expanded={open === 'paste'} data-opens="paste" disabled={busy}>
|
||||
Antwort des Kunden einfügen
|
||||
</Stempel>
|
||||
)}
|
||||
{row.canDone && (
|
||||
<Stempel art="normal" icon="erledigt" onClick={() => run(() => api.pultAction(row.id, 'done'), 'erledigt.', true)} disabled={busy}>
|
||||
Erledigt
|
||||
</Stempel>
|
||||
)}
|
||||
{row.canDiscard && (
|
||||
<>
|
||||
<Stempel art="rot" icon="verwerfen" frage={confirming === 'discard'} onClick={discard} disabled={busy}>
|
||||
{confirming === 'discard' ? 'Wirklich verwerfen?' : 'Verwerfen'}
|
||||
</Stempel>
|
||||
{/* Eine Vorschau-Freigabe wird nur durch Freigeben gesendet; Verwerfen schließt sie, ohne dass etwas hinausgeht (D20) */}
|
||||
{row.kind === 'vorschau_freigabe' && <span className="hk-brief-hinweis">„Verwerfen“ sendet die Vorschau nicht.</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="hk-brief-hinweis">Dieser Eintrag ist abgeschlossen.</p>
|
||||
)}
|
||||
|
||||
{open === 'reply' && row.canReply && (
|
||||
<TextPanel
|
||||
className="hk-feld-antwort"
|
||||
caption="Text der Antwort"
|
||||
label={rowName('Text der Antwort', row.title)}
|
||||
value={replyValue}
|
||||
onChange={(value) => onField(row.id, 'reply', value)}
|
||||
submitLabel="Jetzt senden"
|
||||
onSubmit={sendReply}
|
||||
onClose={() => close('reply')}
|
||||
busy={busy}
|
||||
/>
|
||||
)}
|
||||
{open === 'paste' && row.canPaste && (
|
||||
<TextPanel
|
||||
className="hk-feld-einfuegen"
|
||||
caption="Antwort des Kunden"
|
||||
label={rowName('Antwort des Kunden', row.title)}
|
||||
hint="Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf."
|
||||
value={pasteValue}
|
||||
onChange={(value) => onField(row.id, 'paste', value)}
|
||||
submitLabel="Einfügen"
|
||||
onSubmit={sendPaste}
|
||||
onClose={() => close('paste')}
|
||||
busy={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{note && <div className={`hk-hinweis ${note.kind === 'err' ? 'hk-hinweis-err' : 'hk-hinweis-ok'}`} role={note.kind === 'err' ? 'alert' : 'status'}>{note.text}</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Das Klemmbrett des Pults (Hustler-Pixelwelt T9, Spec E11): Reiter Wartet / Veraltet / Erledigt (PULT_FILTERS) mit Zahl, das Brett mit Klammer, Untertitel,
|
||||
// Meldungen, Laden, Fehler mit „Neu laden“, Leerzustände und die Umschläge. Ein Umschlag ist ein Knopf und öffnet den Brief (Brief.jsx); er zeigt Art-Icon,
|
||||
// Titel, Art · Alter, Ort · Score und je nach Zustand Frist, „veraltet“, Halter oder den Ergebnis-Stempel. Reine Darstellung: Daten, Zustand und Klicks
|
||||
// kommen von PultList. Aufbau und Klassen wie Freigabe.dc.html (Block „Klemmbrett“) mit hk- davor. Kein CSS-Import, keine Bilder, Fremdtext nur als Textknoten.
|
||||
//
|
||||
// „von <Hustler>“ der Vorlage fehlt mit Absicht: ein Pult-Eintrag des Dienstes (Übersicht §4.11) trägt kein Feld, das den bringenden Hustler nennt
|
||||
// (addPult kennt workerId nur beim Anlegen, gespeichert wird es nicht); erfunden wird nichts.
|
||||
import { PULT_FILTERS } from '../../../lib/workersPult'
|
||||
import { LEER, REITER_KLASSE, UNTERTITEL, reiterName, umschlagModell } from '../../../lib/hustlerPult'
|
||||
import Marken from './Marken'
|
||||
|
||||
function Umschlag({ row, onOeffne }) {
|
||||
const m = umschlagModell(row)
|
||||
return (
|
||||
<li>
|
||||
<button type="button" className="hk-umschlag" aria-haspopup="dialog" aria-label={m.label} onClick={(e) => onOeffne(row.id, e)}>
|
||||
<span className={`hk-ic hk-ic-${m.icon} hk-umschlag-art`} aria-hidden="true" />
|
||||
<span className="hk-zelle-text hk-umschlag-titel" title={m.titel}>{m.titel}</span>
|
||||
<span className="hk-klein hk-umschlag-art-alter">{m.alter ? `${m.artLabel} · ${m.alter}` : m.artLabel}</span>
|
||||
<span className="hk-umschlag-marken">
|
||||
{m.info && <span className="hk-klein hk-klein-inline">{m.info}</span>}
|
||||
<Marken m={m} />
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Props: filterId (Id des offenen Reiters), onFilter(id), zahlen ({ [filterId]: Zahl | null }), laden, fehler (Satz des Dienstes oder null), rows ([{ row }] als
|
||||
// rowModel), onNeuLaden(), meldung (Text der Erfolgsmeldung eines Abschlusses, '' = keine) mit meldungRef (nimmt den Fokus), spaet ({ kind, text } | null:
|
||||
// Ergebnis einer Aktion, deren Brief schon zu war), onOeffne(id, event)
|
||||
export default function Klemmbrett({ filterId, onFilter, zahlen, laden, fehler, rows, onNeuLaden, meldung, meldungRef, spaet, onOeffne }) {
|
||||
let inhalt
|
||||
if (laden) {
|
||||
inhalt = <div className="hk-laden" role="status">Wird geladen …</div>
|
||||
} else if (rows.length === 0 && fehler) {
|
||||
inhalt = (
|
||||
<div className="hk-leer">
|
||||
<p className="hk-leer-titel">Das Pult ist gerade nicht erreichbar</p>
|
||||
<p className="hk-leer-text">{fehler}</p>
|
||||
<button type="button" className="hk-stempel hk-stempel-normal hk-leer-knopf" onClick={() => onNeuLaden()}>Neu laden</button>
|
||||
</div>
|
||||
)
|
||||
} else if (rows.length === 0) {
|
||||
const leer = LEER[filterId] ?? LEER.offen
|
||||
inhalt = (
|
||||
<div className="hk-leer">
|
||||
<p className="hk-leer-titel">{leer.title}</p>
|
||||
<p className="hk-leer-text">{leer.hint}</p>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
inhalt = (
|
||||
<ol className="hk-liste">
|
||||
{rows.map(({ row }) => <Umschlag key={row.id} row={row} onOeffne={onOeffne} />)}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hk-reiter-leiste" role="group" aria-label="Einträge filtern">
|
||||
{PULT_FILTERS.map((f) => {
|
||||
const aktiv = f.id === filterId
|
||||
const zahl = zahlen[f.id] ?? null
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
className={`hk-reiter ${REITER_KLASSE[f.id] ?? ''}${aktiv ? ' hk-reiter-an' : ''}`}
|
||||
aria-pressed={aktiv}
|
||||
aria-label={zahl === null ? undefined : reiterName(f.label, zahl)}
|
||||
onClick={() => onFilter(f.id)}
|
||||
>
|
||||
{f.label}
|
||||
{zahl !== null && <span className="hk-zahl">{zahl}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<section className="hk-klemmbrett hk-brett" aria-label="Dein Pult">
|
||||
<span className="hk-klammer" aria-hidden="true" />
|
||||
<div className="hk-brett-kopf">
|
||||
<h2 className="hk-brett-titel">Dein Pult</h2>
|
||||
<p className="hk-brett-unter">{UNTERTITEL[filterId] ?? UNTERTITEL.offen}</p>
|
||||
</div>
|
||||
{meldung && <div className="hk-hinweis hk-hinweis-ok" role="status" tabIndex={-1} ref={meldungRef}>{meldung}</div>}
|
||||
{spaet && <div className={`hk-hinweis ${spaet.kind === 'err' ? 'hk-hinweis-err' : 'hk-hinweis-ok'}`} role={spaet.kind === 'err' ? 'alert' : 'status'}>{spaet.text}</div>}
|
||||
{fehler && rows.length > 0 && <div className="hk-hinweis hk-hinweis-warn" role="status">Verbindung gestört: {fehler} Es wird weiter versucht.</div>}
|
||||
{inhalt}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Die kleinen Marken eines Pult-Eintrags (Frist, „veraltet“, Halter, Ergebnis-Stempel): im Umschlag der Liste und in der Kopfzeile des Briefs gleich.
|
||||
// Klassen und Aufbau wie Freigabe.dc.html (marke, ic-klein, abdruck) mit hk- davor; die Farben stehen in styles/hustler/hustler-pult-ui.css.
|
||||
// Ein Marken-Satz kommt aus umschlagModell (src/lib/hustlerPult.js). Kein CSS-Import, keine Bilder, Fremdtext nur als Textknoten.
|
||||
|
||||
// Props: m = umschlagModell(row)
|
||||
export default function Marken({ m }) {
|
||||
return (
|
||||
<>
|
||||
{m.frist && (
|
||||
<span className="hk-marke hk-marke-frist"><span className="hk-ic hk-ic-klein hk-ic-sanduhr-k" aria-hidden="true" />{m.frist}</span>
|
||||
)}
|
||||
{m.gehalten && (
|
||||
<span className="hk-marke hk-marke-halter"><span className="hk-ic hk-ic-klein hk-ic-person" aria-hidden="true" />{m.gehalten}</span>
|
||||
)}
|
||||
{m.veraltet && (
|
||||
<span className="hk-marke hk-marke-veraltet"><span className="hk-ic hk-ic-klein hk-ic-veraltet" aria-hidden="true" />veraltet</span>
|
||||
)}
|
||||
{m.fertig && <span className={m.fertig.klasse}>{m.fertig.text}</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Die Szene hinter dem Klemmbrett (Hustler-Pixelwelt T9, Spec E11/E13/E14): 1:1 aus dem Markup der Zeichenfläche (pult/entwurf/Freigabe.dc.html, Block „Szene“),
|
||||
// Klassen mit hk- davor. Raum, Fensterschnee, Lichter (Flackern), Uhr mit Sekundenzeiger, Lichterkette, Aufzugtüren, Boten-Schleife (Clara, Nora), Briefkasten-Fähnchen
|
||||
// und Monitor sind reine CSS-Animationen aus styles/hustler/pult.css. Die 640 x 360 große Bühne wird „cover“ in die Fläche eingepasst (ResizeObserver, wie bindeRoot/passe
|
||||
// in logik.js). Die Boten-Schleife und das Fähnchen laufen nur, wenn `laeuft` gilt (kpis.pultOpen > 0); sonst steht die Ruhe-Klasse der Vorlage (hk-pult-ruhe) am
|
||||
// Rahmen und friert sie im Ruhebild ein. „Weniger Bewegung“ regelt das CSS der Vorlage. Dekoration: aria-hidden, keine Bedienelemente.
|
||||
// Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, Fremdtext nur als Textknoten.
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { SZENE_START, SZENE_TEILE as T, szenePassung } from '../../../lib/hustlerPult'
|
||||
|
||||
const lage = (teil) => ({ left: teil.left, top: teil.top })
|
||||
const flaeche = (teil) => ({ left: teil.left, top: teil.top, width: teil.width, height: teil.height })
|
||||
|
||||
// Props: laeuft (Boten-Schleife an, Standard aus)
|
||||
export default function PultSzene({ laeuft = false }) {
|
||||
const rahmen = useRef(null)
|
||||
const [pass, setPass] = useState(SZENE_START)
|
||||
|
||||
// Einpassen: beim Einhängen und bei jeder Größenänderung des Rahmens messen (ohne ResizeObserver nur einmal)
|
||||
useEffect(() => {
|
||||
const el = rahmen.current
|
||||
if (!el || typeof el.getBoundingClientRect !== 'function') return undefined
|
||||
const passe = () => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const next = szenePassung(r.width, r.height)
|
||||
if (next) setPass((prev) => (prev.transform === next.transform ? prev : next))
|
||||
}
|
||||
let beobachter = null
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
beobachter = new ResizeObserver(passe)
|
||||
beobachter.observe(el)
|
||||
}
|
||||
passe()
|
||||
return () => { if (beobachter) beobachter.disconnect() }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={laeuft ? 'hk-pult-szene' : 'hk-pult-szene hk-pult-ruhe'} ref={rahmen} aria-hidden="true">
|
||||
<div className="hk-pult-buehne" style={{ transform: pass.transform }}>
|
||||
<div className="hk-sz-ebene hk-sz-bild" />
|
||||
<div className="hk-pult-teil hk-pult-fenster" style={flaeche(T.fenster1)}>
|
||||
<div className="hk-sz-schnee" />
|
||||
<div className="hk-sz-sprossen1" />
|
||||
</div>
|
||||
<div className="hk-pult-teil hk-pult-fenster hk-sz-fenster2" style={flaeche(T.fenster2)}>
|
||||
<div className="hk-sz-schnee" />
|
||||
</div>
|
||||
<div className="hk-sz-sprossen2" />
|
||||
<span className="hk-spr hk-pult-teil hk-sz-auflicht" style={lage(T.auflicht)} />
|
||||
<div className="hk-sz-innen" />
|
||||
<div className="hk-boten-weg"><span className="hk-boten-bild" /></div>
|
||||
<span className="hk-spr hk-pult-teil hk-sz-tuer" style={lage(T.tuer)} />
|
||||
<span className="hk-spr hk-pult-teil hk-sz-ruftaste" style={lage(T.ruftaste)} />
|
||||
<span className="hk-spr hk-pult-teil hk-sz-funke" style={lage(T.funke)} />
|
||||
<span className="hk-spr hk-pult-teil hk-sz-ding" style={lage(T.ding)} />
|
||||
<span className="hk-spr hk-pult-teil hk-sz-briefkasten" style={lage(T.briefkasten)} />
|
||||
<span className="hk-spr hk-pult-teil hk-sz-monitor" style={lage(T.monitor)} />
|
||||
<span className="hk-sz-sekunde" />
|
||||
<span className="hk-spr hk-pult-teil hk-sz-dampf" style={lage(T.dampf)} />
|
||||
<span className="hk-pult-teil hk-pult-antenne hk-sz-antenne" style={lage(T.antenne)} />
|
||||
<div className="hk-sz-ebene hk-sz-kette0" />
|
||||
<div className="hk-sz-ebene hk-sz-kette1" />
|
||||
<div className="hk-sz-ebene hk-sz-kette2" />
|
||||
<div className="hk-sz-ebene hk-sz-ltisch" />
|
||||
<div className="hk-sz-ebene hk-sz-l55" />
|
||||
<div className="hk-sz-ebene hk-sz-l232" />
|
||||
<div className="hk-sz-ebene hk-sz-lrechts" />
|
||||
<div className="hk-sz-ebene hk-sz-lmond" />
|
||||
</div>
|
||||
<div className="hk-pult-vignette" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Reine Ableitungen des Pults in der Hustler-Pixelwelt (Spec E11/E13/E14, Plan T9): Szene (Maße, Einpassen), Boten-Schleife, Reiter und ihre Zahlen,
|
||||
// Umschlag (Art-Icon, Alter, Frist, Halter, Ergebnis-Stempel, Name für Screenreader). Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule).
|
||||
// Die Klassen heißen genau wie in src/styles/hustler/pult.css (alle mit hk- davor). Fachlich bleibt alles bei workersPult.js (rowModel, Filter, Texte);
|
||||
// hier steht nur, wie ein schon fertiges Zeilenmodell aussieht. Keine Beispieldaten: die Texte sind UI-Text.
|
||||
|
||||
import { PULT_KIND_LABELS } from './workersPult.js'
|
||||
|
||||
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
const isCount = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Szene (640 x 360 Bildpunkte, Vorlage Freigabe.dc.html): Lage der Einzelteile, soweit sie im Markup der Vorlage inline stehen
|
||||
|
||||
export const SZENE = Object.freeze({ w: 640, h: 360 })
|
||||
|
||||
/** Lage (left/top in Bildpunkten der Szene) und, wo die Vorlage sie nennt, Größe der Einzelteile. */
|
||||
export const SZENE_TEILE = Object.freeze({
|
||||
fenster1: Object.freeze({ left: 106, top: 46, width: 79, height: 55 }),
|
||||
fenster2: Object.freeze({ left: 553, top: 61, width: 47, height: 100 }),
|
||||
auflicht: Object.freeze({ left: 1, top: 320 }),
|
||||
tuer: Object.freeze({ left: 11, top: 219 }),
|
||||
ruftaste: Object.freeze({ left: 66, top: 262 }),
|
||||
funke: Object.freeze({ left: 211, top: 274 }),
|
||||
ding: Object.freeze({ left: 14, top: 197 }),
|
||||
briefkasten: Object.freeze({ left: 229, top: 232 }),
|
||||
monitor: Object.freeze({ left: 158, top: 243 }),
|
||||
dampf: Object.freeze({ left: 107, top: 244 }),
|
||||
antenne: Object.freeze({ left: 167, top: 52 }),
|
||||
})
|
||||
|
||||
/** Anfangsmaß, bevor gemessen wurde (Vorlage: Zoom 2, keine Verschiebung). */
|
||||
export const SZENE_START = Object.freeze({ s: 2, x: 0, y: 0, transform: 'translate(0.0px, 0.0px) scale(2.0000)' })
|
||||
|
||||
/**
|
||||
* Einpassen „cover“ (bindeRoot/passe in logik.js): Zoom so, dass die Szene die Fläche füllt. Ist die Szene breiter als die Fläche, wird sie um 15 %
|
||||
* des Überstands nach links geschoben (x = min(0, 0,15 · (Breite − 640 s))); senkrecht bleibt der Raum unten sichtbar
|
||||
* (y = min(Überstand / 2, Überstand + 36 s), Überstand = Höhe − 360 s). Ergebnis { s, x, y, transform } oder null bei leerer oder unbrauchbarer Fläche.
|
||||
*/
|
||||
export function szenePassung(width, height) {
|
||||
if (!(typeof width === 'number' && typeof height === 'number' && width > 0 && height > 0 && Number.isFinite(width) && Number.isFinite(height))) return null
|
||||
const s = Math.max(width / SZENE.w, height / SZENE.h)
|
||||
const ueber = height - SZENE.h * s
|
||||
const x = Math.min(0, (width - SZENE.w * s) * 0.15)
|
||||
const y = Math.min(ueber / 2, ueber + 36 * s)
|
||||
return { s, x, y, transform: `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) scale(${s.toFixed(4)})` }
|
||||
}
|
||||
|
||||
/** Boten-Schleife (Clara, Nora, Aufzug, Fähnchen) läuft nur, solange etwas wartet: kpis.pultOpen > 0 (Spec E11); sonst ruht der Raum. */
|
||||
export function botenLaufen(kpis) {
|
||||
return isRecord(kpis) && typeof kpis.pultOpen === 'number' && Number.isFinite(kpis.pultOpen) && kpis.pultOpen > 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Klemmbrett: Reiter, Untertitel, Leerzustände
|
||||
|
||||
/** Klasse des Reiters je Filter-Id (Farbe des Reiters in pult.css). */
|
||||
export const REITER_KLASSE = Object.freeze({ offen: 'hk-reiter-wartet', veraltet: 'hk-reiter-veraltet', erledigt: 'hk-reiter-erledigt' })
|
||||
|
||||
/** Untertitel des Klemmbretts je Filter (Vorlage). */
|
||||
export const UNTERTITEL = Object.freeze({
|
||||
offen: 'Hier wartet alles, was ein Mensch freigeben muss.',
|
||||
veraltet: 'Diese Einträge liegen schon zu lange. Du kannst sie weiter bearbeiten.',
|
||||
erledigt: 'Was du oder das Team schon abgeschlossen habt.',
|
||||
})
|
||||
|
||||
/** Leerzustand je Filter (Titel und Hinweis, Vorlage = die bisherigen Texte der Liste). */
|
||||
export const LEER = Object.freeze({
|
||||
offen: Object.freeze({ title: 'Nichts wartet auf dich', hint: 'Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.' }),
|
||||
veraltet: Object.freeze({ title: 'Nichts ist veraltet', hint: 'Einträge, die zu lange liegen, landen hier und lassen sich weiter bearbeiten.' }),
|
||||
erledigt: Object.freeze({ title: 'Noch nichts erledigt', hint: 'Gesendete, verworfene und erledigte Einträge stehen hier.' }),
|
||||
})
|
||||
|
||||
/**
|
||||
* „Wartet auf dich“ (Zahl im Kopf und am Reiter „Wartet“): kpis.pultOpen, wenn es eine Zahl ist; sonst, wenn der Reiter „Wartet“ offen und
|
||||
* geladen ist, die Länge der Liste; sonst null (keine Zahl zeigen, kein „0“ erfinden).
|
||||
*/
|
||||
export function wartetZahl(kpis, aktivId, geladen) {
|
||||
if (isRecord(kpis) && isCount(kpis.pultOpen)) return kpis.pultOpen
|
||||
if (aktivId === 'offen' && isCount(geladen)) return geladen
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahl am Reiter (Spec E11): „Wartet“ zeigt kpis.pultOpen (dieselbe Zahl wie der Briefkasten im Haus und der Kopf des Pults; fehlt sie, am offenen Reiter die Länge
|
||||
* der geladenen Liste), der offene Reiter „Veraltet“ oder „Erledigt“ die Länge seiner Liste (geladen = null, solange sie nicht da ist); die übrigen Reiter haben keine
|
||||
* Zahl (sonst drei Abrufe alle 30 s). Ergebnis: Zahl oder null.
|
||||
*/
|
||||
export function reiterZahl(reiterId, { aktivId, kpis, geladen } = {}) {
|
||||
if (reiterId === 'offen') return wartetZahl(kpis, aktivId, geladen)
|
||||
if (reiterId === aktivId && isCount(geladen)) return geladen
|
||||
return null
|
||||
}
|
||||
|
||||
/** Name des Reiters für Screenreader: „Wartet, 3 Einträge“; ohne Zahl nur das Wort. */
|
||||
export function reiterName(label, zahl) {
|
||||
if (!isCount(zahl)) return label
|
||||
return `${label}, ${zahl} ${zahl === 1 ? 'Eintrag' : 'Einträge'}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Umschlag
|
||||
|
||||
/** Icon (Teil der Klasse hk-ic-<icon>) je Pult-Art; unbekannte Arten bekommen das Fragezeichen-Icon. */
|
||||
export const ART_ICONS = Object.freeze({
|
||||
plattform_antwort: 'art-plattform',
|
||||
mail_antwort: 'art-mail',
|
||||
vorschau_freigabe: 'art-vorschau',
|
||||
unsicher: 'art-unsicher',
|
||||
beschwerde: 'art-beschwerde',
|
||||
preisfrage: 'art-preisfrage',
|
||||
mensch_gewuenscht: 'art-mensch',
|
||||
bau_fehlgeschlagen: 'art-bau-kaputt',
|
||||
})
|
||||
const ART_UNBEKANNT = 'art-unsicher'
|
||||
|
||||
/** Name und Icon einer Pult-Art. */
|
||||
export function artView(kind) {
|
||||
return {
|
||||
label: Object.hasOwn(PULT_KIND_LABELS, kind) ? PULT_KIND_LABELS[kind] : String(kind ?? ''),
|
||||
icon: Object.hasOwn(ART_ICONS, kind) ? ART_ICONS[kind] : ART_UNBEKANNT,
|
||||
}
|
||||
}
|
||||
|
||||
export const DU_KUEMMERST = 'Du kümmerst dich darum.'
|
||||
|
||||
/** „66640 Namborn · Score 72“ aus der Kurzfassung des Interessenten (leer, wenn nichts da ist). */
|
||||
export function prospectInfo(prospect) {
|
||||
if (!isRecord(prospect)) return ''
|
||||
const place = [prospect.postalCode, prospect.city].filter(Boolean).join(' ')
|
||||
return [place, prospect.score != null ? `Score ${prospect.score}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Was ein Umschlag (und die Kopfzeile des Briefs) zeigt, aus dem fertigen Zeilenmodell (rowModel):
|
||||
* art/icon, titel, alter (bei Abgeschlossenem leer: das Alter ab Anlage führte dort in die Irre, der Stempel nennt Ergebnis, Person und Zeit),
|
||||
* info (Ort · Score), frist („läuft in N Std. ab“), veraltet, gehalten („Du kümmerst dich darum.“ / „übernommen von …“), fertig { text, klasse } und
|
||||
* label (zugänglicher Name des Umschlags: Art: Titel, Alter, Frist, veraltet, Halter, Ergebnis).
|
||||
*/
|
||||
export function umschlagModell(row) {
|
||||
const art = artView(row.kind)
|
||||
const done = typeof row.doneLine === 'string' && row.doneLine !== ''
|
||||
const alter = done ? '' : (row.ageLabel || '')
|
||||
const frist = row.stale?.soon ? row.stale.label : ''
|
||||
const veraltet = row.stale?.stale === true
|
||||
let gehalten = ''
|
||||
if (row.claimedByMe) gehalten = DU_KUEMMERST
|
||||
else if (row.claimedByLabel) gehalten = `übernommen von ${row.claimedByLabel}`
|
||||
const fertig = done ? { text: row.doneLine, klasse: row.statusTone === 'green' ? 'hk-abdruck hk-stempel-gruen' : 'hk-abdruck hk-stempel-grau' } : null
|
||||
const titel = typeof row.title === 'string' ? row.title : ''
|
||||
const teile = [titel.trim() === '' ? art.label : `${art.label}: ${titel}`]
|
||||
if (alter) teile.push(alter)
|
||||
if (frist) teile.push(frist)
|
||||
if (veraltet) teile.push('veraltet')
|
||||
if (gehalten) teile.push(row.claimedByMe ? 'du kümmerst dich darum' : gehalten)
|
||||
if (fertig) teile.push(fertig.text)
|
||||
return { id: row.id, artLabel: art.label, icon: art.icon, titel, alter, info: prospectInfo(row.prospect), frist, veraltet, gehalten, fertig, label: teile.join(', ') }
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/* Handgeschriebene Regeln des Pults (PultList.jsx, pult/*.jsx; Hustler-Pixelwelt T9, Spec E11/E13/E14). Nicht erzeugt: Szene, Reiter, Klemmbrett, Umschlag, Brief,
|
||||
Stempel, Feld, Icons und Keyframes kommen aus pult.css (scripts/hustler-import.mjs, nie von Hand ändern). Hier stehen die Inline-Stile der Zeichenfläche
|
||||
(pixelwelt-v2/pult/entwurf/Freigabe.dc.html) als Klassen sowie das, was die Vorlage nicht zeigt (Meldungen, Laden, Leerzustand, Handy). Alle Klassen beginnen mit hk-
|
||||
und stehen unter der Wurzel .hk-pult. Die Stylesheets hängt WorkersPage ein (Komponenten importieren kein CSS); diese Datei kommt nach pult.css. */
|
||||
|
||||
/* ---------- Wurzel: füllt den Inhaltsbereich (Vorlage: 100 % x 100vh, nichts scrollt außer der Liste und dem Brief) ---------- */
|
||||
.hk-pult { position: absolute; inset: 0; overflow: hidden; color: #2b2230; font-size: 15px; line-height: 20px; }
|
||||
|
||||
/* ---------- Szene: Rahmen, Bühne (640 x 360, per transform eingepasst), Teile, Vignette ---------- */
|
||||
.hk-pult .hk-pult-szene { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
|
||||
.hk-pult .hk-pult-buehne { position: absolute; left: 0; top: 0; width: 640px; height: 360px; transform-origin: 0 0; image-rendering: pixelated; pointer-events: none; }
|
||||
.hk-pult .hk-pult-teil { position: absolute; }
|
||||
.hk-pult .hk-pult-fenster { overflow: hidden; }
|
||||
.hk-pult .hk-pult-antenne { width: 1px; height: 1px; background: #e2483d; }
|
||||
.hk-pult .hk-pult-vignette {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background: radial-gradient(ellipse 80% 75% at 32% 52%, rgba(10, 12, 28, 0) 48%, rgba(10, 12, 28, 0.38) 80%, rgba(6, 7, 18, 0.72) 100%);
|
||||
}
|
||||
|
||||
/* ---------- Oberfläche vor der Szene: Kopfzeile, Reiter, Klemmbrett ---------- */
|
||||
.hk-pult .hk-pult-ui {
|
||||
position: absolute; top: 14px; right: 16px; bottom: 14px; width: min(716px, calc(100% - 32px));
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.hk-pult .hk-pult-kopf { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin-bottom: 6px; }
|
||||
.hk-pult .hk-pult-knopf { padding: 0 10px 4px 4px; }
|
||||
.hk-pult .hk-pult-zuklappen { margin-left: auto; }
|
||||
.hk-pult .hk-pult-schild { display: flex; align-items: center; gap: 10px; padding: 0 6px; color: #3a2410; }
|
||||
.hk-pult .hk-pult-h1 { margin: 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; font-weight: 400; color: #3a2410; }
|
||||
.hk-pult .hk-pult-zahl { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 16px; line-height: 16px; }
|
||||
/* Nur für Screenreader (Beschriftung vor der Zahl) */
|
||||
.hk-pult .hk-nur-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
.hk-pult .hk-pult-unten { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
|
||||
.hk-pult .hk-reiter-leiste { position: relative; z-index: 1; display: flex; flex-wrap: nowrap; gap: 6px; padding: 0 0 0 22px; min-width: 0; }
|
||||
.hk-pult .hk-zahl { font-size: 13px; line-height: 14px; font-weight: 700; color: #5a4a3a; }
|
||||
|
||||
/* Klemmbrett: das Brett ist ein flexibler Streifen, die Liste scrollt darin */
|
||||
.hk-pult .hk-brett { position: relative; z-index: 2; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.hk-pult .hk-brett-kopf { padding: 6px 4px; border-bottom: 2px solid rgba(90, 60, 176, 0.3); }
|
||||
.hk-pult .hk-brett-titel { margin: 0; font-size: 17px; line-height: 20px; font-weight: 700; color: #4a2fa0; }
|
||||
.hk-pult .hk-brett-unter { margin: 2px 0 0; font-size: 13px; line-height: 16px; color: #5a4a3a; }
|
||||
|
||||
/* Meldungen auf dem Brett (Abschluss, später eintreffendes Ergebnis, Verbindung gestört) und im Brief */
|
||||
.hk-pult .hk-hinweis {
|
||||
margin: 0 4px; padding: 6px 10px; font-size: 14px; line-height: 18px; color: #2b2230; background: #fff7d6;
|
||||
box-shadow: 0 0 0 2px #2b2230, 2px 2px 0 2px rgba(43, 34, 48, 0.22); overflow-wrap: anywhere;
|
||||
}
|
||||
.hk-pult .hk-hinweis:focus-visible { outline: 2px solid #1a1630; box-shadow: 0 0 0 4px #ffd25a; }
|
||||
.hk-pult .hk-hinweis-ok { background: #e3f3d6; color: #1f5a2a; }
|
||||
.hk-pult .hk-hinweis-err { background: #fbe3e0; color: #8e1f17; }
|
||||
.hk-pult .hk-hinweis-warn { background: #fff3cf; color: #6b4a00; }
|
||||
.hk-pult .hk-brief .hk-hinweis { margin: 12px 0 0; }
|
||||
|
||||
/* Laden und Leerzustand */
|
||||
.hk-pult .hk-laden { margin: 8px 4px; color: #5a4a3a; }
|
||||
.hk-pult .hk-leer { margin: 8px 4px; color: #5a4a3a; }
|
||||
.hk-pult .hk-leer-titel { margin: 0; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.hk-pult .hk-leer-text { margin: 2px 0 0; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.hk-pult .hk-leer-knopf { margin-top: 10px; }
|
||||
|
||||
/* ---------- Umschlag (Knopf in der Liste): Icon links über drei Zeilen, rechts Titel, Art · Alter, Marken ---------- */
|
||||
.hk-pult .hk-liste .hk-umschlag { grid-template-columns: 32px minmax(0, 1fr); }
|
||||
.hk-pult .hk-umschlag-art { grid-area: 1 / 1 / 4 / 2; }
|
||||
.hk-pult .hk-umschlag-titel { grid-column: 2; grid-row: 1; font-size: 15px; line-height: 18px; font-weight: 700; }
|
||||
.hk-pult .hk-umschlag-art-alter { grid-column: 2; grid-row: 2; }
|
||||
.hk-pult .hk-umschlag-marken { grid-column: 2; grid-row: 3; display: flex; align-items: center; flex-wrap: wrap; gap: 2px 10px; min-height: 16px; }
|
||||
.hk-pult .hk-klein-inline { display: inline; }
|
||||
.hk-pult .hk-marke-frist { color: #9a4a10; }
|
||||
.hk-pult .hk-marke-halter { color: #4a2fa0; }
|
||||
.hk-pult .hk-marke-veraltet { color: #8a4a20; }
|
||||
|
||||
/* ---------- Brief: Dunkelfläche mit Hintergrund-Knopf, Papier in der Mitte, Inhalt scrollt ---------- */
|
||||
.hk-pult .hk-brief-ueber { position: absolute; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 16px; }
|
||||
.hk-pult .hk-brief-hinten {
|
||||
position: absolute; inset: 0; margin: 0; padding: 0; border: 0; border-radius: 0; background: rgba(6, 7, 18, 0.6); cursor: default;
|
||||
-webkit-appearance: none; appearance: none; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.hk-pult .hk-brief { position: relative; width: min(640px, 100%); max-height: 100%; display: flex; flex-direction: column; color: #2b2230; outline: none; }
|
||||
.hk-pult .hk-brief-inhalt { overflow: auto; min-height: 0; padding: 6px; scrollbar-width: thin; scrollbar-color: #5a3cb0 #e8dcc0; }
|
||||
.hk-pult .hk-brief-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.hk-pult .hk-brief-titelblock { flex: 1 1 auto; min-width: 0; order: 1; }
|
||||
.hk-pult .hk-brief-zu { order: 2; flex: none; }
|
||||
.hk-pult .hk-brief-art { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #4a2fa0; }
|
||||
.hk-pult .hk-brief-titel { margin: 6px 0 0; font-size: 19px; line-height: 24px; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.hk-pult .hk-brief-meta { margin-top: 4px; display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: #5a4a3a; }
|
||||
.hk-pult .hk-link {
|
||||
display: inline-flex; align-items: center; min-height: 24px; margin: 0; padding: 0; border: 0; background: none;
|
||||
font: inherit; color: #4a2fa0; text-decoration: underline; text-underline-offset: 3px; cursor: pointer;
|
||||
}
|
||||
.hk-pult .hk-link:hover { color: #2f1a80; }
|
||||
.hk-pult .hk-link:focus-visible { outline: 2px solid #1a1630; outline-offset: 2px; box-shadow: 0 0 0 4px #ffd25a; }
|
||||
.hk-pult .hk-brief-h { margin: 14px 0 4px; font-size: 13px; line-height: 16px; font-weight: 700; color: #4a2fa0; }
|
||||
.hk-pult .hk-brief-h-aktionen { margin: 16px 0 6px; }
|
||||
.hk-pult .hk-brief-zitat {
|
||||
margin: 0; padding: 8px 10px; background: #f1ead6; box-shadow: inset 4px 0 0 0 #c9a25c;
|
||||
white-space: pre-line; font-size: 14px; line-height: 18px; overflow-wrap: anywhere;
|
||||
}
|
||||
.hk-pult .hk-feld-label { display: block; margin-top: 14px; }
|
||||
.hk-pult .hk-feld-titel { display: block; margin-bottom: 4px; font-size: 13px; line-height: 16px; color: #4a2fa0; }
|
||||
.hk-pult .hk-brief-entwurf { min-height: 96px; background: repeating-linear-gradient(#fffdf6 0 17px, rgba(90, 60, 176, 0.16) 17px 18px); }
|
||||
.hk-pult .hk-brief-lesen {
|
||||
margin: 0; padding: 2px 10px 4px; white-space: pre-line; font-size: 15px; line-height: 20px; overflow-wrap: anywhere;
|
||||
background: repeating-linear-gradient(transparent 0 19px, rgba(90, 60, 176, 0.18) 19px 20px);
|
||||
}
|
||||
.hk-pult .hk-aktionen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
|
||||
.hk-pult .hk-brief-hinweis { margin: 0; font-size: 13px; color: #5a4a3a; }
|
||||
.hk-pult .hk-brief-hinweis-bau { color: #7a2e3a; }
|
||||
.hk-pult .hk-stempel:disabled { opacity: 0.5; cursor: default; }
|
||||
.hk-pult a.hk-stempel { text-decoration: none; }
|
||||
.hk-pult .hk-feld-panel { margin-top: 14px; }
|
||||
.hk-pult .hk-feld-antwort,
|
||||
.hk-pult .hk-feld-einfuegen { padding-top: 6px; border-top: 2px dashed rgba(90, 60, 176, 0.25); }
|
||||
.hk-pult .hk-feld-panel .hk-feld-label { margin-top: 0; }
|
||||
.hk-pult .hk-feld-panel .hk-brief-hinweis { margin: 6px 0 0; }
|
||||
.hk-pult .hk-feld-fuss { margin-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
||||
.hk-pult .hk-zaehler { font-size: 13px; line-height: 16px; color: #5a4a3a; }
|
||||
.hk-pult .hk-zaehler-ueber { color: #a8261d; font-weight: 700; }
|
||||
.hk-pult .hk-feld-knoepfe { display: flex; gap: 10px; }
|
||||
|
||||
/* ---------- Handy (unter 720 px, Spec E13): Klemmbrett in voller Breite über der abgedunkelten Szene, Brief als Vollbild ---------- */
|
||||
@media (max-width: 719px) {
|
||||
.hk-pult .hk-pult-vignette {
|
||||
background: linear-gradient(rgba(6, 7, 18, 0.55), rgba(6, 7, 18, 0.55)), radial-gradient(ellipse 80% 75% at 32% 52%, rgba(10, 12, 28, 0) 48%, rgba(10, 12, 28, 0.38) 80%, rgba(6, 7, 18, 0.72) 100%);
|
||||
}
|
||||
.hk-pult .hk-pult-ui { left: 8px; right: 8px; top: 8px; bottom: 8px; width: auto; }
|
||||
.hk-pult .hk-reiter-leiste { padding-left: 8px; }
|
||||
/* Kopfzeile in zwei Reihen: Zurück und Zuklappen nebeneinander, darunter das Messingschild; die Klammer entfällt (sie läge auf dem dritten Reiter) */
|
||||
.hk-pult .hk-pult-kopf { gap: 4px 8px; margin-bottom: 2px; }
|
||||
.hk-pult .hk-pult-knopf { order: 1; }
|
||||
.hk-pult .hk-pult-schild { order: 2; flex: 1 1 100%; }
|
||||
.hk-pult .hk-klammer { display: none; }
|
||||
.hk-pult .hk-brief-ueber { padding: 0; }
|
||||
.hk-pult .hk-brief { width: 100%; height: 100%; max-height: 100%; }
|
||||
.hk-pult .hk-feld-fuss { flex-direction: column; align-items: stretch; }
|
||||
.hk-pult .hk-feld-knoepfe { flex-direction: column-reverse; }
|
||||
.hk-pult .hk-feld-knoepfe .hk-stempel { justify-content: center; }
|
||||
}
|
||||
Reference in new issue
Block a user