Compare commits

...
3 Commits
15 changed files with 311 additions and 14 deletions

No files matched your search

+1 -1
View File
@@ -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
+14 -1
View File
@@ -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>
)
}
+3 -1
View File
@@ -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. */
+10
View File
@@ -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`
}
+16
View File
@@ -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
}
}
+31
View File
@@ -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
}
+25
View File
@@ -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}`
}
+30 -6
View File
@@ -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 }}>
+1 -1
View File
@@ -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
+6 -4
View File
@@ -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')
})
+13
View File
@@ -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')
})
+52
View File
@@ -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)
})
+20
View File
@@ -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(''), '')
})