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
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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')
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user