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:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 14:31:42 +00:00
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
View File
@@ -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 (
+19
View File
@@ -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 */