diff --git a/src/App.jsx b/src/App.jsx index 54f4579..5afa954 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,6 +10,7 @@ import AdminPage from './pages/AdminPage' import CustomersPage from './pages/CustomersPage' import CustomerDetailPage from './pages/CustomerDetailPage' import CustomerChatPage from './pages/CustomerChatPage' +import StorageTasksPage from './pages/StorageTasksPage' import FinancePage from './pages/FinancePage' import FinanceSettingsPage from './pages/FinanceSettingsPage' import LegalPage from './pages/LegalPage' @@ -52,6 +53,7 @@ function AppRoutes() { } /> {/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */} } /> + } /> } /> } /> {/* Finanzen: nur Admins, alle anderen gehen zu /tickets */} diff --git a/src/components/customer/AboStatusCard.jsx b/src/components/customer/AboStatusCard.jsx new file mode 100644 index 0000000..fb77c42 --- /dev/null +++ b/src/components/customer/AboStatusCard.jsx @@ -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 ( + + {error &&
{error}
} + {!abo && !error && } + {abo && !abo.status && kein Abo} + {abo && abo.status && ( +
+
Abo
+
+ {productLabelOf(abo.productKey)} + {abo.active ? 'aktiv' : abo.status} +
+
Leistungsbeginn
{fmtDate(abo.serviceStart)}
+ {abo.active && <>
Nächste Abbuchung
{fmtDate(abo.currentPeriodEnd)}
} + {abo.cancelAt && <>
Gekündigt zum
{fmtDate(abo.cancelAt)}
} +
Zusatzspeicher
{storageLabel(abo.storageUnits)}
+
+ )} +
+ ) +} diff --git a/src/components/customer/CustomerFunktionen.jsx b/src/components/customer/CustomerFunktionen.jsx new file mode 100644 index 0000000..d0d4624 --- /dev/null +++ b/src/components/customer/CustomerFunktionen.jsx @@ -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
{error}
+ if (!projects) return + if (!projects.length) return + + return ( + +
+
ProjektNewsTicketAktionen
+ {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 ( +
+ + {p.projectName} + {p.liveUrl && <>
{p.liveUrl.replace(/^https?:\/\//, '')}} +
+ + {label} + {news.adminNote && <>
{short(news.adminNote)}} +
+ {news.ticketMissing ? Ticket fehlt : news.ticketWoid ? `#${news.ticketWoid}` : '–'} + + {isConfirming ? ( + <> + {CONFIRM[confirm.action]} + setNote(e.target.value)} style={{ maxWidth: 220 }} /> + + + + ) : ( + <> + {news.state !== 'active' && ( + + )} + {['ordered', 'setup', 'active'].includes(news.state) && ( + + )} + + )} + +
+ ) + })} +
+ {status &&
{status.text}
} +
+ ) +} diff --git a/src/lib/customerTabs.js b/src/lib/customerTabs.js index 8d3dda8..3e3b12b 100644 --- a/src/lib/customerTabs.js +++ b/src/lib/customerTabs.js @@ -8,6 +8,10 @@ const BASE_TABS = [ { id: 'projects', label: 'Projekte' }, // Chat mit dem Kunden (derselbe Gespraechsbaustein wie im Messenger unter Kunden -> 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 { id: 'protokoll', label: 'Protokoll' }, ] diff --git a/src/pages/CustomerDetailPage.jsx b/src/pages/CustomerDetailPage.jsx index 3473f58..723a6f6 100644 --- a/src/pages/CustomerDetailPage.jsx +++ b/src/pages/CustomerDetailPage.jsx @@ -14,6 +14,8 @@ import { customerTabs, resolveCustomerTab } from '../lib/customerTabs' import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads' import CustomerViewLog from '../components/customer/CustomerViewLog' 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 { getCustomerPortalStatus } from '../lib/teamApi' import { formatLastLogin } from '../lib/lastLogin' @@ -106,6 +108,8 @@ export default function CustomerDetailPage() { {activeTab === 'invoices' && setTab('overview')} />} {activeTab === 'projects' && } {activeTab === 'chat' && } + {activeTab === 'abo' && } + {activeTab === 'funktionen' && } {activeTab === 'protokoll' && } ) diff --git a/src/pages/StorageTasksPage.jsx b/src/pages/StorageTasksPage.jsx new file mode 100644 index 0000000..3b2c984 --- /dev/null +++ b/src/pages/StorageTasksPage.jsx @@ -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 ( +
+ + {error && ( +
+ {error} +
+ )} + {!data && !error && } + {data && ( + <> +
{summary.text}
+ +
+
FälligKundeAufgabe in konsoleHBestellt
+ {data.tasks.length === 0 &&
Keine offenen Speicher-Aufgaben.
} + {data.tasks.map((t) => ( +
+ + {t.kind === 'einrichten' + ? <>jetzt einrichten
bestellt {fmtDateTime(t.createdAt)} + : <>zum Periodenende
{fmtDate(t.dueAt)}} +
+ + {t.customerName} + {t.customerCode && <>
Kd.-Nr. {t.customerCode}} + {t.email && <>
{t.email}} +
{t.productLabel} +
+ + {taskText(t)} +
{scopeText(t)} +
{changeText(t)} + {t.partial && <>
{partialText(t)}} +
+ {fmtDateTime(t.createdAt)}
{orderedKind(t)}
+ + + +
+ ))} +
+
+ {data.failed.length > 0 && ( + + {data.failed.map((f) => ( +
+ {fmtDateTime(f.createdAt)} · {f.customerName} · {failedText(f)} +
+ ))} +
+ )} +

+ 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. +

+ + )} +
+ ) +} diff --git a/src/styles/admin-areas.css b/src/styles/admin-areas.css new file mode 100644 index 0000000..575ae4c --- /dev/null +++ b/src/styles/admin-areas.css @@ -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; } diff --git a/tests/unit/customerTabs.test.mjs b/tests/unit/customerTabs.test.mjs index 41e14a8..e0f8b58 100644 --- a/tests/unit/customerTabs.test.mjs +++ b/tests/unit/customerTabs.test.mjs @@ -4,20 +4,23 @@ import { loadModule } from './load.mjs' 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), [ { id: 'overview', label: 'Übersicht' }, { id: 'tickets', label: 'Tickets' }, { id: 'invoices', label: 'Rechnungen' }, { id: 'projects', label: 'Projekte' }, { id: 'chat', label: 'Chat' }, + { id: 'abo', label: 'Abo & Angebote' }, + { id: 'gutschriften', label: 'Gutschriften' }, + { id: 'funktionen', label: 'Funktionen' }, { id: 'protokoll', label: 'Protokoll' }, ]) - const staff = customerTabs(false) - assert.deepEqual(staff.map((t) => t.id), ['overview', 'tickets', 'projects', 'chat', 'protokoll']) + const STAFF = ['overview', 'tickets', 'projects', 'chat', 'abo', 'gutschriften', 'funktionen', 'protokoll'] + assert.deepEqual(customerTabs(false).map((t) => t.id), STAFF) // unbekannt oder nicht eindeutig true gilt als kein Admin 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) 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('protokoll', false), 'protokoll') 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(undefined, true), 'overview') })