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>
This commit is contained in:
1 parent
7d4676121e
commit
8ef47713b5
3 files changed
+184
-125
No files matched your search
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { FaArrowUpRightFromSquare, FaSpinner } from 'react-icons/fa6'
|
|
||||||
import { Button } from '../ui'
|
import { Button } from '../ui'
|
||||||
import { createKundenansichtLink } from '../../lib/teamApi'
|
import { createKundenansichtLink } from '../../lib/teamApi'
|
||||||
import { openInNewTab } from '../../lib/openInNewTab'
|
import { openInNewTab } from '../../lib/openInNewTab'
|
||||||
@@ -8,7 +7,7 @@ import { openInNewTab } from '../../lib/openInNewTab'
|
|||||||
* Kundenansicht im Portal in einem neuen Tab öffnen (Einmal-Link). Ohne Portal-Zugang ausgegraut.
|
* Kundenansicht im Portal in einem neuen Tab öffnen (Einmal-Link). Ohne Portal-Zugang ausgegraut.
|
||||||
* checkAccess=false: Kunden-Dokument liegt nicht vor (z. B. Gutschrift-Detail), den Zugang prüft dann der Server (409).
|
* checkAccess=false: Kunden-Dokument liegt nicht vor (z. B. Gutschrift-Detail), den Zugang prüft dann der Server (409).
|
||||||
*/
|
*/
|
||||||
export default function KundenansichtButton({ customer, variant = 'primary', checkAccess = true }) {
|
export default function KundenansichtButton({ customer, variant = 'default', checkAccess = true }) {
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const hasAccess = !checkAccess || Boolean(customer?.portalAccessEnabled && customer?.appwriteUserId)
|
const hasAccess = !checkAccess || Boolean(customer?.portalAccessEnabled && customer?.appwriteUserId)
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ export default function KundenansichtButton({ customer, variant = 'primary', che
|
|||||||
disabled={!hasAccess || busy}
|
disabled={!hasAccess || busy}
|
||||||
title={hasAccess ? 'Portal so öffnen, wie der Kunde es sieht' : 'Kein Portal-Zugang'}
|
title={hasAccess ? 'Portal so öffnen, wie der Kunde es sieht' : 'Kein Portal-Zugang'}
|
||||||
>
|
>
|
||||||
{busy ? <FaSpinner className="spinner" /> : <FaArrowUpRightFromSquare />} Kundenansicht öffnen
|
{busy ? 'Wird geladen …' : 'Kundenansicht öffnen'}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+163
-122
@@ -1,8 +1,7 @@
|
|||||||
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, 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 } from '../components/ui'
|
||||||
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
||||||
import CustomerInvoices from '../components/finance/CustomerInvoices'
|
import CustomerInvoices from '../components/finance/CustomerInvoices'
|
||||||
import AssignedProjectCard from '../components/AssignedProjectCard'
|
import AssignedProjectCard from '../components/AssignedProjectCard'
|
||||||
@@ -11,7 +10,11 @@ import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '
|
|||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
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 { 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 CustomerViewLog from '../components/customer/CustomerViewLog'
|
||||||
import ChatThread from '../components/chat/ChatThread'
|
import ChatThread from '../components/chat/ChatThread'
|
||||||
import AboStatusCard from '../components/customer/AboStatusCard'
|
import AboStatusCard from '../components/customer/AboStatusCard'
|
||||||
@@ -24,6 +27,12 @@ import { formatLastLogin } from '../lib/lastLogin'
|
|||||||
|
|
||||||
const PORTAL_URL = 'https://project.webklar.com'
|
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() {
|
export default function CustomerDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const [customer, setCustomer] = useState(null)
|
const [customer, setCustomer] = useState(null)
|
||||||
@@ -53,13 +62,14 @@ export default function CustomerDetailPage() {
|
|||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
if (loading) return <div className="page"><div className="empty"><FaSpinner className="spinner" /></div></div>
|
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"><FaArrowLeft /> Zurück</Button>} /></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)
|
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
|
// „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 activeTab = resolveCustomerTab(tab, isAdmin)
|
||||||
const archived = isArchived(customer)
|
const archived = isArchived(customer)
|
||||||
|
const title = customerTitle(customer)
|
||||||
|
|
||||||
const toggleArchived = async () => {
|
const toggleArchived = async () => {
|
||||||
await setCustomerArchived(customer, !archived)
|
await setCustomerArchived(customer, !archived)
|
||||||
@@ -77,28 +87,30 @@ export default function CustomerDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={
|
crumb={[{ label: 'Kunden', to: '/customers' }, { label: title }]}
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
eyebrow={customerEyebrow(customer)}
|
||||||
{customer.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} />}
|
title={title}
|
||||||
{customer.name}
|
subtitle={subtitle}
|
||||||
{stage === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
|
|
||||||
{stage === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
|
|
||||||
{stage === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
|
|
||||||
{archived && <Badge tone="muted"><FaBoxArchive /> Archiviert</Badge>}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' · ')}
|
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
|
{isAdmin && <Button variant="danger" onClick={removeCustomer}>Kunde löschen</Button>}
|
||||||
|
<Button onClick={toggleArchived}>{archived ? 'Wiederherstellen' : 'Archivieren'}</Button>
|
||||||
<KundenansichtButton customer={customer} />
|
<KundenansichtButton customer={customer} />
|
||||||
<Button variant="ghost" onClick={toggleArchived}>
|
|
||||||
{archived ? <><FaBoxOpen /> Wiederherstellen</> : <><FaBoxArchive /> Archivieren</>}
|
|
||||||
</Button>
|
|
||||||
{isAdmin && <Button variant="danger" onClick={removeCustomer}><FaTrash /> Kunde löschen</Button>}
|
|
||||||
<Button variant="ghost" as={Link} to="/customers"><FaArrowLeft /> Alle Kunden</Button>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -113,7 +125,7 @@ export default function CustomerDetailPage() {
|
|||||||
{activeTab === 'abo' && (
|
{activeTab === 'abo' && (
|
||||||
<>
|
<>
|
||||||
<AboStatusCard customerId={customer.$id} />
|
<AboStatusCard customerId={customer.$id} />
|
||||||
<div style={{ marginTop: 16 }}><OffersView customerId={customer.$id} customerName={customer.name} /></div>
|
<div className="kunde-abo-angebote"><OffersView customerId={customer.$id} customerName={customer.name} /></div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'gutschriften' && <CreditsView customerId={customer.$id} />}
|
{activeTab === 'gutschriften' && <CreditsView customerId={customer.$id} />}
|
||||||
@@ -141,7 +153,7 @@ function OverviewTab({ customer, stage, onSaved }) {
|
|||||||
importantCustomer: !!customer.importantCustomer,
|
importantCustomer: !!customer.importantCustomer,
|
||||||
})
|
})
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [msg, setMsg] = useState(null)
|
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
|
||||||
|
|
||||||
const ready = isInvoiceReady({ ...customer, ...form })
|
const ready = isInvoiceReady({ ...customer, ...form })
|
||||||
|
|
||||||
@@ -149,64 +161,76 @@ function OverviewTab({ customer, stage, onSaved }) {
|
|||||||
setBusy(true); setMsg(null)
|
setBusy(true); setMsg(null)
|
||||||
try {
|
try {
|
||||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
|
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
|
||||||
setMsg('Gespeichert')
|
setMsg({ tone: 'ok', text: 'Gespeichert' })
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMsg('Fehler: ' + err.message)
|
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
|
||||||
} finally { setBusy(false) }
|
} finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const upgrade = async () => {
|
const upgrade = async () => {
|
||||||
if (!ready) { setMsg('Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.'); return }
|
if (!ready) { setMsg({ tone: 'warn', text: 'Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.' }); return }
|
||||||
setBusy(true); setMsg(null)
|
setBusy(true); setMsg(null)
|
||||||
try {
|
try {
|
||||||
await upgradeToCustomer(customer, form)
|
await upgradeToCustomer(customer, form)
|
||||||
setMsg('Zum festen Kunden gemacht.')
|
setMsg({ tone: 'ok', text: 'Zum festen Kunden gemacht.' })
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMsg('Fehler: ' + err.message)
|
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
|
||||||
} finally { setBusy(false) }
|
} finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 16 }}>
|
<div className="two-col">
|
||||||
<Card title="Stammdaten">
|
<div>
|
||||||
<div className="flex gap-3 wrap">
|
<Card title="Stammdaten">
|
||||||
<Field label="Unternehmen / Name"><input className="form-control" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value, companyName: e.target.value })} /></Field>
|
<dl className="kv kunde-kv">
|
||||||
<Field label="Code"><input className="form-control" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></Field>
|
<dt><label htmlFor="kunde-name">Unternehmen / Name</label></dt>
|
||||||
</div>
|
<dd><input id="kunde-name" className="input" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value, companyName: e.target.value })} /></dd>
|
||||||
<div className="flex gap-3 wrap">
|
<dt><label htmlFor="kunde-code">Code</label></dt>
|
||||||
<Field label="E-Mail"><input className="form-control" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></Field>
|
<dd><input id="kunde-code" className="input" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></dd>
|
||||||
<Field label="Telefon"><input className="form-control" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></Field>
|
<dt><label htmlFor="kunde-email">E-Mail</label></dt>
|
||||||
</div>
|
<dd><input id="kunde-email" className="input" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></dd>
|
||||||
<Field label="Standort"><input className="form-control" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></Field>
|
<dt><label htmlFor="kunde-phone">Telefon</label></dt>
|
||||||
<Field label="Ansprechpartner"><input className="form-control" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} /></Field>
|
<dd><input id="kunde-phone" className="input" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></dd>
|
||||||
<Field label="Notizen"><textarea className="form-control" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} /></Field>
|
<dt><label htmlFor="kunde-location">Standort</label></dt>
|
||||||
<label className="flex items-center gap-2" style={{ marginBottom: 12, cursor: 'pointer' }}>
|
<dd><input id="kunde-location" className="input" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></dd>
|
||||||
<input type="checkbox" checked={form.importantCustomer} onChange={(e) => setForm({ ...form, importantCustomer: e.target.checked })} />
|
<dt><label htmlFor="kunde-contact">Ansprechpartner</label></dt>
|
||||||
Wichtiger Kunde
|
<dd><input id="kunde-contact" className="input" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} /></dd>
|
||||||
</label>
|
<dt className="kunde-kv-oben"><label htmlFor="kunde-notes">Notizen</label></dt>
|
||||||
<div className="flex gap-2 items-center wrap">
|
<dd><textarea id="kunde-notes" className="textarea" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} /></dd>
|
||||||
<Button variant="primary" onClick={save} disabled={busy}>{busy ? <FaSpinner className="spinner" /> : <><FaFloppyDisk /> Speichern</>}</Button>
|
</dl>
|
||||||
{stage === 'lead' && <Button onClick={upgrade} disabled={busy}><FaArrowUp /> Zum festen Kunden machen</Button>}
|
<label className="check">
|
||||||
{msg && <span className="muted">{msg}</span>}
|
<input type="checkbox" checked={form.importantCustomer} onChange={(e) => setForm({ ...form, importantCustomer: e.target.checked })} />
|
||||||
</div>
|
Wichtiger Kunde
|
||||||
</Card>
|
</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">
|
<Card title="Rechnungsdaten">
|
||||||
{!ready && <div className="badge badge-warn" style={{ marginBottom: 12 }}>Adresse unvollständig</div>}
|
{!ready && <div className="kunde-hinweis"><Badge tone="warn">Adresse unvollständig</Badge></div>}
|
||||||
<Field label="Strasse + Nr."><input className="form-control" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></Field>
|
<dl className="kv kunde-kv">
|
||||||
<div className="flex gap-3 wrap">
|
<dt><label htmlFor="kunde-street">Strasse + Nr.</label></dt>
|
||||||
<Field label="PLZ"><input className="form-control" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} /></Field>
|
<dd><input id="kunde-street" className="input" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></dd>
|
||||||
<Field label="Stadt"><input className="form-control" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} /></Field>
|
<dt><label htmlFor="kunde-postal">PLZ</label></dt>
|
||||||
</div>
|
<dd><input id="kunde-postal" className="input" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} /></dd>
|
||||||
<div className="flex gap-3 wrap">
|
<dt><label htmlFor="kunde-city">Stadt</label></dt>
|
||||||
<Field label="Land"><input className="form-control" value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} /></Field>
|
<dd><input id="kunde-city" className="input" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} /></dd>
|
||||||
<Field label="USt-IdNr."><input className="form-control" value={form.vatNumber} onChange={(e) => setForm({ ...form, vatNumber: e.target.value })} /></Field>
|
<dt><label htmlFor="kunde-country">Land</label></dt>
|
||||||
</div>
|
<dd><input id="kunde-country" className="input" value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} /></dd>
|
||||||
</Card>
|
<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>
|
||||||
|
|
||||||
<PortalAccessCard customer={customer} onSaved={onSaved} />
|
<div>
|
||||||
|
<PortalAccessCard customer={customer} onSaved={onSaved} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -214,7 +238,7 @@ function OverviewTab({ customer, stage, onSaved }) {
|
|||||||
function PortalAccessCard({ customer, onSaved }) {
|
function PortalAccessCard({ customer, onSaved }) {
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [msg, setMsg] = useState(null)
|
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
|
||||||
|
|
||||||
const hasAccess = Boolean(customer.portalAccessEnabled && customer.appwriteUserId)
|
const hasAccess = Boolean(customer.portalAccessEnabled && customer.appwriteUserId)
|
||||||
const currentPassword = getCustomerPortalPassword(customer)
|
const currentPassword = getCustomerPortalPassword(customer)
|
||||||
@@ -230,21 +254,21 @@ function PortalAccessCard({ customer, onSaved }) {
|
|||||||
|
|
||||||
const savePassword = async () => {
|
const savePassword = async () => {
|
||||||
if (!customer.email) {
|
if (!customer.email) {
|
||||||
setMsg('Bitte zuerst eine E-Mail-Adresse in den Stammdaten speichern.')
|
setMsg({ tone: 'warn', text: 'Bitte zuerst eine E-Mail-Adresse in den Stammdaten speichern.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!password || password.length < 8) {
|
if (!password || password.length < 8) {
|
||||||
setMsg('Das Passwort muss mindestens 8 Zeichen haben.')
|
setMsg({ tone: 'warn', text: 'Das Passwort muss mindestens 8 Zeichen haben.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setBusy(true); setMsg(null)
|
setBusy(true); setMsg(null)
|
||||||
try {
|
try {
|
||||||
await updateCustomerWithPortalAccess(customer.$id, { password })
|
await updateCustomerWithPortalAccess(customer.$id, { password })
|
||||||
setPassword('')
|
setPassword('')
|
||||||
setMsg(hasAccess ? 'Passwort aktualisiert.' : 'Portal-Zugang angelegt.')
|
setMsg({ tone: 'ok', text: hasAccess ? 'Passwort aktualisiert.' : 'Portal-Zugang angelegt.' })
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMsg('Fehler: ' + err.message)
|
setMsg({ tone: 'err', text: 'Fehler: ' + err.message })
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -252,44 +276,41 @@ function PortalAccessCard({ customer, onSaved }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Portal-Zugang">
|
<Card title="Portal-Zugang">
|
||||||
<div className="flex items-center justify-between" style={{ marginBottom: 12 }}>
|
<dl className="kv kunde-kv">
|
||||||
<span className="muted">Kundenportal</span>
|
<dt>Kundenportal</dt>
|
||||||
{hasAccess
|
<dd>
|
||||||
? <Badge tone="ok" dot>freigeschaltet</Badge>
|
{hasAccess
|
||||||
: <Badge tone="muted">kein Zugang</Badge>}
|
? <Badge tone="ok">freigeschaltet</Badge>
|
||||||
</div>
|
: <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="flex items-center justify-between" style={{ marginBottom: 12 }}>
|
<div className="toolbar">
|
||||||
<span className="muted">Letzter Login</span>
|
<Button onClick={savePassword} disabled={busy}>
|
||||||
<span>{lastLogin === undefined ? '…' : lastLogin === false ? 'nicht abrufbar' : formatLastLogin(lastLogin)}</span>
|
{busy ? 'Speichert …' : hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field label="Login (E-Mail)">
|
|
||||||
<CopyableCredential value={customer.email} label="Login" />
|
|
||||||
</Field>
|
|
||||||
<Field label="Passwort">
|
|
||||||
<CopyableCredential value={currentPassword} secret label="Passwort" />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label={hasAccess ? 'Neues Passwort setzen' : 'Passwort festlegen (min. 8 Zeichen)'}>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
type="text"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="min. 8 Zeichen"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="flex gap-2 items-center wrap">
|
|
||||||
<Button variant="primary" onClick={savePassword} disabled={busy}>
|
|
||||||
{busy ? <FaSpinner className="spinner" /> : <><FaKey /> {hasAccess ? 'Passwort ändern' : 'Zugang anlegen'}</>}
|
|
||||||
</Button>
|
</Button>
|
||||||
<KundenansichtButton customer={customer} variant="ghost" />
|
<KundenansichtButton customer={customer} />
|
||||||
{msg && <span className="muted">{msg}</span>}
|
<Msg msg={msg} />
|
||||||
</div>
|
</div>
|
||||||
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
|
<p className="muted small">
|
||||||
Der Kunde meldet sich mit dieser E-Mail und dem Passwort unter {PORTAL_URL} an
|
Der Kunde meldet sich mit dieser E-Mail und dem Passwort unter {PORTAL_URL} an
|
||||||
und sieht dort seine Projekte/Previews.
|
und sieht dort seine Projekte/Previews.
|
||||||
</p>
|
</p>
|
||||||
@@ -298,6 +319,7 @@ function PortalAccessCard({ customer, onSaved }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CustomerTickets({ customerId }) {
|
function CustomerTickets({ customerId }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [tickets, setTickets] = useState([])
|
const [tickets, setTickets] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -321,25 +343,44 @@ function CustomerTickets({ customerId }) {
|
|||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [customerId])
|
}, [customerId])
|
||||||
|
|
||||||
if (loading) return <Card><FaSpinner className="spinner" /></Card>
|
if (loading) return <div className="loading">Wird geladen …</div>
|
||||||
if (tickets.length === 0) return <Card><EmptyState title="Keine Tickets" hint="Fuer diesen Kunden gibt es noch keine Tickets." /></Card>
|
if (tickets.length === 0) return <EmptyState title="Keine Tickets" hint="Fuer diesen Kunden gibt es noch keine Tickets." />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card pad={false}>
|
<table className="table">
|
||||||
<div className="list">
|
<thead>
|
||||||
{tickets.map((t) => (
|
<tr>
|
||||||
<div key={t.$id} className="list-row">
|
<th>Ticket</th>
|
||||||
<span className="mono" style={{ color: 'var(--accent)', minWidth: 60 }}>{t.woid || t.$id.slice(-5)}</span>
|
<th>Status</th>
|
||||||
<div className="grow">
|
</tr>
|
||||||
<div style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
|
</thead>
|
||||||
<div className="muted">{t.type}</div>
|
<tbody>
|
||||||
</div>
|
{tickets.map((t) => {
|
||||||
<UrgentPill priority={t.priority} />
|
const path = ticketPath(ticketRef(t))
|
||||||
<StatusPill status={t.status} />
|
return (
|
||||||
</div>
|
<tr
|
||||||
))}
|
key={t.$id}
|
||||||
</div>
|
className="row"
|
||||||
</Card>
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +400,7 @@ function CustomerProjects({ customerId }) {
|
|||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [customerId, fetchAllProjects])
|
}, [customerId, fetchAllProjects])
|
||||||
|
|
||||||
if (loading) return <Card><FaSpinner className="spinner" /></Card>
|
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>
|
if (projects.length === 0) return <Card><EmptyState title="Keine Projekte" hint="Diesem Kunden sind keine Projekte/Previews zugeordnet." /></Card>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1043,6 +1043,25 @@ button.link { font-weight: 500; } /* font: inherit setzt sonst das Gewicht von .
|
|||||||
}
|
}
|
||||||
/* ===== ENDE Planboard (P3) ===== */
|
/* ===== ENDE Planboard (P3) ===== */
|
||||||
|
|
||||||
|
/* ===== Kunden (P4) ===== */
|
||||||
|
/* Die Prototypregel .kv .input gilt nur fuer einzeilige Felder */
|
||||||
|
.kv .textarea { width: 100%; max-width: 360px; }
|
||||||
|
/* Wertetabellen mit Feldern: Beschriftung und Feld teilen die Grundlinie (Textfeld: erste Zeile) */
|
||||||
|
.kunde-kv { align-items: baseline; }
|
||||||
|
/* Mehrzeiliges Feld: die Beschriftung steht auf Hoehe der ersten Zeile (Innenabstand des Textfelds), nicht an seiner Unterkante */
|
||||||
|
@media (min-width: 861px) {
|
||||||
|
.kunde-kv .kunde-kv-oben { align-self: start; padding-top: 8px; }
|
||||||
|
}
|
||||||
|
/* Uebersicht: unter der Wertetabelle kommen das Kaestchen "Wichtiger Kunde" und die Knopfzeile */
|
||||||
|
.kv + .check { margin-top: 16px; }
|
||||||
|
.kv ~ .toolbar { margin-top: 16px; }
|
||||||
|
.kunde-hinweis { margin-bottom: 12px; }
|
||||||
|
/* Abzeichen unter dem Namen: Abstand zum Folgenden, oben buendig (sonst waechst die Zeile um 1 px) */
|
||||||
|
.kunde-sub .badge { margin-right: 8px; vertical-align: top; }
|
||||||
|
/* Reiter "Abo & Angebote": Abstand zwischen Abo-Status und Angeboten (der Umschlag ist ein div, .page section + section greift dort nicht) */
|
||||||
|
.kunde-abo-angebote { margin-top: 36px; }
|
||||||
|
/* ===== ENDE Kunden (P4) ===== */
|
||||||
|
|
||||||
/* ===== KOMPAT (bis P10) ===== */
|
/* ===== KOMPAT (bis P10) ===== */
|
||||||
/* Uebergangsschicht: alte Klassen im neuen Look, damit Seiten und Dialoge, die noch nicht umgebaut sind, weiss, kastenlos */
|
/* Uebergangsschicht: alte Klassen im neuen Look, damit Seiten und Dialoge, die noch nicht umgebaut sind, weiss, kastenlos */
|
||||||
/* und lesbar bleiben. Steht am Dateiende und gewinnt dort gegen die Altregeln und gegen die anderen Stylesheets (global.css */
|
/* und lesbar bleiben. Steht am Dateiende und gewinnt dort gegen die Altregeln und gegen die anderen Stylesheets (global.css */
|
||||||
|
|||||||
Reference in new issue
Block a user