Kunden-Reiter Funktionen als Tabelle mit Textaktionen, Bestaetigung als eigene Zeile

Alle Funktionen als eine Tabelle: Projektzeile, Funktionszeilen mit Status, Ticket (ticketPath) und Textaktionen, Bestaetigung, Einbauanleitung und Zustimmung in Textform als Zeilen darunter, Webshop-Anfrage als Block unter der Tabelle. Alle .funktionen-*-Regeln aus admin-areas.css in den Block Kunden (P4), Handy mit Zweispalten-Zeilen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 14:33:02 +00:00
1 parent 8ef47713b5
commit 8c34e932c1
4 files changed
+232 -185

No files matched your search

+159 -135
View File
@@ -1,6 +1,5 @@
import { Fragment, useCallback, useEffect, useRef, useState } from 'react' import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { FaCheck, FaCopy, FaRotateRight, FaSpinner } from 'react-icons/fa6'
import { Card, Badge, Button, EmptyState } from '../ui' import { Card, Badge, Button, EmptyState } from '../ui'
import { copyText } from '../CopyableCredential' import { copyText } from '../CopyableCredential'
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage } from '../../lib/teamApi' import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage } from '../../lib/teamApi'
@@ -11,9 +10,9 @@ import {
} from '../../lib/funktionenView' } from '../../lib/funktionenView'
import { CHANNELS, channelLabel } from '../../lib/analyseView' import { CHANNELS, channelLabel } from '../../lib/analyseView'
import { berlinYmd, fmtDateTime } from '../../lib/format' import { berlinYmd, fmtDateTime } from '../../lib/format'
import { ticketPath } from '../../lib/ticketRoutes'
import { zeitenStatusText, mergeTeam, abschaltMeldung } from '../../lib/zeitenView' import { zeitenStatusText, mergeTeam, abschaltMeldung } from '../../lib/zeitenView'
import ZeitenPanel from './ZeitenPanel' import ZeitenPanel from './ZeitenPanel'
import '../../styles/admin-areas.css'
// Reiter "Funktionen": News-Pinnwand, Terminplaner und Webshop je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen") // Reiter "Funktionen": News-Pinnwand, Terminplaner und Webshop je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
const short = (text) => (text.length > 60 ? `${text.slice(0, 60)}…` : text) const short = (text) => (text.length > 60 ? `${text.slice(0, 60)}…` : text)
@@ -30,13 +29,13 @@ function SiteKey({ value }) {
timer.current = setTimeout(() => setCopied(false), 1800) timer.current = setTimeout(() => setCopied(false), 1800)
} }
return ( return (
<span className="funktionen-sitekey"> <div className="funktionen-sitekey">
<span className="area-muted">Site-Key</span> <span className="muted">Site-Key</span>
<code>{value}</code> <code>{value}</code>
<button type="button" className="funktionen-sitekey-copy" onClick={copy} title={copied ? 'Kopiert' : 'Site-Key kopieren'} aria-label={copied ? 'Site-Key kopiert' : 'Site-Key kopieren'}> <button type="button" className="link" onClick={copy} title={copied ? 'Kopiert' : 'Site-Key kopieren'} aria-label={copied ? 'Site-Key kopiert' : 'Site-Key kopieren'}>
{copied ? <FaCheck className="funktionen-sitekey-ok" /> : <FaCopy />} {copied ? 'Kopiert' : 'Kopieren'}
</button> </button>
</span> </div>
) )
} }
@@ -52,8 +51,8 @@ function KopierKnopf({ text, label }) {
timer.current = setTimeout(() => setCopied(false), 1800) timer.current = setTimeout(() => setCopied(false), 1800)
} }
return ( return (
<Button size="sm" variant="ghost" onClick={copy} title={label}> <Button type="button" onClick={copy}>
{copied ? <><FaCheck className="funktionen-sitekey-ok" /> Kopiert</> : <><FaCopy /> {label}</>} {copied ? 'Kopiert' : label}
</Button> </Button>
) )
} }
@@ -78,31 +77,31 @@ function AnleitungPanel({ customerId, projectId, feature, reloadKey }) {
const hindernis = hindernisText(feature.hindernis) const hindernis = hindernisText(feature.hindernis)
if (fehler || !antwort) { if (fehler || !antwort) {
return ( return (
<div className="area-row funktionen-panel"> <div className="funktionen-panel">
{fehler ? <div className="area-error">{fehler}</div> : <FaSpinner className="spinner" />} {fehler ? <div className="msg err" role="alert">{fehler}</div> : <div className="muted">Wird geladen …</div>}
{hindernis && <div className="area-summary warn">{hindernis}</div>} {hindernis && <div className="msg warn">{hindernis}</div>}
{fehler && <div><Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)}><FaRotateRight /> Erneut laden</Button></div>} {fehler && <div><Button type="button" onClick={() => setRetry((n) => n + 1)}>Erneut laden</Button></div>}
</div> </div>
) )
} }
const modell = panelModell(antwort, feature) const modell = panelModell(antwort, feature)
return ( return (
<div className="area-row funktionen-panel"> <div className="funktionen-panel">
<div className="funktionen-panel-head"> <div className="funktionen-panel-head">
<strong>Einbauanleitung</strong> <strong>Einbauanleitung</strong>
{modell.kopf && <span className="area-muted">{modell.kopf}</span>} {modell.kopf && <span className="muted small">{modell.kopf}</span>}
{modell.ticketPfad && <Link to={modell.ticketPfad}>Zum Ticket</Link>} {antwort.ticketWoid && <Link className="link" to={ticketPath(antwort.ticketWoid)}>Zum Ticket</Link>}
<Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)} title="Anleitung neu laden" aria-label="Anleitung neu laden"><FaRotateRight /></Button> <button type="button" className="link" onClick={() => setRetry((n) => n + 1)}>Anleitung neu laden</button>
</div> </div>
{modell.warnungen.length > 0 && ( {modell.warnungen.length > 0 && (
<ul className="area-error funktionen-panel-warnungen"> <ul className="msg err funktionen-panel-warnungen">
{modell.warnungen.map((text, i) => <li key={i}>{text}</li>)} {modell.warnungen.map((text, i) => <li key={i}>{text}</li>)}
</ul> </ul>
)} )}
{modell.hinweis && <div className="area-summary warn">{modell.hinweis}</div>} {modell.hinweis && <div className="msg warn">{modell.hinweis}</div>}
{modell.kopien.length > 0 && ( {modell.kopien.length > 0 && (
<div className="area-inline"> <div className="flex items-center gap-2 wrap">
{modell.kopien.map((k) => <KopierKnopf key={k.id} text={k.text} label={k.label} />)} {modell.kopien.map((k) => <KopierKnopf key={k.id} text={k.text} label={k.label} />)}
</div> </div>
)} )}
@@ -122,26 +121,26 @@ function TextformPanel({ funktion, feld, value, onChange, projectId, today, disa
const ansicht = textformAnsicht(feld, funktion) const ansicht = textformAnsicht(feld, funktion)
const { label, links, vorlage } = ansicht const { label, links, vorlage } = ansicht
return ( return (
<div className="area-row funktionen-panel"> <div className="funktionen-panel">
<label className="area-inline" style={{ alignItems: 'flex-start', flexWrap: 'nowrap' }}> <label className="funktionen-zustimmung">
<input type="checkbox" style={{ marginTop: 3 }} checked={value.confirmed} disabled={disabled} onChange={(e) => onChange({ ...value, confirmed: e.target.checked })} /> <input type="checkbox" checked={value.confirmed} disabled={disabled} onChange={(e) => onChange({ ...value, confirmed: e.target.checked })} />
<span> <span>
{label.before} {label.before}
{links.lb ? <a href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a> : label.lb} {links.lb ? <a className="link" href={links.lb} target="_blank" rel="noopener noreferrer">{label.lb}</a> : label.lb}
{label.between} {label.between}
{links.avv ? <a href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a> : label.avv} {links.avv ? <a className="link" href={links.avv} target="_blank" rel="noopener noreferrer">{label.avv}</a> : label.avv}
{label.after} {label.after}
</span> </span>
</label> </label>
<div className="area-inline"> <div className="flex items-center gap-2 wrap">
<label> <label className="flex items-center gap-2 wrap">
Datum der Zustimmung:{' '} Datum der Zustimmung:
<input type="date" required className="form-control" value={value.date} min={ansicht.version} max={today} disabled={disabled} onChange={(e) => onChange({ ...value, date: e.target.value })} /> <input type="date" required className="input" value={value.date} min={ansicht.version} max={today} disabled={disabled} onChange={(e) => onChange({ ...value, date: e.target.value })} />
</label> </label>
<span role="radiogroup" aria-label="Weg der Zustimmung" className="area-inline"> <span role="radiogroup" aria-label="Weg der Zustimmung" className="flex items-center gap-2 wrap">
<span>Weg:</span> <span>Weg:</span>
{CHANNELS.map((c) => ( {CHANNELS.map((c) => (
<label key={c} className="area-inline"> <label key={c} className="check">
<input type="radio" name={`textform-weg-${funktion}-${projectId}`} value={c} checked={value.channel === c} disabled={disabled} onChange={() => onChange({ ...value, channel: c })} /> <input type="radio" name={`textform-weg-${funktion}-${projectId}`} value={c} checked={value.channel === c} disabled={disabled} onChange={() => onChange({ ...value, channel: c })} />
{channelLabel(c)} {channelLabel(c)}
</label> </label>
@@ -149,13 +148,13 @@ function TextformPanel({ funktion, feld, value, onChange, projectId, today, disa
</span> </span>
</div> </div>
{vorlage ? ( {vorlage ? (
<details> <details className="funktionen-vorlage">
<summary style={{ cursor: 'pointer' }}>Textvorlage für den Chat</summary> <summary>Textvorlage für den Chat</summary>
<p style={{ margin: '6px 0', whiteSpace: 'pre-wrap' }}>{vorlage}</p> <p>{vorlage}</p>
<KopierKnopf text={vorlage} label="Vorlage kopieren" /> <KopierKnopf text={vorlage} label="Vorlage kopieren" />
</details> </details>
) : ( ) : (
<div className="area-summary warn">Die Adressen der Unterlagen fehlen; die Chat-Vorlage ist nicht verfügbar.</div> <div className="msg warn">Die Adressen der Unterlagen fehlen; die Chat-Vorlage ist nicht verfügbar.</div>
)} )}
</div> </div>
) )
@@ -190,7 +189,7 @@ function ShopPanel({ customerId, shop, onDecided }) {
} }
const ablehnenFehlt = frage === 'ablehnen' && !begruendungOk(begruendung) const ablehnenFehlt = frage === 'ablehnen' && !begruendungOk(begruendung)
return ( return (
<div className="area-row funktionen-panel"> <div className="funktionen-shop">
<div className="funktionen-panel-head"> <div className="funktionen-panel-head">
<strong>Webshop: {shop.projectName}</strong> <strong>Webshop: {shop.projectName}</strong>
{a && <Badge tone={tone}>Anfrage: {label}</Badge>} {a && <Badge tone={tone}>Anfrage: {label}</Badge>}
@@ -198,34 +197,34 @@ function ShopPanel({ customerId, shop, onDecided }) {
<Badge tone={stripeTon(stripe.status, !!stripe.accountId)}>Stripe: {stripeText(stripe.status, !!stripe.accountId)}</Badge> <Badge tone={stripeTon(stripe.status, !!stripe.accountId)}>Stripe: {stripeText(stripe.status, !!stripe.accountId)}</Badge>
</div> </div>
{a && ( {a && (
<dl className="area-kv"> <dl className="kv">
{anfrageZeilen(a.anfrage).map((z) => ( {anfrageZeilen(a.anfrage).map((z) => (
<Fragment key={z.label}><dt>{z.label}</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{z.text}</dd></Fragment> <Fragment key={z.label}><dt>{z.label}</dt><dd>{z.text}</dd></Fragment>
))} ))}
<dt>Eingegangen</dt><dd>{a.at ? fmtDateTime(a.at) : '–'}</dd> <dt>Eingegangen</dt><dd>{a.at ? fmtDateTime(a.at) : '–'}</dd>
{a.entschiedenAt && <><dt>Entschieden</dt><dd>{fmtDateTime(a.entschiedenAt)}</dd></>} {a.entschiedenAt && <><dt>Entschieden</dt><dd>{fmtDateTime(a.entschiedenAt)}</dd></>}
{a.begruendung && <><dt>Begründung</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{a.begruendung}</dd></>} {a.begruendung && <><dt>Begründung</dt><dd>{a.begruendung}</dd></>}
</dl> </dl>
)} )}
<div className="area-muted">Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}</div> <div className="muted small">Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}</div>
{a?.status === 'offen' && ( {a?.status === 'offen' && (
frage ? ( frage ? (
<div className="area-inline"> <div className="flex items-center gap-2 wrap">
<span>{frage === 'annehmen' ? 'Anfrage annehmen? Der Kunde bekommt eine Mail.' : 'Anfrage ablehnen? Der Kunde bekommt die Begründung per Mail.'}</span> <span>{frage === 'annehmen' ? 'Anfrage annehmen? Der Kunde bekommt eine Mail.' : 'Anfrage ablehnen? Der Kunde bekommt die Begründung per Mail.'}</span>
{frage === 'ablehnen' && ( {frage === 'ablehnen' && (
<textarea className="form-control" required rows={3} maxLength={BEGRUENDUNG_MAX} placeholder="Begründung (Pflicht)" value={begruendung} onChange={(e) => setBegruendung(e.target.value)} style={{ width: '100%' }} /> <textarea className="textarea funktionen-begruendung" required rows={3} maxLength={BEGRUENDUNG_MAX} placeholder="Begründung (Pflicht)" aria-label="Begründung (Pflicht)" value={begruendung} onChange={(e) => setBegruendung(e.target.value)} />
)} )}
<Button size="sm" variant="primary" onClick={entscheiden} disabled={busy || ablehnenFehlt}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button> <Button type="button" variant="primary" onClick={entscheiden} disabled={busy || ablehnenFehlt}>{busy ? 'Speichert …' : 'Ja'}</Button>
<Button size="sm" variant="ghost" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button> <Button type="button" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button>
</div> </div>
) : ( ) : (
<div className="area-inline"> <div className="flex items-center gap-2 wrap">
<Button size="sm" variant="primary" onClick={() => { setBegruendung(''); setFrage('annehmen') }}>Anfrage annehmen</Button> <Button type="button" variant="primary" onClick={() => { setBegruendung(''); setFrage('annehmen') }}>Anfrage annehmen</Button>
<Button size="sm" variant="ghost" onClick={() => { setBegruendung(''); setFrage('ablehnen') }}>Ablehnen</Button> <Button type="button" onClick={() => { setBegruendung(''); setFrage('ablehnen') }}>Ablehnen</Button>
</div> </div>
) )
)} )}
{status && <div className={status.ok ? 'area-message' : 'area-error'}>{status.text}</div>} {status && <div className={`msg ${status.ok ? 'ok' : 'err'}`} role={status.ok ? undefined : 'alert'}>{status.text}</div>}
</div> </div>
) )
} }
@@ -296,97 +295,122 @@ export default function CustomerFunktionen({ customerId }) {
const panelOffen = (projectId) => (Object.prototype.hasOwnProperty.call(panel, projectId) ? panel[projectId] : anleitungOffen(window.location.search, projectId)) const panelOffen = (projectId) => (Object.prototype.hasOwnProperty.call(panel, projectId) ? panel[projectId] : anleitungOffen(window.location.search, projectId))
const togglePanel = (projectId) => setPanel((p) => ({ ...p, [projectId]: !panelOffen(projectId) })) const togglePanel = (projectId) => setPanel((p) => ({ ...p, [projectId]: !panelOffen(projectId) }))
if (error) return <Card><div className="area-error">{error}</div></Card> if (error) return <div className="msg err" role="alert">{error}</div>
if (!projects) return <Card><FaSpinner className="spinner" /></Card> if (!projects) return <div className="loading">Wird geladen …</div>
if (!projects.length) return <Card><EmptyState title="Keine Projekte für diesen Kunden." /></Card> if (!projects.length) return <EmptyState title="Keine Projekte für diesen Kunden." />
const shopEintraege = shop.filter(shopHatEintrag)
return ( return (
<Card title="Funktionen" pad={false}> <Card title="Funktionen">
<div className="area-rows"> <table className="table funktionen-table">
<div className="area-row funktionen-row head"><span>Projekt</span><span>Funktion</span><span>Status</span><span>Ticket</span><span>Aktionen</span></div> <thead>
{projects.flatMap((p) => FUNKTIONEN.map((f, index) => { <tr><th>Funktion</th><th>Status</th><th>Ticket</th><th>Aktionen</th></tr>
const feature = funktionOf(p, f.key) </thead>
const [tone, label] = ZUSTAND[feature.state] || ['muted', feature.state] {projects.map((p) => (
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key <tbody key={p.projectId}>
const hatAnleitung = f.key === 'termine' && feature.state !== 'off' <tr className="funktionen-group">
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId) <th colSpan={4} scope="rowgroup">
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key]) {p.projectName}
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, felder[f.key]) {p.liveUrl && <>{' '}<a className="link" href={p.liveUrl} target="_blank" rel="noreferrer">{p.liveUrl.replace(/^https?:\/\//, '')}</a></>}
const hatZeitenPanel = f.key === 'zeiten' && feature.state !== 'off' </th>
const zeitenText = f.key === 'zeiten' ? zeitenStatusText(feature) : '' </tr>
return ( {FUNKTIONEN.map((f) => {
<Fragment key={`${p.projectId}:${f.key}`}> const feature = funktionOf(p, f.key)
<div className="area-row funktionen-row"> const [tone, label] = ZUSTAND[feature.state] || ['muted', feature.state]
{index === 0 ? ( const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
<span> const hatAnleitung = f.key === 'termine' && feature.state !== 'off'
<strong>{p.projectName}</strong> const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
{p.liveUrl && <><br /><a href={p.liveUrl} target="_blank" rel="noreferrer" className="area-muted">{p.liveUrl.replace(/^https?:\/\//, '')}</a></>} const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key])
</span> const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, felder[f.key])
) : ( const siteKey = siteKeyOf(feature, f.key)
<span className="funktionen-leer" /> const hindernis = feature.state === 'setup' ? hindernisText(feature.hindernis) : ''
)} const abgeschaltet = abgeschaltetText(feature)
<span>{f.label}</span> const hatZeitenPanel = f.key === 'zeiten' && feature.state !== 'off'
<span> const zeitenText = f.key === 'zeiten' ? zeitenStatusText(feature) : ''
<Badge tone={tone}>{label}</Badge> // Zusatzzeilen direkt unter der Funktionszeile in dieser Reihenfolge: Bestaetigung, Zustimmung in Textform, Einbauanleitung.
{feature.state === 'setup' && hindernisText(feature.hindernis) && <><br /><span className="area-warn">{hindernisText(feature.hindernis)}</span></>} // Alle Zeilen bis auf die letzte verlieren die Haarlinie, damit sie als eine Gruppe lesbar sind (.funktionen-offen).
{zeitenText && <><br /><span className={feature.hindernis ? 'area-warn' : 'area-muted'}>{zeitenText}</span></>} const letzte = [isConfirming, braucheTextform, anleitungOffenHier, hatZeitenPanel].lastIndexOf(true)
{abgeschaltetText(feature) && <><br /><span className="area-muted">{abgeschaltetText(feature)}</span></>} const offen = (nr) => nr < letzte
{feature.adminNote && <><br /><span className="area-muted" title={feature.adminNote}>{short(feature.adminNote)}</span></>} return (
{siteKeyOf(feature, f.key) && <><br /><SiteKey value={siteKeyOf(feature, f.key)} /></>} <Fragment key={f.key}>
</span> <tr className={`funktionen-zeile${offen(-1) ? ' funktionen-offen' : ''}`}>
<span> <td>{f.label}</td>
{feature.ticketMissing <td>
? <Badge tone="danger">Ticket fehlt</Badge> <Badge tone={tone}>{label}</Badge>
: feature.ticketWoid {hindernis && <div className="cell-sub msg warn">{hindernis}</div>}
? <Link to={`/tickets?woid=${encodeURIComponent(feature.ticketWoid)}`}>#{feature.ticketWoid}</Link> {zeitenText && <div className={feature.hindernis ? 'cell-sub msg warn' : 'cell-sub'}>{zeitenText}</div>}
: '–'} {abgeschaltet && <div className="cell-sub">{abgeschaltet}</div>}
</span> {feature.adminNote && <div className="cell-sub" title={feature.adminNote}>{short(feature.adminNote)}</div>}
<span className="area-inline"> {siteKey && <SiteKey value={siteKey} />}
{isConfirming ? ( </td>
<Fragment> <td className={feature.ticketMissing || feature.ticketWoid ? undefined : 'funktionen-ohne-ticket'}>
<span>{f.bestaetigen[confirm.action]}</span> {feature.ticketMissing
<input className="form-control" placeholder="Notiz (optional)" maxLength={120} value={note} onChange={(e) => setNote(e.target.value)} style={{ maxWidth: 220 }} /> ? <Badge tone="danger">Ticket fehlt</Badge>
<Button size="sm" variant="primary" onClick={save} disabled={busy || textformFehlt}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button> : feature.ticketWoid
<Button size="sm" variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>Abbrechen</Button> ? <Link className="link" to={ticketPath(feature.ticketWoid)}>#{feature.ticketWoid}</Link>
</Fragment> : <span className="faint">–</span>}
) : ( </td>
<Fragment> <td>
{aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing, hindernis: feature.hindernis }).map((action) => ( {!isConfirming && (
<Button key={action} size="sm" variant="ghost" onClick={() => ask(p.projectId, f.key, action)}> <div className="funktionen-actions">
{AKTION_LABEL[action]} {aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing, hindernis: feature.hindernis }).map((action) => (
</Button> <button key={action} type="button" className="link" onClick={() => ask(p.projectId, f.key, action)}>
))} {AKTION_LABEL[action]}
{hatAnleitung && ( </button>
<Button size="sm" variant="ghost" onClick={() => togglePanel(p.projectId)} aria-expanded={anleitungOffenHier}> ))}
Anleitung {hatAnleitung && (
</Button> <button type="button" className="link" onClick={() => togglePanel(p.projectId)} aria-expanded={anleitungOffenHier}>
Anleitung
</button>
)}
</div>
)} )}
</Fragment> </td>
</tr>
{isConfirming && (
<tr className={`funktionen-confirm${offen(0) ? ' funktionen-offen' : ''}`}>
<td colSpan={4}>
<div className="flex items-center gap-2 wrap">
<span>{f.bestaetigen[confirm.action]}</span>
<input className="input funktionen-note" placeholder="Notiz (optional)" maxLength={120} aria-label="Notiz (optional)" value={note} onChange={(e) => setNote(e.target.value)} />
<Button type="button" variant="primary" onClick={save} disabled={busy || textformFehlt}>{busy ? 'Speichert …' : 'Ja'}</Button>
<Button type="button" onClick={() => setConfirm(null)} disabled={busy}>Abbrechen</Button>
</div>
</td>
</tr>
)} )}
</span> {braucheTextform && (
</div> <tr className={`funktionen-extra${offen(1) ? ' funktionen-offen' : ''}`}>
{braucheTextform && ( <td colSpan={4}>
<TextformPanel funktion={f.key} feld={felder[f.key]} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} /> <TextformPanel funktion={f.key} feld={felder[f.key]} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
)} </td>
{hatZeitenPanel && ( </tr>
<ZeitenPanel customerId={customerId} projectId={p.projectId} feature={feature} onSaved={teamGespeichert} onReload={load} /> )}
)} {anleitungOffenHier && (
{anleitungOffenHier && ( <tr className={`funktionen-extra${offen(2) ? ' funktionen-offen' : ''}`}>
<AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} /> <td colSpan={4}>
)} <AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} />
</Fragment> </td>
) </tr>
}))} )}
</div> {hatZeitenPanel && (
{status && <div className={status.ok ? 'area-message' : 'area-error'} style={{ margin: 12 }}>{status.text}</div>} <tr className="funktionen-extra">
{shopFehler && <div className="area-error" style={{ margin: 12 }}>{shopFehler}</div>} <td colSpan={4}>
{shop.filter(shopHatEintrag).length > 0 && ( <ZeitenPanel customerId={customerId} projectId={p.projectId} feature={feature} onSaved={teamGespeichert} onReload={load} />
<div className="area-rows"> </td>
{shop.filter(shopHatEintrag).map((s) => ( </tr>
<ShopPanel key={s.projectId} customerId={customerId} shop={s} onDecided={nachEntscheidung} /> )}
))} </Fragment>
</div> )
)} })}
</tbody>
))}
</table>
{status && <div className={`msg ${status.ok ? 'ok' : 'err'} funktionen-msg`} role={status.ok ? undefined : 'alert'}>{status.text}</div>}
{shopFehler && <div className="msg err funktionen-msg" role="alert">{shopFehler}</div>}
{shopEintraege.map((s) => (
<ShopPanel key={s.projectId} customerId={customerId} shop={s} onDecided={nachEntscheidung} />
))}
</Card> </Card>
) )
} }
+22 -23
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { FaCopy, FaSpinner } from 'react-icons/fa6'
import { Button } from '../ui' import { Button } from '../ui'
import { getZeitenErsatz, patchZeitenTeam } from '../../lib/teamApi' import { getZeitenErsatz, patchZeitenTeam } from '../../lib/teamApi'
import { import {
@@ -9,8 +8,8 @@ import {
// Panel "Öffnungszeiten live" unter der Zeile im Reiter "Funktionen": Team-Einstellungen der Website-Leisten (nur das Team ändert sie) // Panel "Öffnungszeiten live" unter der Zeile im Reiter "Funktionen": Team-Einstellungen der Website-Leisten (nur das Team ändert sie)
// und "Ersatz-HTML kopieren" je Plan. Entscheidungen, Texte und Prüfungen stehen in zeitenView (rein, getestet); hier wird nur verdrahtet. // und "Ersatz-HTML kopieren" je Plan. Entscheidungen, Texte und Prüfungen stehen in zeitenView (rein, getestet); hier wird nur verdrahtet.
// Texte stehen ausschließlich als React-Textknoten da (nie als HTML). Die Hülle (area-row funktionen-panel) liegt als eigene Zeile außerhalb // Texte stehen ausschließlich als React-Textknoten da (nie als HTML). Die Hülle (funktionen-panel) steht in einer eigenen Tabellenzeile
// des Fünf-Spalten-Rasters, wie die Einbauanleitung des Terminplaners. // unter der Funktionszeile, wie die Einbauanleitung des Terminplaners; weißes Design: keine Icons, Bausteine aus ../ui.
export default function ZeitenPanel({ customerId, projectId, feature, onSaved, onReload }) { export default function ZeitenPanel({ customerId, projectId, feature, onSaved, onReload }) {
const start = teamFormular(feature) const start = teamFormular(feature)
const [form, setForm] = useState(start) const [form, setForm] = useState(start)
@@ -62,17 +61,17 @@ export default function ZeitenPanel({ customerId, projectId, feature, onSaved, o
const knoepfe = ersatzKnoepfe(feature) const knoepfe = ersatzKnoepfe(feature)
return ( return (
<div className="area-row funktionen-panel funktionen-panel-zeiten"> <div className="funktionen-panel funktionen-panel-zeiten">
<div className="funktionen-panel-head"> <div className="funktionen-panel-head">
<strong>Öffnungszeiten live</strong> <strong>Öffnungszeiten live</strong>
<span className="area-muted">Einstellungen des Teams (der Kunde sieht und ändert sie nicht)</span> <span className="muted small">Einstellungen des Teams (der Kunde sieht und ändert sie nicht)</span>
</div> </div>
<form onSubmit={speichern} className="funktionen-panel-form"> <form onSubmit={speichern} className="funktionen-panel-form">
<label className="funktionen-panel-feld"> <label className="funktionen-panel-feld">
<span>Header-Selektor</span> <span>Header-Selektor</span>
<input <input
className="form-control" className="input"
value={form.navSelektor} value={form.navSelektor}
onChange={(e) => aendern({ navSelektor: e.target.value })} onChange={(e) => aendern({ navSelektor: e.target.value })}
disabled={busy} disabled={busy}
@@ -82,51 +81,51 @@ export default function ZeitenPanel({ customerId, projectId, feature, onSaved, o
aria-invalid={selektorFehler ? 'true' : 'false'} aria-invalid={selektorFehler ? 'true' : 'false'}
/> />
</label> </label>
<div className="area-muted"> <div className="muted small">
Nur nötig, wenn die Hinweis-Leiste den festen Header der Website überlappt: CSS-Selektor des Headers. Nur nötig, wenn die Hinweis-Leiste den festen Header der Website überlappt: CSS-Selektor des Headers.
</div> </div>
{selektorFehler && <div className="area-error" role="alert">{selektorFehler}</div>} {selektorFehler && <div className="msg err" role="alert">{selektorFehler}</div>}
<div className="area-inline"> <div className="flex items-center gap-2 wrap">
<label className="area-inline"> <label className="check">
<input type="checkbox" checked={form.kontaktleisteAus} disabled={busy} onChange={(e) => aendern({ kontaktleisteAus: e.target.checked })} /> <input type="checkbox" checked={form.kontaktleisteAus} disabled={busy} onChange={(e) => aendern({ kontaktleisteAus: e.target.checked })} />
Kontakt-Leiste aus Kontakt-Leiste aus
</label> </label>
<label className="area-inline"> <label className="check">
<input type="checkbox" checked={form.hinweisleisteAus} disabled={busy} onChange={(e) => aendern({ hinweisleisteAus: e.target.checked })} /> <input type="checkbox" checked={form.hinweisleisteAus} disabled={busy} onChange={(e) => aendern({ hinweisleisteAus: e.target.checked })} />
Hinweis-Leiste aus Hinweis-Leiste aus
</label> </label>
</div> </div>
<div className="area-inline"> <div className="flex items-center gap-2 wrap">
<Button type="submit" size="sm" variant="primary" disabled={busy || Boolean(selektorFehler) || !geaendert}> <Button type="submit" variant="primary" disabled={busy || Boolean(selektorFehler) || !geaendert}>
{busy ? <FaSpinner className="spinner" /> : 'Speichern'} {busy ? 'Speichert …' : 'Speichern'}
</Button> </Button>
{meldung && <span role="status" className={meldung.ok ? 'area-message funktionen-panel-meldung' : 'area-error funktionen-panel-meldung'}>{meldung.text}</span>} {meldung && <span role="status" className={meldung.ok ? 'msg ok' : 'msg err'}>{meldung.text}</span>}
{meldung?.neuLaden && ( {meldung?.neuLaden && (
<Button type="button" size="sm" variant="ghost" onClick={() => { setMeldung(null); onReload() }}>Neu laden</Button> <Button type="button" onClick={() => { setMeldung(null); onReload() }}>Neu laden</Button>
)} )}
</div> </div>
</form> </form>
<div className="funktionen-panel-head"> <div className="funktionen-panel-head">
<strong>Ersatz-HTML</strong> <strong>Ersatz-HTML</strong>
<span className="area-muted">Wochentabelle des Plans für den Platzhalter der Website (Einbau)</span> <span className="muted small">Wochentabelle des Plans für den Platzhalter der Website (Einbau)</span>
</div> </div>
{knoepfe.length === 0 ? ( {knoepfe.length === 0 ? (
<div className="area-muted">{KEINE_ZEITEN_TEXT}</div> <div className="muted small">{KEINE_ZEITEN_TEXT}</div>
) : ( ) : (
<div className="area-inline"> <div className="flex items-center gap-2 wrap">
{knoepfe.map((k) => ( {knoepfe.map((k) => (
<Button key={k.kurz} type="button" size="sm" variant="ghost" disabled={ersatz.busy} onClick={() => kopieren(k)} title={`Plan „${k.kurz}“`}> <Button key={k.kurz} type="button" disabled={ersatz.busy} onClick={() => kopieren(k)} title={`Plan „${k.kurz}“`}>
{ersatz.busy && ersatz.kurz === k.kurz ? <FaSpinner className="spinner" /> : <FaCopy />} {k.label} {ersatz.busy && ersatz.kurz === k.kurz ? 'Lädt …' : k.label}
</Button> </Button>
))} ))}
</div> </div>
)} )}
{ersatz.meldung && <div role="status" className={ersatz.meldung.ok ? 'area-message' : 'area-error'}>{ersatz.meldung.text}</div>} {ersatz.meldung && <div role="status" className={ersatz.meldung.ok ? 'msg ok' : 'msg err'}>{ersatz.meldung.text}</div>}
{ersatz.html && ( {ersatz.html && (
<textarea <textarea
readOnly readOnly
className="form-control funktionen-panel-html" className="textarea funktionen-panel-html"
rows={8} rows={8}
value={ersatz.html} value={ersatz.html}
aria-label="Ersatz-HTML zum Kopieren" aria-label="Ersatz-HTML zum Kopieren"
+1 -27
View File
@@ -1,4 +1,4 @@
/* Bereiche aus dem frueheren Admin-Panel (Gutschriften, Abo-Angebote, E-Mail-Speicher, Funktionen, Abo-Status). /* Bereiche aus dem frueheren Admin-Panel (Gutschriften, Abo-Angebote, E-Mail-Speicher, Abo-Status).
Zeilen als Raster; unter 720 px untereinander, damit nichts waagerecht scrollt. */ Zeilen als Raster; unter 720 px untereinander, damit nichts waagerecht scrollt. */
.area-rows { display: flex; flex-direction: column; container-type: inline-size; } .area-rows { display: flex; flex-direction: column; container-type: inline-size; }
.area-row { .area-row {
@@ -21,34 +21,9 @@
.area-filter { display: inline-flex; gap: 4px; margin-bottom: var(--sp-3); } .area-filter { display: inline-flex; gap: 4px; margin-bottom: var(--sp-3); }
.area-inline { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; } .area-inline { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
.storage-row { grid-template-columns: 150px 220px minmax(0, 1fr) 150px 110px; } .storage-row { grid-template-columns: 150px 220px minmax(0, 1fr) 150px 110px; }
.funktionen-row { grid-template-columns: minmax(0, 1.2fr) 110px 200px 110px minmax(0, 1.4fr); }
.funktionen-sitekey { display: inline-flex; align-items: center; gap: 6px; margin-top: 4px; }
.funktionen-sitekey code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--text); user-select: all; }
.funktionen-sitekey-copy {
display: inline-flex; align-items: center; justify-content: center; width: 25px; height: 25px; flex: 0 0 25px;
border: 0; border-radius: 4px; background: transparent; color: var(--text-faint); cursor: pointer;
}
.funktionen-sitekey-copy:hover { background: var(--surface-2); color: var(--text); }
.funktionen-sitekey-ok { color: var(--ok); }
.funktionen-panel { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); background: var(--surface-2); }
.funktionen-panel-head { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-3); align-items: center; }
.funktionen-panel-warnungen { margin: 0; padding-left: 32px; }
.funktionen-panel .area-summary { margin-bottom: 0; }
.funktionen-panel-zeiten .area-error, .funktionen-panel-zeiten .area-message { margin: 0; }
.funktionen-panel-form { display: flex; flex-direction: column; gap: var(--sp-2); }
.funktionen-panel-feld { display: flex; flex-direction: column; gap: 4px; max-width: 520px; }
.funktionen-panel-html { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; min-height: 120px; }
.funktionen-panel-abschnitt { border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-1); min-width: 0; }
.funktionen-panel-abschnitt > summary { padding: 8px 12px; cursor: pointer; font-weight: 600; }
.funktionen-panel-abschnitt pre {
margin: 0; padding: 10px 12px; border-top: 1px solid var(--border); max-width: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--text);
white-space: pre-wrap; overflow-wrap: anywhere; user-select: text;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.area-rows .area-row { grid-template-columns: minmax(0, 1fr); } .area-rows .area-row { grid-template-columns: minmax(0, 1fr); }
.area-row.head { display: none; } .area-row.head { display: none; }
.funktionen-leer { display: none; }
.area-kv { grid-template-columns: minmax(0, 1fr); } .area-kv { grid-template-columns: minmax(0, 1fr); }
.area-kv dt { margin-top: 6px; } .area-kv dt { margin-top: 6px; }
} }
@@ -72,5 +47,4 @@
@container (max-width: 960px) { @container (max-width: 960px) {
.area-rows .area-row { grid-template-columns: minmax(0, 1fr); gap: 4px; } .area-rows .area-row { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.area-row.head { display: none; } .area-row.head { display: none; }
.funktionen-leer { display: none; }
} }
+50
View File
@@ -1060,6 +1060,56 @@ button.link { font-weight: 500; } /* font: inherit setzt sonst das Gewicht von .
.kunde-sub .badge { margin-right: 8px; vertical-align: top; } .kunde-sub .badge { margin-right: 8px; vertical-align: top; }
/* Reiter "Abo & Angebote": Abstand zwischen Abo-Status und Angeboten (der Umschlag ist ein div, .page section + section greift dort nicht) */ /* Reiter "Abo & Angebote": Abstand zwischen Abo-Status und Angeboten (der Umschlag ist ein div, .page section + section greift dort nicht) */
.kunde-abo-angebote { margin-top: 36px; } .kunde-abo-angebote { margin-top: 36px; }
/* Reiter "Funktionen": eine Tabelle, je Projekt eine Gruppenzeile; Bestaetigung und Panels sind Zusatzzeilen direkt unter der Funktionszeile */
.funktionen-group th { text-align: left; font-size: 14px; font-weight: 600; color: var(--text); padding-top: 20px; overflow-wrap: anywhere; white-space: normal; }
.funktionen-group .link { font-size: 13px; font-weight: 400; margin-left: 8px; }
.funktionen-sitekey { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin-top: 4px; }
.funktionen-sitekey code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; overflow-wrap: anywhere; user-select: all; }
.funktionen-actions { display: flex; flex-wrap: wrap; gap: 4px 14px; }
.funktionen-confirm td { padding-top: 0; }
.funktionen-note { width: 100%; max-width: 260px; }
/* Zeilen einer Gruppe (Funktionszeile, Bestaetigung, Panels): nur die letzte behaelt die Haarlinie */
.funktionen-offen td { border-bottom: 0; }
.funktionen-extra td { padding-top: 0; }
.funktionen-panel { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; background: var(--bg-subtle); border-radius: var(--radius); }
.funktionen-panel-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
.funktionen-panel-warnungen { margin: 0; padding-left: 18px; }
.funktionen-panel-abschnitt { border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg); min-width: 0; }
.funktionen-panel-abschnitt > summary { padding: 8px 12px; cursor: pointer; font-weight: 600; }
.funktionen-panel-abschnitt pre {
margin: 0; padding: 10px 12px; border-top: 1px solid var(--line); max-width: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--text);
white-space: pre-wrap; overflow-wrap: anywhere; user-select: text;
}
/* Oeffnungszeiten live (Panel unter der Funktionszeile): Team-Formular und Ersatz-HTML */
.funktionen-panel-form { display: flex; flex-direction: column; gap: 8px; }
.funktionen-panel-feld { display: flex; flex-direction: column; gap: 4px; max-width: 520px; }
.funktionen-panel-html { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; min-height: 120px; }
.funktionen-zustimmung { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; }
.funktionen-zustimmung input { flex: none; margin-top: 3px; width: 15px; height: 15px; accent-color: var(--accent); }
.funktionen-vorlage summary { cursor: pointer; }
.funktionen-vorlage p { margin: 6px 0; white-space: pre-wrap; }
.funktionen-msg { margin-top: 12px; }
/* Webshop-Anfrage je Projekt unter der Tabelle: ein Block je Eintrag, dazwischen eine Haarlinie */
.funktionen-shop { display: flex; flex-direction: column; gap: 8px; margin-top: 24px; }
.funktionen-shop + .funktionen-shop { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--line); }
.funktionen-shop dd { white-space: pre-wrap; }
.funktionen-begruendung { width: 100%; max-width: 560px; }
@media (min-width: 861px) {
.funktionen-shop .kv { grid-template-columns: max-content minmax(0, 1fr); }
}
/* Handy: Kopf nur fuer Screenreader, jede Funktionszeile als zwei Spalten (Name | Status, Ticket, Aktionen untereinander) */
@media (max-width: 860px) {
.funktionen-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.funktionen-table, .funktionen-table tbody, .funktionen-table tr, .funktionen-table td, .funktionen-table th { display: block; }
.funktionen-table .funktionen-zeile { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 4px 12px; padding: 12px 0; border-bottom: 1px solid var(--line); }
.funktionen-table .funktionen-zeile.funktionen-offen { border-bottom: 0; }
.funktionen-zeile > td { grid-column: 2; min-width: 0; padding: 0; border-bottom: 0; }
.funktionen-zeile > td:first-child { grid-column: 1; grid-row: 1; }
.funktionen-zeile > td:empty, .funktionen-zeile > .funktionen-ohne-ticket { display: none; }
.funktionen-confirm td { padding-bottom: 12px; }
.funktionen-extra td { padding-bottom: 12px; }
}
/* ===== ENDE Kunden (P4) ===== */ /* ===== ENDE Kunden (P4) ===== */
/* ===== KOMPAT (bis P10) ===== */ /* ===== KOMPAT (bis P10) ===== */