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 { Link } from 'react-router-dom'
import { FaCheck, FaCopy, FaRotateRight, FaSpinner } from 'react-icons/fa6'
import { Card, Badge, Button, EmptyState } from '../ui'
import { copyText } from '../CopyableCredential'
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage } from '../../lib/teamApi'
@@ -11,9 +10,9 @@ import {
} from '../../lib/funktionenView'
import { CHANNELS, channelLabel } from '../../lib/analyseView'
import { berlinYmd, fmtDateTime } from '../../lib/format'
import { ticketPath } from '../../lib/ticketRoutes'
import { zeitenStatusText, mergeTeam, abschaltMeldung } from '../../lib/zeitenView'
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")
const short = (text) => (text.length > 60 ? `${text.slice(0, 60)}…` : text)
@@ -30,13 +29,13 @@ function SiteKey({ value }) {
timer.current = setTimeout(() => setCopied(false), 1800)
}
return (
<span className="funktionen-sitekey">
<span className="area-muted">Site-Key</span>
<div className="funktionen-sitekey">
<span className="muted">Site-Key</span>
<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'}>
{copied ? <FaCheck className="funktionen-sitekey-ok" /> : <FaCopy />}
<button type="button" className="link" onClick={copy} title={copied ? 'Kopiert' : 'Site-Key kopieren'} aria-label={copied ? 'Site-Key kopiert' : 'Site-Key kopieren'}>
{copied ? 'Kopiert' : 'Kopieren'}
</button>
</span>
</div>
)
}
@@ -52,8 +51,8 @@ function KopierKnopf({ text, label }) {
timer.current = setTimeout(() => setCopied(false), 1800)
}
return (
<Button size="sm" variant="ghost" onClick={copy} title={label}>
{copied ? <><FaCheck className="funktionen-sitekey-ok" /> Kopiert</> : <><FaCopy /> {label}</>}
<Button type="button" onClick={copy}>
{copied ? 'Kopiert' : label}
</Button>
)
}
@@ -78,31 +77,31 @@ function AnleitungPanel({ customerId, projectId, feature, reloadKey }) {
const hindernis = hindernisText(feature.hindernis)
if (fehler || !antwort) {
return (
<div className="area-row funktionen-panel">
{fehler ? <div className="area-error">{fehler}</div> : <FaSpinner className="spinner" />}
{hindernis && <div className="area-summary warn">{hindernis}</div>}
{fehler && <div><Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)}><FaRotateRight /> Erneut laden</Button></div>}
<div className="funktionen-panel">
{fehler ? <div className="msg err" role="alert">{fehler}</div> : <div className="muted">Wird geladen …</div>}
{hindernis && <div className="msg warn">{hindernis}</div>}
{fehler && <div><Button type="button" onClick={() => setRetry((n) => n + 1)}>Erneut laden</Button></div>}
</div>
)
}
const modell = panelModell(antwort, feature)
return (
<div className="area-row funktionen-panel">
<div className="funktionen-panel">
<div className="funktionen-panel-head">
<strong>Einbauanleitung</strong>
{modell.kopf && <span className="area-muted">{modell.kopf}</span>}
{modell.ticketPfad && <Link to={modell.ticketPfad}>Zum Ticket</Link>}
<Button size="sm" variant="ghost" onClick={() => setRetry((n) => n + 1)} title="Anleitung neu laden" aria-label="Anleitung neu laden"><FaRotateRight /></Button>
{modell.kopf && <span className="muted small">{modell.kopf}</span>}
{antwort.ticketWoid && <Link className="link" to={ticketPath(antwort.ticketWoid)}>Zum Ticket</Link>}
<button type="button" className="link" onClick={() => setRetry((n) => n + 1)}>Anleitung neu laden</button>
</div>
{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>)}
</ul>
)}
{modell.hinweis && <div className="area-summary warn">{modell.hinweis}</div>}
{modell.hinweis && <div className="msg warn">{modell.hinweis}</div>}
{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} />)}
</div>
)}
@@ -122,26 +121,26 @@ function TextformPanel({ funktion, feld, value, onChange, projectId, today, disa
const ansicht = textformAnsicht(feld, funktion)
const { label, links, vorlage } = ansicht
return (
<div className="area-row funktionen-panel">
<label className="area-inline" style={{ alignItems: 'flex-start', flexWrap: 'nowrap' }}>
<input type="checkbox" style={{ marginTop: 3 }} checked={value.confirmed} disabled={disabled} onChange={(e) => onChange({ ...value, confirmed: e.target.checked })} />
<div className="funktionen-panel">
<label className="funktionen-zustimmung">
<input type="checkbox" checked={value.confirmed} disabled={disabled} onChange={(e) => onChange({ ...value, confirmed: e.target.checked })} />
<span>
{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}
{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}
</span>
</label>
<div className="area-inline">
<label>
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 })} />
<div className="flex items-center gap-2 wrap">
<label className="flex items-center gap-2 wrap">
Datum der Zustimmung:
<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>
<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>
{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 })} />
{channelLabel(c)}
</label>
@@ -149,13 +148,13 @@ function TextformPanel({ funktion, feld, value, onChange, projectId, today, disa
</span>
</div>
{vorlage ? (
<details>
<summary style={{ cursor: 'pointer' }}>Textvorlage für den Chat</summary>
<p style={{ margin: '6px 0', whiteSpace: 'pre-wrap' }}>{vorlage}</p>
<details className="funktionen-vorlage">
<summary>Textvorlage für den Chat</summary>
<p>{vorlage}</p>
<KopierKnopf text={vorlage} label="Vorlage kopieren" />
</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>
)
@@ -190,7 +189,7 @@ function ShopPanel({ customerId, shop, onDecided }) {
}
const ablehnenFehlt = frage === 'ablehnen' && !begruendungOk(begruendung)
return (
<div className="area-row funktionen-panel">
<div className="funktionen-shop">
<div className="funktionen-panel-head">
<strong>Webshop: {shop.projectName}</strong>
{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>
</div>
{a && (
<dl className="area-kv">
<dl className="kv">
{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>
{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>
)}
<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' && (
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>
{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 size="sm" variant="ghost" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button>
<Button type="button" variant="primary" onClick={entscheiden} disabled={busy || ablehnenFehlt}>{busy ? 'Speichert …' : 'Ja'}</Button>
<Button type="button" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button>
</div>
) : (
<div className="area-inline">
<Button size="sm" variant="primary" onClick={() => { setBegruendung(''); setFrage('annehmen') }}>Anfrage annehmen</Button>
<Button size="sm" variant="ghost" onClick={() => { setBegruendung(''); setFrage('ablehnen') }}>Ablehnen</Button>
<div className="flex items-center gap-2 wrap">
<Button type="button" variant="primary" onClick={() => { setBegruendung(''); setFrage('annehmen') }}>Anfrage annehmen</Button>
<Button type="button" onClick={() => { setBegruendung(''); setFrage('ablehnen') }}>Ablehnen</Button>
</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>
)
}
@@ -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 togglePanel = (projectId) => setPanel((p) => ({ ...p, [projectId]: !panelOffen(projectId) }))
if (error) return <Card><div className="area-error">{error}</div></Card>
if (!projects) return <Card><FaSpinner className="spinner" /></Card>
if (!projects.length) return <Card><EmptyState title="Keine Projekte für diesen Kunden." /></Card>
if (error) return <div className="msg err" role="alert">{error}</div>
if (!projects) return <div className="loading">Wird geladen …</div>
if (!projects.length) return <EmptyState title="Keine Projekte für diesen Kunden." />
const shopEintraege = shop.filter(shopHatEintrag)
return (
<Card title="Funktionen" pad={false}>
<div className="area-rows">
<div className="area-row funktionen-row head"><span>Projekt</span><span>Funktion</span><span>Status</span><span>Ticket</span><span>Aktionen</span></div>
{projects.flatMap((p) => FUNKTIONEN.map((f, index) => {
const feature = funktionOf(p, f.key)
const [tone, label] = ZUSTAND[feature.state] || ['muted', feature.state]
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
const hatAnleitung = f.key === 'termine' && feature.state !== 'off'
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key])
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, felder[f.key])
const hatZeitenPanel = f.key === 'zeiten' && feature.state !== 'off'
const zeitenText = f.key === 'zeiten' ? zeitenStatusText(feature) : ''
return (
<Fragment key={`${p.projectId}:${f.key}`}>
<div className="area-row funktionen-row">
{index === 0 ? (
<span>
<strong>{p.projectName}</strong>
{p.liveUrl && <><br /><a href={p.liveUrl} target="_blank" rel="noreferrer" className="area-muted">{p.liveUrl.replace(/^https?:\/\//, '')}</a></>}
</span>
) : (
<span className="funktionen-leer" />
)}
<span>{f.label}</span>
<span>
<Badge tone={tone}>{label}</Badge>
{feature.state === 'setup' && hindernisText(feature.hindernis) && <><br /><span className="area-warn">{hindernisText(feature.hindernis)}</span></>}
{zeitenText && <><br /><span className={feature.hindernis ? 'area-warn' : 'area-muted'}>{zeitenText}</span></>}
{abgeschaltetText(feature) && <><br /><span className="area-muted">{abgeschaltetText(feature)}</span></>}
{feature.adminNote && <><br /><span className="area-muted" title={feature.adminNote}>{short(feature.adminNote)}</span></>}
{siteKeyOf(feature, f.key) && <><br /><SiteKey value={siteKeyOf(feature, f.key)} /></>}
</span>
<span>
{feature.ticketMissing
? <Badge tone="danger">Ticket fehlt</Badge>
: feature.ticketWoid
? <Link to={`/tickets?woid=${encodeURIComponent(feature.ticketWoid)}`}>#{feature.ticketWoid}</Link>
: '–'}
</span>
<span className="area-inline">
{isConfirming ? (
<Fragment>
<span>{f.bestaetigen[confirm.action]}</span>
<input className="form-control" placeholder="Notiz (optional)" maxLength={120} value={note} onChange={(e) => setNote(e.target.value)} style={{ maxWidth: 220 }} />
<Button size="sm" variant="primary" onClick={save} disabled={busy || textformFehlt}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>Abbrechen</Button>
</Fragment>
) : (
<Fragment>
{aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing, hindernis: feature.hindernis }).map((action) => (
<Button key={action} size="sm" variant="ghost" onClick={() => ask(p.projectId, f.key, action)}>
{AKTION_LABEL[action]}
</Button>
))}
{hatAnleitung && (
<Button size="sm" variant="ghost" onClick={() => togglePanel(p.projectId)} aria-expanded={anleitungOffenHier}>
Anleitung
</Button>
<Card title="Funktionen">
<table className="table funktionen-table">
<thead>
<tr><th>Funktion</th><th>Status</th><th>Ticket</th><th>Aktionen</th></tr>
</thead>
{projects.map((p) => (
<tbody key={p.projectId}>
<tr className="funktionen-group">
<th colSpan={4} scope="rowgroup">
{p.projectName}
{p.liveUrl && <>{' '}<a className="link" href={p.liveUrl} target="_blank" rel="noreferrer">{p.liveUrl.replace(/^https?:\/\//, '')}</a></>}
</th>
</tr>
{FUNKTIONEN.map((f) => {
const feature = funktionOf(p, f.key)
const [tone, label] = ZUSTAND[feature.state] || ['muted', feature.state]
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
const hatAnleitung = f.key === 'termine' && feature.state !== 'off'
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key])
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, felder[f.key])
const siteKey = siteKeyOf(feature, f.key)
const hindernis = feature.state === 'setup' ? hindernisText(feature.hindernis) : ''
const abgeschaltet = abgeschaltetText(feature)
const hatZeitenPanel = f.key === 'zeiten' && feature.state !== 'off'
const zeitenText = f.key === 'zeiten' ? zeitenStatusText(feature) : ''
// Zusatzzeilen direkt unter der Funktionszeile in dieser Reihenfolge: Bestaetigung, Zustimmung in Textform, Einbauanleitung.
// Alle Zeilen bis auf die letzte verlieren die Haarlinie, damit sie als eine Gruppe lesbar sind (.funktionen-offen).
const letzte = [isConfirming, braucheTextform, anleitungOffenHier, hatZeitenPanel].lastIndexOf(true)
const offen = (nr) => nr < letzte
return (
<Fragment key={f.key}>
<tr className={`funktionen-zeile${offen(-1) ? ' funktionen-offen' : ''}`}>
<td>{f.label}</td>
<td>
<Badge tone={tone}>{label}</Badge>
{hindernis && <div className="cell-sub msg warn">{hindernis}</div>}
{zeitenText && <div className={feature.hindernis ? 'cell-sub msg warn' : 'cell-sub'}>{zeitenText}</div>}
{abgeschaltet && <div className="cell-sub">{abgeschaltet}</div>}
{feature.adminNote && <div className="cell-sub" title={feature.adminNote}>{short(feature.adminNote)}</div>}
{siteKey && <SiteKey value={siteKey} />}
</td>
<td className={feature.ticketMissing || feature.ticketWoid ? undefined : 'funktionen-ohne-ticket'}>
{feature.ticketMissing
? <Badge tone="danger">Ticket fehlt</Badge>
: feature.ticketWoid
? <Link className="link" to={ticketPath(feature.ticketWoid)}>#{feature.ticketWoid}</Link>
: <span className="faint">–</span>}
</td>
<td>
{!isConfirming && (
<div className="funktionen-actions">
{aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing, hindernis: feature.hindernis }).map((action) => (
<button key={action} type="button" className="link" onClick={() => ask(p.projectId, f.key, action)}>
{AKTION_LABEL[action]}
</button>
))}
{hatAnleitung && (
<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>
</div>
{braucheTextform && (
<TextformPanel funktion={f.key} feld={felder[f.key]} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
)}
{hatZeitenPanel && (
<ZeitenPanel customerId={customerId} projectId={p.projectId} feature={feature} onSaved={teamGespeichert} onReload={load} />
)}
{anleitungOffenHier && (
<AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} />
)}
</Fragment>
)
}))}
</div>
{status && <div className={status.ok ? 'area-message' : 'area-error'} style={{ margin: 12 }}>{status.text}</div>}
{shopFehler && <div className="area-error" style={{ margin: 12 }}>{shopFehler}</div>}
{shop.filter(shopHatEintrag).length > 0 && (
<div className="area-rows">
{shop.filter(shopHatEintrag).map((s) => (
<ShopPanel key={s.projectId} customerId={customerId} shop={s} onDecided={nachEntscheidung} />
))}
</div>
)}
{braucheTextform && (
<tr className={`funktionen-extra${offen(1) ? ' funktionen-offen' : ''}`}>
<td colSpan={4}>
<TextformPanel funktion={f.key} feld={felder[f.key]} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
</td>
</tr>
)}
{anleitungOffenHier && (
<tr className={`funktionen-extra${offen(2) ? ' funktionen-offen' : ''}`}>
<td colSpan={4}>
<AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} />
</td>
</tr>
)}
{hatZeitenPanel && (
<tr className="funktionen-extra">
<td colSpan={4}>
<ZeitenPanel customerId={customerId} projectId={p.projectId} feature={feature} onSaved={teamGespeichert} onReload={load} />
</td>
</tr>
)}
</Fragment>
)
})}
</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>
)
}
+22 -23
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { FaCopy, FaSpinner } from 'react-icons/fa6'
import { Button } from '../ui'
import { getZeitenErsatz, patchZeitenTeam } from '../../lib/teamApi'
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)
// 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
// des Fünf-Spalten-Rasters, wie die Einbauanleitung des Terminplaners.
// Texte stehen ausschließlich als React-Textknoten da (nie als HTML). Die Hülle (funktionen-panel) steht in einer eigenen Tabellenzeile
// 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 }) {
const start = teamFormular(feature)
const [form, setForm] = useState(start)
@@ -62,17 +61,17 @@ export default function ZeitenPanel({ customerId, projectId, feature, onSaved, o
const knoepfe = ersatzKnoepfe(feature)
return (
<div className="area-row funktionen-panel funktionen-panel-zeiten">
<div className="funktionen-panel funktionen-panel-zeiten">
<div className="funktionen-panel-head">
<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>
<form onSubmit={speichern} className="funktionen-panel-form">
<label className="funktionen-panel-feld">
<span>Header-Selektor</span>
<input
className="form-control"
className="input"
value={form.navSelektor}
onChange={(e) => aendern({ navSelektor: e.target.value })}
disabled={busy}
@@ -82,51 +81,51 @@ export default function ZeitenPanel({ customerId, projectId, feature, onSaved, o
aria-invalid={selektorFehler ? 'true' : 'false'}
/>
</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.
</div>
{selektorFehler && <div className="area-error" role="alert">{selektorFehler}</div>}
<div className="area-inline">
<label className="area-inline">
{selektorFehler && <div className="msg err" role="alert">{selektorFehler}</div>}
<div className="flex items-center gap-2 wrap">
<label className="check">
<input type="checkbox" checked={form.kontaktleisteAus} disabled={busy} onChange={(e) => aendern({ kontaktleisteAus: e.target.checked })} />
Kontakt-Leiste aus
</label>
<label className="area-inline">
<label className="check">
<input type="checkbox" checked={form.hinweisleisteAus} disabled={busy} onChange={(e) => aendern({ hinweisleisteAus: e.target.checked })} />
Hinweis-Leiste aus
</label>
</div>
<div className="area-inline">
<Button type="submit" size="sm" variant="primary" disabled={busy || Boolean(selektorFehler) || !geaendert}>
{busy ? <FaSpinner className="spinner" /> : 'Speichern'}
<div className="flex items-center gap-2 wrap">
<Button type="submit" variant="primary" disabled={busy || Boolean(selektorFehler) || !geaendert}>
{busy ? 'Speichert …' : 'Speichern'}
</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 && (
<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>
</form>
<div className="funktionen-panel-head">
<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>
{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) => (
<Button key={k.kurz} type="button" size="sm" variant="ghost" disabled={ersatz.busy} onClick={() => kopieren(k)} title={`Plan „${k.kurz}“`}>
{ersatz.busy && ersatz.kurz === k.kurz ? <FaSpinner className="spinner" /> : <FaCopy />} {k.label}
<Button key={k.kurz} type="button" disabled={ersatz.busy} onClick={() => kopieren(k)} title={`Plan „${k.kurz}“`}>
{ersatz.busy && ersatz.kurz === k.kurz ? 'Lädt …' : k.label}
</Button>
))}
</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 && (
<textarea
readOnly
className="form-control funktionen-panel-html"
className="textarea funktionen-panel-html"
rows={8}
value={ersatz.html}
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. */
.area-rows { display: flex; flex-direction: column; container-type: inline-size; }
.area-row {
@@ -21,34 +21,9 @@
.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; }
.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) {
.area-rows .area-row { grid-template-columns: minmax(0, 1fr); }
.area-row.head { display: none; }
.funktionen-leer { display: none; }
.area-kv { grid-template-columns: minmax(0, 1fr); }
.area-kv dt { margin-top: 6px; }
}
@@ -72,5 +47,4 @@
@container (max-width: 960px) {
.area-rows .area-row { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.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; }
/* 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; }
/* 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) ===== */
/* ===== KOMPAT (bis P10) ===== */