Workers P3: Reiter Freigabe mit Kopieren & öffnen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 09:47:38 +00:00
1 parent 777d5272bf
commit f8f8df2cbd
6 files changed
+750 -4

No files matched your search

+287
View File
@@ -0,0 +1,287 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import Badge from '../ui/Badge'
import Button from '../ui/Button'
import EmptyState from '../ui/EmptyState'
import { copyText } from '../CopyableCredential'
import { useEmployees } from '../../hooks/useEmployees'
import { usePultFeed } from '../../hooks/usePultFeed'
import { PULT_FILTERS, copyAndOpen, mailtoHref, rowModel } from '../../lib/workersPult'
const CONFIRM_MS = 4000 // so lange gilt die Rückfrage „Wirklich verwerfen?“ nach dem ersten Klick
const ACTION_FAILED = 'Das hat nicht geklappt.'
// 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.' }
}
// 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, onText, refresh, onFinished, onDetails }) {
const [busy, setBusy] = useState(false)
const [confirming, setConfirming] = useState(false)
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)
useEffect(() => {
aliveRef.current = true
return () => { aliveRef.current = false }
}, [])
// Die Rückfrage vor dem Verwerfen verfällt nach 4 s
useEffect(() => {
if (!confirming) return undefined
const timer = window.setTimeout(() => setConfirming(false), CONFIRM_MS)
return () => window.clearTimeout(timer)
}, [confirming])
const begin = () => {
if (busyRef.current) return false
busyRef.current = true
setBusy(true)
setConfirming(false)
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.
const run = async (call, done, finished) => {
if (!begin()) return
try {
await call()
if (finished) onFinished(row.id, `„${row.title}“ ${done}`)
else if (aliveRef.current) setNote({ kind: 'ok', text: done })
} catch (err) {
if (aliveRef.current) setNote({ kind: 'err', text: err?.message || ACTION_FAILED })
}
await refresh()
end()
}
// Kopieren & öffnen: copyAndOpen steht ganz vorn und ohne await davor, nur im Zug des Klicks darf der Browser den Tab öffnen.
// 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: copyText, 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) {
setConfirming(true)
return
}
run(() => api.pultAction(row.id, 'discard', {}), 'verworfen.', true)
}
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">
<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>}
</div>
<div className="wk-pult-meta">
{row.stale.soon && <span>{row.stale.label}</span>}
{row.claimedByLabel && <span>übernommen von {row.claimedByLabel}</span>}
{row.ageLabel && <span>{row.ageLabel}</span>}
{info && <span>{info}</span>}
{row.sourceHref && (
<a className="link" href={row.sourceHref} target="_blank" rel="noopener noreferrer">Anzeige öffnen</a>
)}
{prospect && (
<button type="button" className="link" onClick={() => onDetails(prospect.$id)}>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="Entwurf der Antwort"
/>
) : (
item.draft && (
<details className="wk-pult-original">
<summary>Entwurf</summary>
<p className="wk-pult-original-text">{item.draft}</p>
</details>
)
)}
{(row.canSend || row.canClaim || row.canDone || row.canDiscard) && (
<div className="row-actions wk-pult-actions">
{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.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 ? 'Wirklich verwerfen?' : 'Verwerfen'}
</Button>
)}
</div>
)}
{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 }) {
const [filterId, setFilterId] = useState('offen')
const [drafts, setDrafts] = useState({}) // bearbeitete Texte je Eintrag; was fehlt, zeigt den Entwurf des Dienstes
const [finished, setFinished] = useState('') // Meldung über den zuletzt abgeschlossenen Eintrag (seine Zeile ist dann schon weg)
const [, setDetailId] = useState(null) // „Details“ öffnet ab P3-T20 das Interessenten-Panel
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()
const chooseFilter = (id) => {
setFilterId(id)
setFinished('')
}
const setText = useCallback((id, value) => setDrafts((d) => ({ ...d, [id]: value })), [])
const onFinished = useCallback((id, message) => {
setFinished(message)
setDrafts((d) => {
const { [id]: _gone, ...rest } = d
return rest
})
}, [])
const rows = items.map((item) => ({ item, row: rowModel(item, { me, nowMs, employees }) }))
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}
onText={setText}
refresh={refresh}
onFinished={onFinished}
onDetails={setDetailId}
/>
))}
</ul>
)
}
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>
</div>
{finished && <div className="msg ok" role="status">{finished}</div>}
{error && rows.length > 0 && <div className="msg warn" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
{body}
</>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { useCallback, useEffect, useRef, useState } from 'react'
// Die Liste des Pults wird bei sichtbarem Tab alle 30 s neu geholt
const POLL_MS = 30000
const UNKNOWN_ERROR = 'Unbekannter Fehler'
const isVisible = () => document.visibilityState === 'visible'
// { items, loading, error, refresh }: Pult-Einträge von der Dienst-API (api.pult). `statuses` ist die Komma-Liste eines Filters aus
// PULT_FILTERS (ein Text, kein Array: er ist der Schlüssel des Abrufs). Beim Start und bei jedem Wechsel des Filters wird sofort
// geladen, danach alle 30 s und beim Zurückkehren in den Tab (visibilitychange), jeweils nur bei sichtbarem Tab.
// items zeigt nie die Einträge eines anderen Filters (loading, bis die erste Antwort für diesen Filter da ist); schlägt ein
// späterer Abruf fehl, bleiben die letzten Einträge stehen und error ist der deutsche Satz aus der API (sonst null).
// Nur die jüngste Anfrage zählt: eine späte Antwort (auch ein später Fehler) einer älteren Anfrage, eines früheren Filters
// oder nach dem Abbau wird verworfen. refresh() holt sofort neu (z. B. nach einer Aktion) und endet, wenn die Antwort verarbeitet ist.
export function usePultFeed({ api, statuses }) {
const key = Array.isArray(statuses) ? statuses.join(',') : String(statuses ?? '')
const [state, setState] = useState({ key: null, items: [], error: null })
const seqRef = useRef(0)
const aliveRef = useRef(true)
const loadRef = useRef(null)
useEffect(() => {
aliveRef.current = true
return () => { aliveRef.current = false }
}, [])
useEffect(() => {
const load = async () => {
seqRef.current += 1
const mine = seqRef.current
try {
const data = await api.pult(key)
if (!aliveRef.current || mine !== seqRef.current) return
setState({ key, items: Array.isArray(data?.items) ? data.items : [], error: null })
} catch (err) {
if (!aliveRef.current || mine !== seqRef.current) return
// die letzten Einträge desselben Filters bleiben stehen, der Fehler kommt dazu
setState((prev) => ({ key, items: prev.key === key ? prev.items : [], error: err?.message || UNKNOWN_ERROR }))
}
}
loadRef.current = load
const timer = window.setInterval(() => { if (isVisible()) load() }, POLL_MS)
const onVisibility = () => { if (isVisible()) load() }
document.addEventListener('visibilitychange', onVisibility)
load()
return () => {
seqRef.current += 1 // eine noch offene Anfrage dieses Filters zählt nicht mehr
loadRef.current = null
window.clearInterval(timer)
document.removeEventListener('visibilitychange', onVisibility)
}
}, [api, key])
const refresh = useCallback(() => (loadRef.current ? loadRef.current() : Promise.resolve()), [])
const current = state.key === key
return {
items: current ? state.items : [],
loading: !current,
error: current ? state.error : null,
refresh,
}
}
+5 -2
View File
@@ -1,15 +1,17 @@
import { useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { PageHeader, Tabs } from '../components/ui'
import PultList from '../components/workers/PultList'
import WorkersBoard from '../components/workers/WorkersBoard'
import { useAuth } from '../context/AuthContext'
import { workersApi } from '../lib/workersClient'
import { pickTab } from '../lib/workersView'
import '../styles/workers.css'
// Reiter der Seite. Später kommen freigabe (P3) und botleads (P6) dazu; die Reiterleiste erscheint erst ab zwei Reitern.
// Reiter der Seite. Später kommt botleads (P6) dazu; die Reiterleiste erscheint erst ab zwei Reitern.
const WORKERS_TABS = [
{ id: 'hochhaus', label: 'Hochhaus' },
{ id: 'freigabe', label: 'Freigabe' },
]
const api = workersApi
@@ -17,7 +19,7 @@ const api = workersApi
// Seite „Hustler“ (Dateiname bleibt WorkersPage): Titel, Reiterwahl über ?tab= und der Inhalt des gewählten Reiters.
// Auth und Dienst-API kommen von hier; das Board selbst kennt weder Anmeldung noch Appwrite.
export default function WorkersPage() {
const { isAdmin } = useAuth()
const { isAdmin, user } = useAuth()
const [params, setParams] = useSearchParams()
const active = pickTab(WORKERS_TABS, params.get('tab'))
@@ -35,6 +37,7 @@ export default function WorkersPage() {
<Tabs tabs={WORKERS_TABS} active={active.id} onChange={(id) => setParams({ tab: id })} />
)}
{active.id === 'hochhaus' && <WorkersBoard api={api} isAdmin={isAdmin} />}
{active.id === 'freigabe' && <PultList api={api} me={{ id: user?.$id, name: user?.name }} />}
</div>
)
}
+32
View File
@@ -171,6 +171,34 @@
}
.wk-halt .wk-set-title { color: var(--red-fg); }
/* ---------- Freigabe-Pult ---------- */
.wk-pult-filters { margin-bottom: 16px; }
.wk-pult-filters ~ .msg { margin-bottom: 12px; }
.wk-pult-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.wk-pult-row { min-width: 0; padding: 16px 0; border-bottom: 1px solid var(--line); }
.wk-pult-row .msg { margin-top: 8px; }
.wk-pult-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; }
.wk-pult-title { min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
.wk-pult-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 16px;
margin: 4px 0 12px;
font-size: 13px;
color: var(--text-2);
}
.wk-pult-original { margin: 0 0 12px; }
.wk-pult-original summary { cursor: pointer; color: var(--text-2); font-size: 13px; }
.wk-pult-original-text { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.wk-pult-row .wk-pult-draft { display: block; width: 100%; min-height: 140px; margin: 0 0 12px; }
.wk-pult-row .wk-pult-actions { align-items: center; gap: 8px 16px; }
/* ---------- Schmale Bildschirme ---------- */
@media (max-width: 1100px) {
@@ -186,6 +214,10 @@
.wk-zoom { gap: 6px; }
.wk-zoom .btn { min-width: 36px; min-height: 36px; }
.wk-toolbar .btn { min-height: 36px; }
/* Pult: Aktionen untereinander, Schaltflächen über die volle Breite */
.wk-pult-row { padding: 12px 0; }
.wk-pult-row .wk-pult-actions { flex-direction: column; align-items: stretch; gap: 8px; }
.wk-pult-row .wk-pult-actions .btn, .wk-pult-row .wk-pult-actions .link-danger { width: 100%; min-height: 40px; justify-content: center; text-align: center; }
}
/* ---------- Weniger Bewegung ---------- */