Kundenseite: Abo-Status und Funktionen; Seite E-Mail-Speicher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
91d526e909
commit
cef0d5465e
8 files changed
+297
-4
No files matched your search
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FaSpinner } from 'react-icons/fa6'
|
||||
import { Card, Badge } from '../ui'
|
||||
import { getCustomerAboStatus } from '../../lib/teamApi'
|
||||
import { fmtDate, productLabelOf } from '../../lib/format'
|
||||
import { storageLabel } from '../../lib/storageFormat'
|
||||
import '../../styles/admin-areas.css'
|
||||
|
||||
// Abo-Status eines Kunden (Reiter "Abo & Angebote"): Appwrite-Spiegel des Stripe-Abos, fuer alle Mitarbeiter
|
||||
export default function AboStatusCard({ customerId }) {
|
||||
const [abo, setAbo] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setAbo(null)
|
||||
setError(null)
|
||||
getCustomerAboStatus(customerId)
|
||||
.then((a) => { if (alive) setAbo(a) })
|
||||
.catch((err) => { if (alive) setError(err.message || 'Abo konnte nicht geladen werden.') })
|
||||
return () => { alive = false }
|
||||
}, [customerId])
|
||||
|
||||
return (
|
||||
<Card title="Abo">
|
||||
{error && <div className="area-error">{error}</div>}
|
||||
{!abo && !error && <FaSpinner className="spinner" />}
|
||||
{abo && !abo.status && <span className="area-muted">kein Abo</span>}
|
||||
{abo && abo.status && (
|
||||
<dl className="area-kv">
|
||||
<dt>Abo</dt>
|
||||
<dd className="area-inline">
|
||||
{productLabelOf(abo.productKey)}
|
||||
<Badge tone={abo.active ? 'ok' : 'danger'}>{abo.active ? 'aktiv' : abo.status}</Badge>
|
||||
</dd>
|
||||
<dt>Leistungsbeginn</dt><dd>{fmtDate(abo.serviceStart)}</dd>
|
||||
{abo.active && <><dt>Nächste Abbuchung</dt><dd>{fmtDate(abo.currentPeriodEnd)}</dd></>}
|
||||
{abo.cancelAt && <><dt>Gekündigt zum</dt><dd>{fmtDate(abo.cancelAt)}</dd></>}
|
||||
<dt>Zusatzspeicher</dt><dd>{storageLabel(abo.storageUnits)}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { FaSpinner } from 'react-icons/fa6'
|
||||
import { Card, Badge, Button, EmptyState } from '../ui'
|
||||
import { getFunktionen, setNewsFeature } from '../../lib/teamApi'
|
||||
import '../../styles/admin-areas.css'
|
||||
|
||||
// Reiter "Funktionen": News-Pinnwand je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
|
||||
const STATE = {
|
||||
off: ['muted', 'Aus'],
|
||||
ordered: ['warn', 'Bestellt'],
|
||||
setup: ['info', 'Einrichtung'],
|
||||
active: ['ok', 'Aktiv'],
|
||||
disabled: ['muted', 'Abgeschaltet'],
|
||||
}
|
||||
const CONFIRM = {
|
||||
activate: 'News ohne Zahlung aktivieren?',
|
||||
disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.',
|
||||
}
|
||||
const short = (text) => (text.length > 60 ? `${text.slice(0, 60)}…` : text)
|
||||
|
||||
export default function CustomerFunktionen({ customerId }) {
|
||||
const [projects, setProjects] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [confirm, setConfirm] = useState(null) // { projectId, action }
|
||||
const [note, setNote] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [status, setStatus] = useState(null) // { ok, text }
|
||||
|
||||
const load = useCallback(() => {
|
||||
setError(null)
|
||||
getFunktionen(customerId)
|
||||
.then(({ projects: list }) => setProjects(list || []))
|
||||
.catch((err) => setError(err.message || 'Funktionen konnten nicht geladen werden.'))
|
||||
}, [customerId])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
setStatus(null)
|
||||
try {
|
||||
const { projects: changed } = await setNewsFeature(customerId, { projectId: confirm.projectId, action: confirm.action, note: note.trim() })
|
||||
const updated = changed?.[0]
|
||||
if (updated) setProjects((list) => list.map((p) => (p.projectId === updated.projectId ? updated : p)))
|
||||
setConfirm(null)
|
||||
setNote('')
|
||||
setStatus({ ok: true, text: 'Gespeichert.' })
|
||||
} catch (err) {
|
||||
setConfirm(null)
|
||||
setStatus({ ok: false, text: err.message })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
return (
|
||||
<Card title="Funktionen" pad={false}>
|
||||
<div className="area-rows">
|
||||
<div className="area-row funktionen-row head"><span>Projekt</span><span>News</span><span>Ticket</span><span>Aktionen</span></div>
|
||||
{projects.map((p) => {
|
||||
const news = p.features?.news || { state: 'off' }
|
||||
const [tone, label] = STATE[news.state] || ['muted', news.state]
|
||||
const isConfirming = confirm?.projectId === p.projectId
|
||||
return (
|
||||
<div key={p.projectId} className="area-row funktionen-row">
|
||||
<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>
|
||||
<Badge tone={tone}>{label}</Badge>
|
||||
{news.adminNote && <><br /><span className="area-muted" title={news.adminNote}>{short(news.adminNote)}</span></>}
|
||||
</span>
|
||||
<span>{news.ticketMissing ? <Badge tone="danger">Ticket fehlt</Badge> : news.ticketWoid ? `#${news.ticketWoid}` : '–'}</span>
|
||||
<span className="area-inline">
|
||||
{isConfirming ? (
|
||||
<>
|
||||
<span>{CONFIRM[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}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>Abbrechen</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{news.state !== 'active' && (
|
||||
<Button size="sm" variant="ghost" onClick={() => { setNote(''); setConfirm({ projectId: p.projectId, action: 'activate' }) }}>Aktivieren (ohne Zahlung)</Button>
|
||||
)}
|
||||
{['ordered', 'setup', 'active'].includes(news.state) && (
|
||||
<Button size="sm" variant="ghost" onClick={() => { setNote(''); setConfirm({ projectId: p.projectId, action: 'disable' }) }}>Abschalten</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{status && <div className={status.ok ? 'area-message' : 'area-error'} style={{ margin: 12 }}>{status.text}</div>}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user