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 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,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 { 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 }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user