Files
tickte-system/src/pages/CustomerDetailPage.jsx
T
ClaudeandClaude Sonnet 5.5 8ef47713b5 Kundenseite: Brotkrumen, Eyebrow, Uebersicht in zwei Spalten, Tickets-Reiter mit Links
Kopf mit Brotkrume, Eyebrow (Nummer, Stufe) und Abzeichen unter dem Namen, kein schwarzer Knopf. Uebersicht mit .kv-Zeilen in zwei Spalten, Rueckmeldungen als .msg, Tickets-Reiter als Tabelle mit echten Links (ticketPath) und Zeilenklick ueber isRowActivation. KundenansichtButton ohne Icon, Standard ist jetzt default. Block Kunden (P4) in global.css.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:31:42 +00:00

412 lines
17 KiB
React

import { useState, useEffect, useCallback } from 'react'
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { PageHeader, Card, Button, Badge, Tabs, EmptyState } from '../components/ui'
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
import CustomerInvoices from '../components/finance/CustomerInvoices'
import AssignedProjectCard from '../components/AssignedProjectCard'
import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '../lib/customerAdminApi'
import { useAuth } from '../context/AuthContext'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
import { customerStage, isArchived, customerBadges, customerEyebrow, customerTitle } from '../lib/customerList'
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
import { ticketNumber } from '../lib/ticketView'
import { isRowActivation } from '../lib/rowClick'
import CustomerViewLog from '../components/customer/CustomerViewLog'
import ChatThread from '../components/chat/ChatThread'
import AboStatusCard from '../components/customer/AboStatusCard'
import CustomerFunktionen from '../components/customer/CustomerFunktionen'
import CreditsView from '../components/credits/CreditsView'
import OffersView from '../components/offers/OffersView'
import KundenansichtButton from '../components/customer/KundenansichtButton'
import { getCustomerPortalStatus } from '../lib/teamApi'
import { formatLastLogin } from '../lib/lastLogin'
const PORTAL_URL = 'https://project.webklar.com'
// Rueckmeldung neben einem Knopf: err und warn mit role="alert", ok ohne
function Msg({ msg }) {
if (!msg) return null
return <span className={`msg ${msg.tone}`} role={msg.tone === 'ok' ? undefined : 'alert'}>{msg.text}</span>
}
export default function CustomerDetailPage() {
const { id } = useParams()
const [customer, setCustomer] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
// 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()
const load = useCallback(async () => {
setLoading(true)
try {
setCustomer(await databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, id))
setError(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [id])
useEffect(() => { load() }, [load])
if (loading) return <div className="page"><div className="loading">Wird geladen …</div></div>
if (error || !customer) return <div className="page"><EmptyState title="Kunde nicht gefunden" hint={error} action={<Button as={Link} to="/customers">Zurück</Button>} /></div>
const stage = customerStage(customer)
// „Rechnungen“ nur für Admins (die Finanzen-API gibt sonst 403); ein nicht erlaubter Reiter fällt auf die Übersicht zurück
const activeTab = resolveCustomerTab(tab, isAdmin)
const archived = isArchived(customer)
const title = customerTitle(customer)
const toggleArchived = async () => {
await setCustomerArchived(customer, !archived)
await load()
}
// Loescht Kunde und Portal-Login ueber die Admin-API des Kundenportals (nur Admins)
const removeCustomer = async () => {
if (!window.confirm(`${customer.name || customer.email} und den Portal-Zugang wirklich löschen?`)) return
try {
await deleteCustomerWithPortalAccess(customer.$id)
navigate('/customers')
} catch (err) {
window.alert(`Löschen fehlgeschlagen: ${err.message}`)
}
}
// Unter dem Namen: Abzeichen (Wichtig, Archiviert; die Stufe steht in der Eyebrow), dann Standort · E-Mail · Telefon
const badges = customerBadges(customer, { stage: false })
const contact = [customer.location, customer.email, customer.phone].filter(Boolean).join(' · ')
const subtitle = badges.length > 0 || contact ? (
<span className="kunde-sub">
{badges.map((b) => (
<Badge key={b.key} tone={b.tone}>{b.label}</Badge>
))}
{contact}
</span>
) : null
return (
<div className="page">
<PageHeader
crumb={[{ label: 'Kunden', to: '/customers' }, { label: title }]}
eyebrow={customerEyebrow(customer)}
title={title}
subtitle={subtitle}
actions={
<>
{isAdmin && <Button variant="danger" onClick={removeCustomer}>Kunde löschen</Button>}
<Button onClick={toggleArchived}>{archived ? 'Wiederherstellen' : 'Archivieren'}</Button>
<KundenansichtButton customer={customer} />
</>
}
/>
<Tabs tabs={customerTabs(isAdmin)} active={activeTab} onChange={setTab} />
{activeTab === 'overview' && <OverviewTab customer={customer} stage={stage} onSaved={load} />}
{activeTab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
{activeTab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />}
{activeTab === 'projects' && <CustomerProjects customerId={customer.$id} />}
{activeTab === 'chat' && <Card><ChatThread customerId={customer.$id} customerName={customer.name} /></Card>}
{activeTab === 'abo' && (
<>
<AboStatusCard customerId={customer.$id} />
<div className="kunde-abo-angebote"><OffersView customerId={customer.$id} customerName={customer.name} /></div>
</>
)}
{activeTab === 'gutschriften' && <CreditsView customerId={customer.$id} />}
{activeTab === 'funktionen' && <CustomerFunktionen customerId={customer.$id} />}
{activeTab === 'protokoll' && <CustomerViewLog customerId={customer.$id} />}
</div>
)
}
function OverviewTab({ customer, stage, onSaved }) {
const [form, setForm] = useState({
name: customer.name || '',
companyName: customer.companyName || customer.name || '',
code: customer.code || '',
email: customer.email || '',
phone: customer.phone || '',
contactName: customer.contactName || '',
street: customer.street || '',
postalCode: customer.postalCode || '',
city: customer.city || '',
country: customer.country || 'Deutschland',
vatNumber: customer.vatNumber || '',
location: customer.location || '',
notes: customer.notes || '',
importantCustomer: !!customer.importantCustomer,
})
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
const ready = isInvoiceReady({ ...customer, ...form })
const save = async () => {
setBusy(true); setMsg(null)
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
setMsg({ tone: 'ok', text: 'Gespeichert' })
onSaved()
} catch (err) {
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
} finally { setBusy(false) }
}
const upgrade = async () => {
if (!ready) { setMsg({ tone: 'warn', text: 'Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.' }); return }
setBusy(true); setMsg(null)
try {
await upgradeToCustomer(customer, form)
setMsg({ tone: 'ok', text: 'Zum festen Kunden gemacht.' })
onSaved()
} catch (err) {
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
} finally { setBusy(false) }
}
return (
<div className="two-col">
<div>
<Card title="Stammdaten">
<dl className="kv kunde-kv">
<dt><label htmlFor="kunde-name">Unternehmen / Name</label></dt>
<dd><input id="kunde-name" className="input" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value, companyName: e.target.value })} /></dd>
<dt><label htmlFor="kunde-code">Code</label></dt>
<dd><input id="kunde-code" className="input" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></dd>
<dt><label htmlFor="kunde-email">E-Mail</label></dt>
<dd><input id="kunde-email" className="input" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></dd>
<dt><label htmlFor="kunde-phone">Telefon</label></dt>
<dd><input id="kunde-phone" className="input" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></dd>
<dt><label htmlFor="kunde-location">Standort</label></dt>
<dd><input id="kunde-location" className="input" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></dd>
<dt><label htmlFor="kunde-contact">Ansprechpartner</label></dt>
<dd><input id="kunde-contact" className="input" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} /></dd>
<dt className="kunde-kv-oben"><label htmlFor="kunde-notes">Notizen</label></dt>
<dd><textarea id="kunde-notes" className="textarea" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} /></dd>
</dl>
<label className="check">
<input type="checkbox" checked={form.importantCustomer} onChange={(e) => setForm({ ...form, importantCustomer: e.target.checked })} />
Wichtiger Kunde
</label>
<div className="toolbar">
<Button variant="primary" onClick={save} disabled={busy}>{busy ? 'Speichert …' : 'Speichern'}</Button>
{stage === 'lead' && <Button onClick={upgrade} disabled={busy}>Zum festen Kunden machen</Button>}
<Msg msg={msg} />
</div>
</Card>
<Card title="Rechnungsdaten">
{!ready && <div className="kunde-hinweis"><Badge tone="warn">Adresse unvollständig</Badge></div>}
<dl className="kv kunde-kv">
<dt><label htmlFor="kunde-street">Strasse + Nr.</label></dt>
<dd><input id="kunde-street" className="input" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></dd>
<dt><label htmlFor="kunde-postal">PLZ</label></dt>
<dd><input id="kunde-postal" className="input" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} /></dd>
<dt><label htmlFor="kunde-city">Stadt</label></dt>
<dd><input id="kunde-city" className="input" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} /></dd>
<dt><label htmlFor="kunde-country">Land</label></dt>
<dd><input id="kunde-country" className="input" value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} /></dd>
<dt><label htmlFor="kunde-vat">USt-IdNr.</label></dt>
<dd><input id="kunde-vat" className="input" value={form.vatNumber} onChange={(e) => setForm({ ...form, vatNumber: e.target.value })} /></dd>
</dl>
</Card>
</div>
<div>
<PortalAccessCard customer={customer} onSaved={onSaved} />
</div>
</div>
)
}
function PortalAccessCard({ customer, onSaved }) {
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
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) {
setMsg({ tone: 'warn', text: 'Bitte zuerst eine E-Mail-Adresse in den Stammdaten speichern.' })
return
}
if (!password || password.length < 8) {
setMsg({ tone: 'warn', text: 'Das Passwort muss mindestens 8 Zeichen haben.' })
return
}
setBusy(true); setMsg(null)
try {
await updateCustomerWithPortalAccess(customer.$id, { password })
setPassword('')
setMsg({ tone: 'ok', text: hasAccess ? 'Passwort aktualisiert.' : 'Portal-Zugang angelegt.' })
onSaved()
} catch (err) {
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
} finally {
setBusy(false)
}
}
return (
<Card title="Portal-Zugang">
<dl className="kv kunde-kv">
<dt>Kundenportal</dt>
<dd>
{hasAccess
? <Badge tone="ok">freigeschaltet</Badge>
: <Badge tone="muted">kein Zugang</Badge>}
</dd>
<dt>Letzter Login</dt>
<dd>{lastLogin === undefined ? '…' : lastLogin === false ? 'nicht abrufbar' : formatLastLogin(lastLogin)}</dd>
<dt>Login (E-Mail)</dt>
<dd><CopyableCredential value={customer.email} label="Login" /></dd>
<dt>Passwort</dt>
<dd><CopyableCredential value={currentPassword} secret label="Passwort" /></dd>
<dt><label htmlFor="kunde-neues-passwort">{hasAccess ? 'Neues Passwort setzen' : 'Passwort festlegen (min. 8 Zeichen)'}</label></dt>
<dd>
<input
id="kunde-neues-passwort"
className="input"
type="text"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="min. 8 Zeichen"
autoComplete="off"
/>
</dd>
</dl>
<div className="toolbar">
<Button onClick={savePassword} disabled={busy}>
{busy ? 'Speichert …' : hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}
</Button>
<KundenansichtButton customer={customer} />
<Msg msg={msg} />
</div>
<p className="muted small">
Der Kunde meldet sich mit dieser E-Mail und dem Passwort unter {PORTAL_URL} an
und sieht dort seine Projekte/Previews.
</p>
</Card>
)
}
function CustomerTickets({ customerId }) {
const navigate = useNavigate()
const [tickets, setTickets] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
;(async () => {
setLoading(true)
try {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(100),
])
if (active) setTickets(res.documents)
} catch {
if (active) setTickets([])
} finally {
if (active) setLoading(false)
}
})()
return () => { active = false }
}, [customerId])
if (loading) return <div className="loading">Wird geladen …</div>
if (tickets.length === 0) return <EmptyState title="Keine Tickets" hint="Fuer diesen Kunden gibt es noch keine Tickets." />
return (
<table className="table">
<thead>
<tr>
<th>Ticket</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{tickets.map((t) => {
const path = ticketPath(ticketRef(t))
return (
<tr
key={t.$id}
className="row"
onClick={(e) => {
if (isRowActivation(e)) navigate(path)
}}
>
<td>
<div className="cell-title">
<Link className="row-link" to={path}>
<span className="id">{ticketNumber(t)}</span>
<span>{t.topic || t.title || '-'}</span>
</Link>
<UrgentPill priority={t.priority} />
</div>
{t.type && <div className="cell-sub">{t.type}</div>}
</td>
<td><StatusPill status={t.status} /></td>
</tr>
)
})}
</tbody>
</table>
)
}
function CustomerProjects({ customerId }) {
const { fetchAllProjects } = useWebsiteProjects()
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
;(async () => {
setLoading(true)
const all = await fetchAllProjects()
if (active) setProjects((all || []).filter((p) => p.customerId === customerId))
if (active) setLoading(false)
})()
return () => { active = false }
}, [customerId, fetchAllProjects])
if (loading) return <div className="loading">Wird geladen …</div>
if (projects.length === 0) return <Card><EmptyState title="Keine Projekte" hint="Diesem Kunden sind keine Projekte/Previews zugeordnet." /></Card>
return (
<Card title={`Projekte (${projects.length})`}>
{projects.map((p) => <AssignedProjectCard key={p.$id} project={p} />)}
</Card>
)
}