Kundenseite: Kundenansicht oeffnen, letzter Login, Preview per Einmal-Link
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
370724a9a7
commit
fce2f41463
7 files changed
+92
-7
No files matched your search
@@ -91,7 +91,7 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
<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} />
|
<GiteaLinkButton href={project.giteaRepoUrl} />
|
||||||
{showActions && onUnassign && (
|
{showActions && onUnassign && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
import { FaEye } from 'react-icons/fa6'
|
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
|
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 (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
|
onClick={open}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title={title}
|
title={title}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useParams, Link, useNavigate, useSearchParams } 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 { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaBoxArchive, FaBoxOpen, FaTrash } from 'react-icons/fa6'
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||||
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
|
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
|
||||||
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
||||||
@@ -13,6 +13,9 @@ import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
|||||||
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
|
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 KundenansichtButton from '../components/customer/KundenansichtButton'
|
||||||
|
import { getCustomerPortalStatus } from '../lib/teamApi'
|
||||||
|
import { formatLastLogin } from '../lib/lastLogin'
|
||||||
|
|
||||||
const PORTAL_URL = 'https://project.webklar.com'
|
const PORTAL_URL = 'https://project.webklar.com'
|
||||||
|
|
||||||
@@ -85,6 +88,7 @@ export default function CustomerDetailPage() {
|
|||||||
subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' · ')}
|
subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' · ')}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
|
<KundenansichtButton customer={customer} />
|
||||||
<Button variant="ghost" onClick={toggleArchived}>
|
<Button variant="ghost" onClick={toggleArchived}>
|
||||||
{archived ? <><FaBoxOpen /> Wiederherstellen</> : <><FaBoxArchive /> Archivieren</>}
|
{archived ? <><FaBoxOpen /> Wiederherstellen</> : <><FaBoxArchive /> Archivieren</>}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -200,6 +204,15 @@ function PortalAccessCard({ customer, onSaved }) {
|
|||||||
|
|
||||||
const hasAccess = Boolean(customer.portalAccessEnabled && customer.appwriteUserId)
|
const hasAccess = Boolean(customer.portalAccessEnabled && customer.appwriteUserId)
|
||||||
const currentPassword = getCustomerPortalPassword(customer)
|
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 () => {
|
const savePassword = async () => {
|
||||||
if (!customer.email) {
|
if (!customer.email) {
|
||||||
@@ -232,6 +245,11 @@ function PortalAccessCard({ customer, onSaved }) {
|
|||||||
: <Badge tone="muted">kein Zugang</Badge>}
|
: <Badge tone="muted">kein Zugang</Badge>}
|
||||||
</div>
|
</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)">
|
<Field label="Login (E-Mail)">
|
||||||
<CopyableCredential value={customer.email} label="Login" />
|
<CopyableCredential value={customer.email} label="Login" />
|
||||||
</Field>
|
</Field>
|
||||||
@@ -254,9 +272,7 @@ function PortalAccessCard({ customer, onSaved }) {
|
|||||||
<Button variant="primary" onClick={savePassword} disabled={busy}>
|
<Button variant="primary" onClick={savePassword} disabled={busy}>
|
||||||
{busy ? <FaSpinner className="spinner" /> : <><FaKey /> {hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}</>}
|
{busy ? <FaSpinner className="spinner" /> : <><FaKey /> {hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}</>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button as="a" href={PORTAL_URL} target="_blank" rel="noreferrer" variant="ghost">
|
<KundenansichtButton customer={customer} variant="ghost" />
|
||||||
<FaArrowUpRightFromSquare /> Portal öffnen
|
|
||||||
</Button>
|
|
||||||
{msg && <span className="muted">{msg}</span>}
|
{msg && <span className="muted">{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
|
<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} />
|
<FaGlobe style={hostBusyId === project.$id ? { animation: 'spin 1s linear infinite' } : undefined} />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<PreviewLinkButton href={project.previewUrl} />
|
<PreviewLinkButton href={project.previewUrl} subdomain={project.subdomain} />
|
||||||
)}
|
)}
|
||||||
{hosted && ready && !project.archived && (
|
{hosted && ready && !project.archived && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user