Files
tickte-system/src/pages/CustomerDetailPage.jsx
T

504 lines
21 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, Dialog, Field } from '../components/ui'
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
import AuftraegeUndRechnungen from '../components/auftraege/AuftraegeUndRechnungen'
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, markLost, reopenAsLead } from '../lib/leads'
import { lastRejection } from '../lib/leadDuplicates'
import { KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartAenderung } from '../lib/kundenFelder'
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} />
{/* key: nach Absage/Umstufung neu aufbauen, sonst ueberschreibt ein spaeteres Speichern die neuen Notizen */}
{activeTab === 'overview' && <OverviewTab key={customer.$updatedAt} customer={customer} stage={stage} onSaved={load} />}
{activeTab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
{activeTab === 'invoices' && <AuftraegeUndRechnungen key={customer.$id} 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 [kundenart, setKundenart] = useState(customer.kundenart || '')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
const [showLost, setShowLost] = useState(false)
const { user, employee } = useAuth()
// Die Kundenart geht nur ins Dokument, wenn sie geaendert wurde (kein Fehler bei noch nicht angelegtem Attribut)
const felder = () => ({ ...form, ...kundenartAenderung(kundenart, customer.kundenart) })
const ready = isInvoiceReady({ ...customer, ...form })
// Absage: ungespeicherte Notizen gehen mit, damit der Vermerk nichts ueberschreibt
const saveLost = async (reason) => {
await markLost({ ...customer, notes: form.notes }, { reason, by: employee?.displayName || user?.name || '' })
setShowLost(false)
onSaved()
}
const reopen = async () => {
setBusy(true); setMsg(null)
try {
await reopenAsLead(customer)
onSaved()
} catch (err) {
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
setBusy(false)
}
}
const save = async () => {
setBusy(true); setMsg(null)
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, felder())
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, felder())
setMsg({ tone: 'ok', text: 'Zum festen Kunden gemacht.' })
onSaved()
} catch (err) {
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
} finally { setBusy(false) }
}
const rejection = lastRejection(customer.notes)
return (
<div className="two-col">
<div>
{stage === 'lost' && (
<div className="kunde-absage" role="note">
<strong>Hat abgesagt – bitte nicht erneut anrufen.</strong>
{rejection && <div>{rejection}</div>}
<div className="toolbar">
<Button onClick={reopen} disabled={busy}>Wieder als Lead führen</Button>
</div>
</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>}
{stage === 'lead' && <Button onClick={() => setShowLost(true)} disabled={busy}>Absage eintragen</Button>}
<Msg msg={msg} />
</div>
</Card>
{showLost && <LostDialog name={customer.name} onCancel={() => setShowLost(false)} onSave={saveLost} />}
<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-kundenart">Kundenart</label></dt>
<dd>
<select id="kunde-kundenart" className="select" value={kundenart} onChange={(e) => setKundenart(e.target.value)}>
{KUNDENART_OPTIONEN.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<div className="hint">{KUNDENART_HINWEIS}</div>
</dd>
<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>
)
}
// Absage eintragen: Grund ist freiwillig; Datum und Person setzt markLost
function LostDialog({ name, onCancel, onSave }) {
const [reason, setReason] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await onSave(reason.trim())
} catch (err) {
setError(err.message || 'Fehler')
setBusy(false)
}
}
return (
<Dialog
as="form"
title={`Absage von ${name || 'diesem Lead'}`}
busy={busy}
onClose={onCancel}
onSubmit={submit}
footer={
<>
<span className="grow" />
<Button type="button" disabled={busy} onClick={onCancel}>Abbrechen</Button>
<Button variant="primary" type="submit" disabled={busy}>{busy ? 'Speichert …' : 'Absage speichern'}</Button>
</>
}
>
{error && <div className="msg err" role="alert">{error}</div>}
<p className="muted small">
Der Eintrag wandert in den Reiter „Abgesagt“ und wird beim Anlegen neuer Leads als Warnung angezeigt.
Offene Akquise-Tickets werden auf „Abgesagt“ gesetzt. Datum und dein Name kommen in die Notizen.
</p>
<Field label="Grund (freiwillig)" htmlFor="lost-reason">
<textarea id="lost-reason" className="textarea" rows={3} value={reason} onChange={(e) => setReason(e.target.value)}
placeholder="z. B. kein Bedarf, hat schon eine Agentur, nicht vor 2027 melden" />
</Field>
</Dialog>
)
}
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>
)
}