Kundenseite: Kundenansicht oeffnen, letzter Login, Preview per Einmal-Link

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 19:44:50 +00:00
1 parent 370724a9a7
commit fce2f41463
7 files changed
+92 -7

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,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>
)
}
+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`
}
+20 -4
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
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 { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
@@ -13,6 +13,9 @@ 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'
@@ -85,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>
@@ -200,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) {
@@ -232,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>
@@ -254,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