diff --git a/src/components/customer/CustomerViewLog.jsx b/src/components/customer/CustomerViewLog.jsx new file mode 100644 index 0000000..6020c02 --- /dev/null +++ b/src/components/customer/CustomerViewLog.jsx @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from 'react' +import { FaSpinner, FaRotateRight } from 'react-icons/fa6' +import { Card, Button, EmptyState } from '../ui' +import { getCustomerViewLog } from '../../lib/teamApi' +import { formatViewLogTime, viewLogLabel } from '../../lib/viewLog' + +// Reiter „Protokoll“: wer wann die Kundenansicht dieses Kunden geöffnet oder darin etwas geändert hat +export default function CustomerViewLog({ customerId }) { + const [entries, setEntries] = useState(null) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + setError(null) + setEntries(null) + try { + const { entries: list } = await getCustomerViewLog(customerId) + setEntries(list || []) + } catch (err) { + setError(err.message || 'Protokoll konnte nicht geladen werden.') + } + }, [customerId]) + + useEffect(() => { load() }, [load]) + + if (error) { + return ( + +
+ {error} + +
+
+ ) + } + if (!entries) return + if (entries.length === 0) { + return + } + + return ( + +
+ {entries.map((e) => ( +
+ {formatViewLogTime(e.at)} + {e.staffName || '–'} +
+
{viewLogLabel(e.action)}
+ {e.detail &&
{e.detail}
} +
+
+ ))} +
+
+ ) +} diff --git a/src/lib/customerTabs.js b/src/lib/customerTabs.js index e9056b7..a3b058c 100644 --- a/src/lib/customerTabs.js +++ b/src/lib/customerTabs.js @@ -2,10 +2,12 @@ // die nur Admins freigibt: für alle anderen gibt es den Reiter nicht. const BASE_TABS = [ - { id: 'overview', label: 'Uebersicht' }, + { id: 'overview', label: 'Übersicht' }, { id: 'tickets', label: 'Tickets' }, { id: 'invoices', label: 'Rechnungen', adminOnly: true }, { id: 'projects', label: 'Projekte' }, + // Protokoll der Kundenansicht (wer wann das Portal des Kunden geöffnet oder darin etwas geändert hat): alle Mitarbeiter + { id: 'protokoll', label: 'Protokoll' }, ] /** Reiter für Tabs: [{ id, label }]; „Rechnungen“ nur für Admins. */ diff --git a/src/lib/viewLog.js b/src/lib/viewLog.js new file mode 100644 index 0000000..06970f1 --- /dev/null +++ b/src/lib/viewLog.js @@ -0,0 +1,25 @@ +// Protokoll der Kundenansicht (Portal-Collection customerViewLog): Beschriftung und Zeit. Rein, unter Node testbar. + +const LABELS = { + geoeffnet: 'Kundenansicht geöffnet', + beendet: 'Kundenansicht beendet', + abgelaufen: 'Kundenansicht abgelaufen', + news_angelegt: 'News angelegt', + news_bild: 'News-Foto hochgeladen', + news_geaendert: 'News geändert', + news_geloescht: 'News gelöscht', +} + +/** Deutsche Beschriftung einer Aktion; unbekannte Aktionen erscheinen roh. */ +export const viewLogLabel = (action) => LABELS[action] || action + +/** ISO-Zeit -> "dd.mm.yyyy hh:mm" in deutscher Zeit; leer bleibt leer. */ +export function formatViewLogTime(iso) { + if (!iso) return '' + const parts = Object.fromEntries( + new Intl.DateTimeFormat('de-DE', { + timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', + }).formatToParts(new Date(iso)).map((p) => [p.type, p.value]) + ) + return `${parts.day}.${parts.month}.${parts.year} ${parts.hour}:${parts.minute}` +} diff --git a/src/pages/CustomerDetailPage.jsx b/src/pages/CustomerDetailPage.jsx index 0e7abf0..f34f02b 100644 --- a/src/pages/CustomerDetailPage.jsx +++ b/src/pages/CustomerDetailPage.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { useParams, Link, useNavigate } from 'react-router-dom' +import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom' import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaArrowUpRightFromSquare, FaBoxArchive, FaBoxOpen, FaTrash } from 'react-icons/fa6' import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite' import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui' @@ -12,6 +12,7 @@ import { useAuth } from '../context/AuthContext' import { useWebsiteProjects } from '../hooks/useWebsiteProjects' import { customerTabs, resolveCustomerTab } from '../lib/customerTabs' import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads' +import CustomerViewLog from '../components/customer/CustomerViewLog' const PORTAL_URL = 'https://project.webklar.com' @@ -20,7 +21,13 @@ export default function CustomerDetailPage() { const [customer, setCustomer] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [tab, setTab] = useState('overview') + // Reiter steht in der Adresse (/customers/?tab=protokoll), damit Sammellisten und Team-Mails direkt hineinspringen + const [searchParams, setSearchParams] = useSearchParams() + const tab = searchParams.get('tab') || 'overview' + const setTab = useCallback( + (next) => setSearchParams(next === 'overview' ? {} : { tab: next }, { replace: true }), + [setSearchParams] + ) const { isAdmin } = useAuth() const navigate = useNavigate() @@ -93,6 +100,7 @@ export default function CustomerDetailPage() { {activeTab === 'tickets' && } {activeTab === 'invoices' && setTab('overview')} />} {activeTab === 'projects' && } + {activeTab === 'protokoll' && } ) } diff --git a/tests/unit/customerTabs.test.mjs b/tests/unit/customerTabs.test.mjs index 2fa5325..e71c443 100644 --- a/tests/unit/customerTabs.test.mjs +++ b/tests/unit/customerTabs.test.mjs @@ -4,18 +4,19 @@ import { loadModule } from './load.mjs' const { customerTabs, resolveCustomerTab } = await loadModule('src/lib/customerTabs.js') -test('customerTabs: Rechnungen nur für Admins, Reihenfolge bleibt', () => { +test('customerTabs: Rechnungen nur für Admins, Reihenfolge mit Protokoll, Übersicht mit Umlaut', () => { assert.deepEqual(customerTabs(true), [ - { id: 'overview', label: 'Uebersicht' }, + { id: 'overview', label: 'Übersicht' }, { id: 'tickets', label: 'Tickets' }, { id: 'invoices', label: 'Rechnungen' }, { id: 'projects', label: 'Projekte' }, + { id: 'protokoll', label: 'Protokoll' }, ]) const staff = customerTabs(false) - assert.deepEqual(staff.map((t) => t.id), ['overview', 'tickets', 'projects']) + assert.deepEqual(staff.map((t) => t.id), ['overview', 'tickets', 'projects', 'protokoll']) // 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'], String(v)) + assert.deepEqual(customerTabs(v).map((t) => t.id), ['overview', 'tickets', 'projects', 'protokoll'], String(v)) } // die Einträge tragen nur id und label (kein internes Feld) assert.deepEqual(Object.keys(customerTabs(true)[2]), ['id', 'label']) @@ -28,6 +29,7 @@ test('resolveCustomerTab: erlaubter Reiter bleibt, sonst Übersicht', () => { assert.equal(resolveCustomerTab('tickets', false), 'tickets') assert.equal(resolveCustomerTab('projects', false), 'projects') assert.equal(resolveCustomerTab('overview', false), 'overview') + assert.equal(resolveCustomerTab('protokoll', false), 'protokoll') assert.equal(resolveCustomerTab('gibtsnicht', true), 'overview') assert.equal(resolveCustomerTab(undefined, true), 'overview') }) diff --git a/tests/unit/viewLog.test.mjs b/tests/unit/viewLog.test.mjs new file mode 100644 index 0000000..d453e7c --- /dev/null +++ b/tests/unit/viewLog.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +// Protokoll der Kundenansicht: Aktionen des Portals (customerViewLog.action) als deutsche Beschriftung +const { viewLogLabel, formatViewLogTime } = await loadModule('src/lib/viewLog.js') + +test('viewLogLabel: alle sieben Aktionen und Rohwert', () => { + assert.deepEqual( + ['geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht'].map(viewLogLabel), + ['Kundenansicht geöffnet', 'Kundenansicht beendet', 'Kundenansicht abgelaufen', 'News angelegt', 'News-Foto hochgeladen', 'News geändert', 'News gelöscht'] + ) + assert.equal(viewLogLabel('neu_irgendwas'), 'neu_irgendwas') +}) + +test('formatViewLogTime: deutsche Zeit, dd.mm.yyyy hh:mm', () => { + assert.equal(formatViewLogTime('2026-09-30T10:05:00.000Z'), '30.09.2026 12:05') + assert.equal(formatViewLogTime('2026-12-01T23:30:00.000Z'), '02.12.2026 00:30') + assert.equal(formatViewLogTime(''), '') +})