Compare commits
3
Commits
52968b3017
...
fce2f41463
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce2f41463 | ||
|
|
370724a9a7 | ||
|
|
8c7a3fcced |
No files matched your search
@@ -91,7 +91,7 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
||||
<PreviewLinkButton href={project.previewUrl} />
|
||||
<PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />
|
||||
<GiteaLinkButton href={project.giteaRepoUrl} />
|
||||
{showActions && onUnassign && (
|
||||
<button
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { FaEye } from 'react-icons/fa6'
|
||||
import { createPreviewLink } from '../lib/teamApi'
|
||||
import { openInNewTab } from '../lib/openInNewTab'
|
||||
|
||||
export default function PreviewLinkButton({ href, title = 'Preview oeffnen', size = 16 }) {
|
||||
// Mit subdomain holt der Knopf einen Einmal-Link vom Portal (Mitarbeiter sind dort nicht mehr angemeldet);
|
||||
// ohne subdomain bleibt er ein einfacher Link.
|
||||
export default function PreviewLinkButton({ href, subdomain, title = 'Preview oeffnen', size = 16 }) {
|
||||
if (!href) return null
|
||||
|
||||
const open = (event) => {
|
||||
if (!subdomain) return
|
||||
event.preventDefault()
|
||||
openInNewTab(() => createPreviewLink({ subdomain, previewUrl: href })).catch((err) =>
|
||||
window.alert(err.message || 'Die Preview konnte nicht geöffnet werden.')
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={open}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={title}
|
||||
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<span className="muted">{error}</span>
|
||||
<Button variant="ghost" onClick={load}><FaRotateRight /> Erneut versuchen</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
if (!entries) return <Card><FaSpinner className="spinner" /></Card>
|
||||
if (entries.length === 0) {
|
||||
return <Card><EmptyState title="Noch keine Einträge" hint="Hier steht, wer die Kundenansicht geöffnet oder darin etwas geändert hat." /></Card>
|
||||
}
|
||||
|
||||
return (
|
||||
<Card pad={false}>
|
||||
<div className="list">
|
||||
{entries.map((e) => (
|
||||
<div key={e.id} className="list-row">
|
||||
<span className="mono muted" style={{ minWidth: 130 }}>{formatViewLogTime(e.at)}</span>
|
||||
<span style={{ minWidth: 120, fontWeight: 600 }}>{e.staffName || '–'}</span>
|
||||
<div className="grow">
|
||||
<div>{viewLogLabel(e.action)}</div>
|
||||
{e.detail && <div className="muted">{e.detail}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react'
|
||||
import { FaArrowUpRightFromSquare, FaSpinner } from 'react-icons/fa6'
|
||||
import { Button } from '../ui'
|
||||
import { createKundenansichtLink } from '../../lib/teamApi'
|
||||
import { openInNewTab } from '../../lib/openInNewTab'
|
||||
|
||||
/** Kundenansicht im Portal in einem neuen Tab öffnen (Einmal-Link). Ohne Portal-Zugang ausgegraut. */
|
||||
export default function KundenansichtButton({ customer, variant = 'primary' }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const hasAccess = Boolean(customer?.portalAccessEnabled && customer?.appwriteUserId)
|
||||
|
||||
const open = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await openInNewTab(() => createKundenansichtLink(customer.$id))
|
||||
} catch (err) {
|
||||
window.alert(err.message || 'Die Kundenansicht konnte nicht geöffnet werden.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={open}
|
||||
disabled={!hasAccess || busy}
|
||||
title={hasAccess ? 'Portal so öffnen, wie der Kunde es sieht' : 'Kein Portal-Zugang'}
|
||||
>
|
||||
{busy ? <FaSpinner className="spinner" /> : <FaArrowUpRightFromSquare />} Kundenansicht öffnen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { formatViewLogTime } from './viewLog'
|
||||
|
||||
// Letzter Portal-Login eines Kunden (Karte „Portal-Zugang“). Rein, unter Node testbar.
|
||||
|
||||
/** ISO-Zeit -> "dd.mm.yyyy, hh:mm Uhr" in deutscher Zeit; ohne Login "noch nie". */
|
||||
export function formatLastLogin(iso) {
|
||||
if (!iso) return 'noch nie'
|
||||
const [day, time] = formatViewLogTime(iso).split(' ')
|
||||
return `${day}, ${time} Uhr`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Einmal-Links des Kundenportals (Kundenansicht, Preview) in einem neuen Tab oeffnen. Der Tab geht synchron im Klick
|
||||
// auf (sonst blockiert der Browser das Pop-up), die Adresse kommt erst nach der Anfrage ans Portal. Rein (ohne React),
|
||||
// damit unter Node testbar.
|
||||
|
||||
/** getUrl: () => Promise<string>. Fehler schliessen den leeren Tab wieder und werden weitergereicht. */
|
||||
export async function openInNewTab(getUrl, { win = globalThis.window } = {}) {
|
||||
const tab = win.open('', '_blank')
|
||||
if (!tab) throw new Error('Neuer Tab wurde vom Browser blockiert.')
|
||||
tab.opener = null
|
||||
try {
|
||||
tab.location.href = await getUrl()
|
||||
} catch (err) {
|
||||
tab.close()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { account } from './appwrite'
|
||||
import { createFinanceRequest } from './financeRequest'
|
||||
|
||||
/**
|
||||
* Client fuer den Team-Eingang des Kundenportals (/api/team): alle Mitarbeiter, Appwrite-JWT (Bearer).
|
||||
* Anfrage-Kern (JWT-Cache, Wiederholung bei 401, deutsche Fehlertexte) wie bei den Finanzen: financeRequest.js.
|
||||
*/
|
||||
const PROJECT_ADMIN_URL =
|
||||
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
||||
|
||||
const request = createFinanceRequest({ baseUrl: `${PROJECT_ADMIN_URL}/api/team`, createJWT: () => account.createJWT() })
|
||||
|
||||
const enc = encodeURIComponent
|
||||
|
||||
/** { hasAccess, lastLoginAt } */
|
||||
export const getCustomerPortalStatus = (customerId) => request(`/customers/${enc(customerId)}/portal`)
|
||||
|
||||
/** { entries: [{ id, at, staffName, action, detail }] }, neueste zuerst */
|
||||
export const getCustomerViewLog = (customerId) => request(`/customers/${enc(customerId)}/protokoll`)
|
||||
|
||||
/** Einmal-Link (60 s) in die Kundenansicht des Portals */
|
||||
export async function createKundenansichtLink(customerId) {
|
||||
const { openUrl } = await request(`/customers/${enc(customerId)}/kundenansicht`, { method: 'POST', body: {} })
|
||||
return openUrl
|
||||
}
|
||||
|
||||
/** Einmal-Link fuer die Preview eines Projekts */
|
||||
export async function createPreviewLink({ subdomain, previewUrl }) {
|
||||
const { openUrl } = await request('/preview-link', { method: 'POST', body: { subdomain, previewUrl } })
|
||||
return openUrl
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaArrowUpRightFromSquare, FaBoxArchive, FaBoxOpen, FaTrash } from 'react-icons/fa6'
|
||||
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, 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'
|
||||
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
||||
@@ -12,6 +12,10 @@ 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'
|
||||
import KundenansichtButton from '../components/customer/KundenansichtButton'
|
||||
import { getCustomerPortalStatus } from '../lib/teamApi'
|
||||
import { formatLastLogin } from '../lib/lastLogin'
|
||||
|
||||
const PORTAL_URL = 'https://project.webklar.com'
|
||||
|
||||
@@ -20,7 +24,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/<id>?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()
|
||||
|
||||
@@ -78,6 +88,7 @@ export default function CustomerDetailPage() {
|
||||
subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' · ')}
|
||||
actions={
|
||||
<>
|
||||
<KundenansichtButton customer={customer} />
|
||||
<Button variant="ghost" onClick={toggleArchived}>
|
||||
{archived ? <><FaBoxOpen /> Wiederherstellen</> : <><FaBoxArchive /> Archivieren</>}
|
||||
</Button>
|
||||
@@ -93,6 +104,7 @@ export default function CustomerDetailPage() {
|
||||
{activeTab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
|
||||
{activeTab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />}
|
||||
{activeTab === 'projects' && <CustomerProjects customerId={customer.$id} />}
|
||||
{activeTab === 'protokoll' && <CustomerViewLog customerId={customer.$id} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -192,6 +204,15 @@ function PortalAccessCard({ customer, onSaved }) {
|
||||
|
||||
const hasAccess = Boolean(customer.portalAccessEnabled && customer.appwriteUserId)
|
||||
const currentPassword = getCustomerPortalPassword(customer)
|
||||
// Letzter Login kommt vom Portal (customerPortalAccess), nicht aus dem Kunden-Dokument
|
||||
const [lastLogin, setLastLogin] = useState(undefined)
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getCustomerPortalStatus(customer.$id)
|
||||
.then((status) => active && setLastLogin(status.lastLoginAt || null))
|
||||
.catch(() => active && setLastLogin(false))
|
||||
return () => { active = false }
|
||||
}, [customer.$id])
|
||||
|
||||
const savePassword = async () => {
|
||||
if (!customer.email) {
|
||||
@@ -224,6 +245,11 @@ function PortalAccessCard({ customer, onSaved }) {
|
||||
: <Badge tone="muted">kein Zugang</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
|
||||
<span className="muted">Letzter Login</span>
|
||||
<span>{lastLogin === undefined ? '…' : lastLogin === false ? 'nicht abrufbar' : formatLastLogin(lastLogin)}</span>
|
||||
</div>
|
||||
|
||||
<Field label="Login (E-Mail)">
|
||||
<CopyableCredential value={customer.email} label="Login" />
|
||||
</Field>
|
||||
@@ -246,9 +272,7 @@ function PortalAccessCard({ customer, onSaved }) {
|
||||
<Button variant="primary" onClick={savePassword} disabled={busy}>
|
||||
{busy ? <FaSpinner className="spinner" /> : <><FaKey /> {hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}</>}
|
||||
</Button>
|
||||
<Button as="a" href={PORTAL_URL} target="_blank" rel="noreferrer" variant="ghost">
|
||||
<FaArrowUpRightFromSquare /> Portal öffnen
|
||||
</Button>
|
||||
<KundenansichtButton customer={customer} variant="ghost" />
|
||||
{msg && <span className="muted">{msg}</span>}
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
|
||||
|
||||
@@ -574,7 +574,7 @@ export default function ProjectsPage() {
|
||||
<FaGlobe style={hostBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined} />
|
||||
</button>
|
||||
) : (
|
||||
<PreviewLinkButton href={project.previewUrl} />
|
||||
<PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />
|
||||
)}
|
||||
{hosted && ready && !project.archived && (
|
||||
<button
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
// Karte „Portal-Zugang“: letzter Login des Kunden in deutscher Zeit
|
||||
const { formatLastLogin } = await loadModule('src/lib/lastLogin.js')
|
||||
|
||||
test('formatLastLogin', () => {
|
||||
assert.equal(formatLastLogin(null), 'noch nie')
|
||||
assert.equal(formatLastLogin(''), 'noch nie')
|
||||
assert.equal(formatLastLogin('2026-09-30T10:05:00.000Z'), '30.09.2026, 12:05 Uhr')
|
||||
assert.equal(formatLastLogin('2026-01-15T07:00:00.000Z'), '15.01.2026, 08:00 Uhr')
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
// Neuer Tab fuer Einmal-Links (Kundenansicht, Preview): der Tab muss im Klick synchron aufgehen, sonst blockiert
|
||||
// der Browser das Pop-up; die Adresse kommt erst nach der Anfrage ans Portal.
|
||||
const { openInNewTab } = await loadModule('src/lib/openInNewTab.js')
|
||||
|
||||
function fakeWindow() {
|
||||
const order = []
|
||||
const tab = { location: {}, opener: 'ticketsystem', close: () => order.push('close') }
|
||||
const win = {
|
||||
open: (...args) => {
|
||||
order.push('open')
|
||||
win.openArgs = args
|
||||
return tab
|
||||
},
|
||||
}
|
||||
return { win, tab, order }
|
||||
}
|
||||
|
||||
test('open laeuft vor getUrl, danach wird die Adresse gesetzt', async () => {
|
||||
const { win, tab, order } = fakeWindow()
|
||||
await openInNewTab(async () => {
|
||||
order.push('getUrl')
|
||||
return 'https://project.webklar.com/api/auth/kundenansicht?token=abc'
|
||||
}, { win })
|
||||
assert.deepEqual(order, ['open', 'getUrl'])
|
||||
assert.deepEqual(win.openArgs, ['', '_blank'])
|
||||
assert.equal(tab.location.href, 'https://project.webklar.com/api/auth/kundenansicht?token=abc')
|
||||
assert.equal(tab.opener, null)
|
||||
})
|
||||
|
||||
test('Fehler beim Holen -> Tab wird geschlossen, Fehler weitergereicht', async () => {
|
||||
const { win, tab, order } = fakeWindow()
|
||||
await assert.rejects(
|
||||
() => openInNewTab(async () => { throw new Error('Kein Portal-Zugang') }, { win }),
|
||||
/Kein Portal-Zugang/
|
||||
)
|
||||
assert.deepEqual(order, ['open', 'close'])
|
||||
assert.equal(tab.location.href, undefined)
|
||||
})
|
||||
|
||||
test('Pop-up blockiert -> Fehlermeldung, getUrl nicht aufgerufen', async () => {
|
||||
let called = false
|
||||
const win = { open: () => null }
|
||||
await assert.rejects(
|
||||
() => openInNewTab(async () => { called = true; return 'x' }, { win }),
|
||||
{ message: 'Neuer Tab wurde vom Browser blockiert.' }
|
||||
)
|
||||
assert.equal(called, false)
|
||||
})
|
||||
@@ -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(''), '')
|
||||
})
|
||||
Reference in new issue
Block a user