Kundenseite: Abo-Status und Funktionen; Seite E-Mail-Speicher

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 02:58:37 +00:00
1 parent 91d526e909
commit cef0d5465e
8 files changed
+297 -4

No files matched your search

+2
View File
@@ -10,6 +10,7 @@ import AdminPage from './pages/AdminPage'
import CustomersPage from './pages/CustomersPage' import CustomersPage from './pages/CustomersPage'
import CustomerDetailPage from './pages/CustomerDetailPage' import CustomerDetailPage from './pages/CustomerDetailPage'
import CustomerChatPage from './pages/CustomerChatPage' import CustomerChatPage from './pages/CustomerChatPage'
import StorageTasksPage from './pages/StorageTasksPage'
import FinancePage from './pages/FinancePage' import FinancePage from './pages/FinancePage'
import FinanceSettingsPage from './pages/FinanceSettingsPage' import FinanceSettingsPage from './pages/FinanceSettingsPage'
import LegalPage from './pages/LegalPage' import LegalPage from './pages/LegalPage'
@@ -52,6 +53,7 @@ function AppRoutes() {
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} /> <Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
{/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */} {/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */}
<Route path="/customers/chat" element={<ProtectedRoute><CustomerChatPage /></ProtectedRoute>} /> <Route path="/customers/chat" element={<ProtectedRoute><CustomerChatPage /></ProtectedRoute>} />
<Route path="/customers/email-speicher" element={<ProtectedRoute><StorageTasksPage /></ProtectedRoute>} />
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} /> <Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
<Route path="/leads" element={<Navigate to="/tickets" replace />} /> <Route path="/leads" element={<Navigate to="/tickets" replace />} />
{/* Finanzen: nur Admins, alle anderen gehen zu /tickets */} {/* Finanzen: nur Admins, alle anderen gehen zu /tickets */}
+44
View File
@@ -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>
)
}
+4
View File
@@ -8,6 +8,10 @@ const BASE_TABS = [
{ id: 'projects', label: 'Projekte' }, { id: 'projects', label: 'Projekte' },
// Chat mit dem Kunden (derselbe Gespraechsbaustein wie im Messenger unter Kunden -> Chat) // Chat mit dem Kunden (derselbe Gespraechsbaustein wie im Messenger unter Kunden -> Chat)
{ id: 'chat', label: 'Chat' }, { id: 'chat', label: 'Chat' },
// Bereiche aus dem frueheren Admin-Panel (Etappe 3)
{ id: 'abo', label: 'Abo & Angebote' },
{ id: 'gutschriften', label: 'Gutschriften' },
{ id: 'funktionen', label: 'Funktionen' },
// Protokoll der Kundenansicht (wer wann das Portal des Kunden geöffnet oder darin etwas geändert hat): alle Mitarbeiter // Protokoll der Kundenansicht (wer wann das Portal des Kunden geöffnet oder darin etwas geändert hat): alle Mitarbeiter
{ id: 'protokoll', label: 'Protokoll' }, { id: 'protokoll', label: 'Protokoll' },
] ]
+4
View File
@@ -14,6 +14,8 @@ import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads' import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
import CustomerViewLog from '../components/customer/CustomerViewLog' import CustomerViewLog from '../components/customer/CustomerViewLog'
import ChatThread from '../components/chat/ChatThread' import ChatThread from '../components/chat/ChatThread'
import AboStatusCard from '../components/customer/AboStatusCard'
import CustomerFunktionen from '../components/customer/CustomerFunktionen'
import KundenansichtButton from '../components/customer/KundenansichtButton' import KundenansichtButton from '../components/customer/KundenansichtButton'
import { getCustomerPortalStatus } from '../lib/teamApi' import { getCustomerPortalStatus } from '../lib/teamApi'
import { formatLastLogin } from '../lib/lastLogin' import { formatLastLogin } from '../lib/lastLogin'
@@ -106,6 +108,8 @@ export default function CustomerDetailPage() {
{activeTab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />} {activeTab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />}
{activeTab === 'projects' && <CustomerProjects customerId={customer.$id} />} {activeTab === 'projects' && <CustomerProjects customerId={customer.$id} />}
{activeTab === 'chat' && <Card><ChatThread customerId={customer.$id} customerName={customer.name} /></Card>} {activeTab === 'chat' && <Card><ChatThread customerId={customer.$id} customerName={customer.name} /></Card>}
{activeTab === 'abo' && <AboStatusCard customerId={customer.$id} />}
{activeTab === 'funktionen' && <CustomerFunktionen customerId={customer.$id} />}
{activeTab === 'protokoll' && <CustomerViewLog customerId={customer.$id} />} {activeTab === 'protokoll' && <CustomerViewLog customerId={customer.$id} />}
</div> </div>
) )
+99
View File
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { FaSpinner, FaRotateRight } from 'react-icons/fa6'
import { PageHeader, Card, Badge, Button } from '../components/ui'
import { listStorageTasks, markStorageDone } from '../lib/teamApi'
import { fmtDate, fmtDateTime } from '../lib/format'
import { changeText, failedText, isOverdue, orderedKind, partialText, scopeText, summaryText, taskText } from '../lib/storageFormat'
import '../styles/admin-areas.css'
// E-Mail-Speicher (Kunden -> E-Mail-Speicher): Zusatzspeicher-Aufgaben in konsoleH wie im frueheren Admin-Panel
export default function StorageTasksPage() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [busyId, setBusyId] = useState('')
const load = useCallback(() => {
setError(null)
listStorageTasks()
.then(setData)
.catch((err) => setError(err.message || 'Speicher-Aufgaben konnten nicht geladen werden.'))
}, [])
useEffect(() => { load() }, [load])
const done = async (task) => {
if (task.partial && !window.confirm('Stripe-Menge von Hand korrigiert?')) return
setBusyId(task.id)
try {
await markStorageDone(task.id)
load()
} catch (err) {
window.alert(`Aufgabe konnte nicht als erledigt gespeichert werden: ${err.message}`)
} finally {
setBusyId('')
}
}
const summary = data ? summaryText(data) : null
return (
<div className="page">
<PageHeader title="E-Mail-Speicher" subtitle="Zusatzspeicher der Abos in konsoleH einrichten und senken" />
{error && (
<div className="area-error area-inline">
{error} <Button size="sm" variant="ghost" onClick={load}><FaRotateRight /> Erneut versuchen</Button>
</div>
)}
{!data && !error && <FaSpinner className="spinner" />}
{data && (
<>
<div className={`area-summary ${summary.warn ? 'warn' : ''}`}>{summary.text}</div>
<Card pad={false}>
<div className="area-rows">
<div className="area-row storage-row head"><span>Fällig</span><span>Kunde</span><span>Aufgabe in konsoleH</span><span>Bestellt</span><span /></div>
{data.tasks.length === 0 && <div className="area-row"><span className="area-muted">Keine offenen Speicher-Aufgaben.</span></div>}
{data.tasks.map((t) => (
<div key={t.id} className="area-row storage-row">
<span>
{t.kind === 'einrichten'
? <><Badge tone="warn">jetzt einrichten</Badge><br /><span className="area-muted">bestellt {fmtDateTime(t.createdAt)}</span></>
: <><Badge tone={isOverdue(t) ? 'warn' : 'muted'}>zum Periodenende</Badge><br />{fmtDate(t.dueAt)}</>}
</span>
<span>
<Link to={`/customers/${t.customerId}`}><strong>{t.customerName}</strong></Link>
{t.customerCode && <><br /><span className="area-muted">Kd.-Nr. {t.customerCode}</span></>}
{t.email && <><br /><span className="area-muted">{t.email}</span></>}
<br /><span className="area-muted">{t.productLabel}</span>
</span>
<span>
<strong>{taskText(t)}</strong>
<br /><span className="area-muted">{scopeText(t)}</span>
<br /><span className="area-muted">{changeText(t)}</span>
{t.partial && <><br /><span className="area-danger">{partialText(t)}</span></>}
</span>
<span>{fmtDateTime(t.createdAt)}<br /><span className="area-muted">{orderedKind(t)}</span></span>
<span>
<Button size="sm" variant="primary" data-storage-done={t.id} onClick={() => done(t)} disabled={busyId === t.id}>
{busyId === t.id ? <FaSpinner className="spinner" /> : 'Erledigt'}
</Button>
</span>
</div>
))}
</div>
</Card>
{data.failed.length > 0 && (
<Card title="Gescheiterte Änderungen (letzte 30 Tage)">
{data.failed.map((f) => (
<div key={f.id} style={{ marginBottom: 6 }}>
{fmtDateTime(f.createdAt)} · <strong>{f.customerName}</strong> · <span className="area-muted">{failedText(f)}</span>
</div>
))}
</Card>
)}
<p className="area-muted" style={{ marginTop: 12 }}>
Jedes Abo bringt 5 GB E-Mail-Speicher mit, jedes Zusatzpaket 5 GB dazu. „Einrichten“: Postfachgrenze in konsoleH jetzt auf den Zielwert setzen. „Zum Periodenende“: erst am genannten Tag auf den Zielwert senken. Die Grenzen gelten für den Gesamtspeicher des Kunden (5 GB + 5 GB je Paket); er verteilt ihn nach seiner Vorgabe auf seine Postfächer (§ 3 Abs. 4). Angezeigt wird die Domain der Kontakt-Adresse, eine eigene Mail-Domain ist im Portal nicht gespeichert. „Vergeben“ zählt, was konsoleH gerade hält: eine Verringerung zählt erst nach „Erledigt“. Danach „Erledigt“ drücken – der Kunde hat seine Bestätigung schon bekommen.
</p>
</>
)}
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
/* Bereiche aus dem frueheren Admin-Panel (Gutschriften, Abo-Angebote, E-Mail-Speicher, Funktionen, Abo-Status).
Zeilen als Raster; unter 720 px untereinander, damit nichts waagerecht scrollt. */
.area-rows { display: flex; flex-direction: column; }
.area-row {
display: grid; gap: var(--sp-3); align-items: start; padding: 12px 16px;
border-bottom: 1px solid var(--border); overflow-wrap: anywhere;
}
.area-row:last-child { border-bottom: none; }
.area-row.head { color: var(--text-muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
.area-row.active { background: var(--accent-soft); }
.area-muted { color: var(--text-muted); font-size: 13px; }
.area-danger { color: #f87171; font-size: 13px; }
.area-warn { color: #fbbf24; font-size: 13px; }
.area-summary { padding: 12px 16px; border-radius: var(--r-sm); background: var(--surface-2); margin-bottom: var(--sp-3); }
.area-summary.warn { background: rgba(245, 158, 11, 0.16); color: #fbbf24; }
.area-message { padding: 10px 14px; border-radius: var(--r-sm); background: var(--accent-soft); margin-bottom: var(--sp-3); }
.area-error { padding: 10px 14px; border-radius: var(--r-sm); background: rgba(239, 68, 68, 0.14); color: #f87171; margin: var(--sp-2) 0; }
.area-kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; }
.area-kv dt { color: var(--text-muted); }
.area-kv dd { margin: 0; overflow-wrap: anywhere; }
.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) 200px 110px minmax(0, 1.4fr); }
@media (max-width: 720px) {
.area-row { grid-template-columns: minmax(0, 1fr) !important; }
.area-row.head { display: none; }
.area-kv { grid-template-columns: minmax(0, 1fr); }
.area-kv dt { margin-top: 6px; }
}
.area-rows a { color: var(--text); text-decoration: none; }
.area-rows a:hover { color: var(--accent); text-decoration: underline; }
+8 -4
View File
@@ -4,20 +4,23 @@ import { loadModule } from './load.mjs'
const { customerTabs, resolveCustomerTab } = await loadModule('src/lib/customerTabs.js') const { customerTabs, resolveCustomerTab } = await loadModule('src/lib/customerTabs.js')
test('customerTabs: Rechnungen nur für Admins, Reihenfolge mit Chat und Protokoll, Übersicht mit Umlaut', () => { test('customerTabs: volle Reiterfolge der Spec, Rechnungen nur für Admins', () => {
assert.deepEqual(customerTabs(true), [ assert.deepEqual(customerTabs(true), [
{ id: 'overview', label: 'Übersicht' }, { id: 'overview', label: 'Übersicht' },
{ id: 'tickets', label: 'Tickets' }, { id: 'tickets', label: 'Tickets' },
{ id: 'invoices', label: 'Rechnungen' }, { id: 'invoices', label: 'Rechnungen' },
{ id: 'projects', label: 'Projekte' }, { id: 'projects', label: 'Projekte' },
{ id: 'chat', label: 'Chat' }, { id: 'chat', label: 'Chat' },
{ id: 'abo', label: 'Abo & Angebote' },
{ id: 'gutschriften', label: 'Gutschriften' },
{ id: 'funktionen', label: 'Funktionen' },
{ id: 'protokoll', label: 'Protokoll' }, { id: 'protokoll', label: 'Protokoll' },
]) ])
const staff = customerTabs(false) const STAFF = ['overview', 'tickets', 'projects', 'chat', 'abo', 'gutschriften', 'funktionen', 'protokoll']
assert.deepEqual(staff.map((t) => t.id), ['overview', 'tickets', 'projects', 'chat', 'protokoll']) assert.deepEqual(customerTabs(false).map((t) => t.id), STAFF)
// unbekannt oder nicht eindeutig true gilt als kein Admin // unbekannt oder nicht eindeutig true gilt als kein Admin
for (const v of [undefined, null, 0, '', 'true']) { for (const v of [undefined, null, 0, '', 'true']) {
assert.deepEqual(customerTabs(v).map((t) => t.id), ['overview', 'tickets', 'projects', 'chat', 'protokoll'], String(v)) assert.deepEqual(customerTabs(v).map((t) => t.id), STAFF, String(v))
} }
// die Einträge tragen nur id und label (kein internes Feld) // die Einträge tragen nur id und label (kein internes Feld)
assert.deepEqual(Object.keys(customerTabs(true)[2]), ['id', 'label']) assert.deepEqual(Object.keys(customerTabs(true)[2]), ['id', 'label'])
@@ -32,6 +35,7 @@ test('resolveCustomerTab: erlaubter Reiter bleibt, sonst Übersicht', () => {
assert.equal(resolveCustomerTab('overview', false), 'overview') assert.equal(resolveCustomerTab('overview', false), 'overview')
assert.equal(resolveCustomerTab('protokoll', false), 'protokoll') assert.equal(resolveCustomerTab('protokoll', false), 'protokoll')
assert.equal(resolveCustomerTab('chat', false), 'chat') assert.equal(resolveCustomerTab('chat', false), 'chat')
for (const id of ['abo', 'gutschriften', 'funktionen']) assert.equal(resolveCustomerTab(id, false), id)
assert.equal(resolveCustomerTab('gibtsnicht', true), 'overview') assert.equal(resolveCustomerTab('gibtsnicht', true), 'overview')
assert.equal(resolveCustomerTab(undefined, true), 'overview') assert.equal(resolveCustomerTab(undefined, true), 'overview')
}) })