Kundenliste als Tabelle mit Reitern und Zaehlern, Neuer Kunde und Neuer Lead als Dialoge
Zeilenklick nur ueber isRowActivation, Link.row-link in der ersten Zelle. CreateCustomerForm bleibt exportiert (Zuordnen-Assistent), dort entfaellt der Umschlag um den Dialog. KOMPAT-Regel .side-logo gestrichen. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4b6ab7505a
commit
7d4676121e
3 files changed
+139
-112
No files matched your search
@@ -274,7 +274,6 @@ export default function AssignProjectPage() {
|
||||
)}
|
||||
</div>
|
||||
{showNewCustomer && isAdmin && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<CreateCustomerForm
|
||||
onCancel={() => setShowNewCustomer(false)}
|
||||
onCreate={async (data) => {
|
||||
@@ -286,7 +285,6 @@ export default function AssignProjectPage() {
|
||||
return r
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="list" style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||
{filteredCustomers.slice(0, 100).map((c) => (
|
||||
|
||||
+138
-105
@@ -1,37 +1,74 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FaSpinner, FaPlus, FaStar } from 'react-icons/fa6'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { createLead, customerStage, isArchived } from '../lib/leads'
|
||||
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
|
||||
import { createLead } from '../lib/leads'
|
||||
import { CUSTOMER_TABS, customerBadges, customerTitle, filterCustomers, stageCounts } from '../lib/customerList'
|
||||
import { isRowActivation } from '../lib/rowClick'
|
||||
import { PageHeader, Tabs, Button, Badge, EmptyState, Field, Dialog } from '../components/ui'
|
||||
|
||||
export default function CustomersPage() {
|
||||
const { customers, loading, refresh, createCustomer } = useCustomers()
|
||||
const { user, isAdmin } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [search, setSearch] = useState('')
|
||||
// 105 von 110 Eintraegen sind Leads der Lead-Automatik, deshalb startet die Liste bei den festen Kunden
|
||||
const [stage, setStage] = useState('customer') // customer | lead | all | archived
|
||||
const [stage, setStage] = useState('customer') // customer | lead | archived | all
|
||||
const [showCreate, setShowCreate] = useState(null) // null | 'lead' | 'customer'
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return (customers || [])
|
||||
.filter((c) => {
|
||||
const archived = isArchived(c)
|
||||
// Archivierte nur im Archiviert-Tab, sonst nie
|
||||
if (stage === 'archived') return archived
|
||||
if (archived) return false
|
||||
const st = customerStage(c)
|
||||
if (st === 'lost') return stage === 'all'
|
||||
if (stage === 'all') return true
|
||||
return st === stage
|
||||
})
|
||||
.filter((c) => {
|
||||
if (!search) return true
|
||||
const s = search.toLowerCase()
|
||||
return `${c.name} ${c.code || ''} ${c.location || ''} ${c.email || ''}`.toLowerCase().includes(s)
|
||||
})
|
||||
}, [customers, search, stage])
|
||||
const filtered = useMemo(() => filterCustomers(customers, { stage, search }), [customers, stage, search])
|
||||
// Zähler mit derselben Funktion wie die Liste (beachten den Suchtext); bis zum Laden ohne Zahl
|
||||
const counts = useMemo(() => stageCounts(customers, search), [customers, search])
|
||||
const tabs = CUSTOMER_TABS.map((tab) => ({ id: tab.id, label: tab.label, count: loading ? undefined : counts[tab.id] }))
|
||||
|
||||
let content
|
||||
if (loading) {
|
||||
content = <div className="loading">Wird geladen …</div>
|
||||
} else if (filtered.length === 0) {
|
||||
content = <EmptyState title="Keine Einträge" hint="Keine Kunden in dieser Ansicht." />
|
||||
} else {
|
||||
content = (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Unternehmen</th>
|
||||
<th className="hide-sm num">Kundennummer</th>
|
||||
<th>Standort</th>
|
||||
<th className="hide-md muted">E-Mail</th>
|
||||
<th className="hide-md muted nowrap num">Telefon</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((c) => {
|
||||
const path = `/customers/${c.$id}`
|
||||
return (
|
||||
<tr
|
||||
key={c.$id}
|
||||
className="row"
|
||||
onClick={(e) => {
|
||||
if (isRowActivation(e)) navigate(path)
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<div className="cell-title">
|
||||
<Link className="row-link" to={path}>{customerTitle(c)}</Link>
|
||||
{customerBadges(c).map((b) => (
|
||||
<Badge key={b.key} tone={b.tone}>{b.label}</Badge>
|
||||
))}
|
||||
</div>
|
||||
{c.contactName && <div className="cell-sub">{c.contactName}</div>}
|
||||
</td>
|
||||
<td className="hide-sm num">{c.code || <span className="faint">–</span>}</td>
|
||||
<td>{c.location || '–'}</td>
|
||||
<td className="hide-md muted">{c.email || '–'}</td>
|
||||
<td className="hide-md muted nowrap num">{c.phone || '–'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
@@ -41,15 +78,29 @@ export default function CustomersPage() {
|
||||
actions={
|
||||
<>
|
||||
{isAdmin && (
|
||||
<Button onClick={() => setShowCreate((s) => (s === 'customer' ? null : 'customer'))}><FaPlus /> Neuer Kunde</Button>
|
||||
<Button variant="primary" type="button" onClick={() => setShowCreate('customer')}>Neuer Kunde</Button>
|
||||
)}
|
||||
<Button variant="primary" onClick={() => setShowCreate((s) => (s === 'lead' ? null : 'lead'))}><FaPlus /> Neuer Lead</Button>
|
||||
<Button type="button" onClick={() => setShowCreate('lead')}>Neuer Lead</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs tabs={tabs} active={stage} onChange={setStage} />
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="input search"
|
||||
type="search"
|
||||
placeholder="Kunde suchen …"
|
||||
aria-label="Kunde suchen"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{content}
|
||||
|
||||
{showCreate === 'lead' && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<CreateLeadForm
|
||||
onCancel={() => setShowCreate(null)}
|
||||
onDone={async (data) => {
|
||||
@@ -59,11 +110,9 @@ export default function CustomersPage() {
|
||||
return r
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate === 'customer' && isAdmin && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<CreateCustomerForm
|
||||
onCancel={() => setShowCreate(null)}
|
||||
onCreate={async (data) => {
|
||||
@@ -76,54 +125,7 @@ export default function CustomersPage() {
|
||||
return r
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card pad={false}>
|
||||
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="seg">
|
||||
<button className={stage === 'customer' ? 'active' : ''} onClick={() => setStage('customer')}>Feste Kunden</button>
|
||||
<button className={stage === 'lead' ? 'active' : ''} onClick={() => setStage('lead')}>Leads</button>
|
||||
<button className={stage === 'all' ? 'active' : ''} onClick={() => setStage('all')}>Alle</button>
|
||||
<button className={stage === 'archived' ? 'active' : ''} onClick={() => setStage('archived')}>Archiviert</button>
|
||||
</div>
|
||||
<input className="form-control search-input" placeholder="Kunde suchen …" value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 320 }} />
|
||||
</div>
|
||||
<div className="ui-card-body" style={{ padding: 0 }}>
|
||||
{loading ? (
|
||||
<div className="empty"><FaSpinner className="spinner" /></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState title="Keine Einträge" hint="Keine Kunden in dieser Ansicht." />
|
||||
) : (
|
||||
<div className="list">
|
||||
{filtered.map((c) => {
|
||||
const st = customerStage(c)
|
||||
return (
|
||||
<Link key={c.$id} to={`/customers/${c.$id}`} className="list-row">
|
||||
<div className="side-logo" style={{ width: 34, height: 34, fontSize: 13 }}>
|
||||
{(c.name || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div style={{ fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{c.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} title="Wichtiger Kunde" />}
|
||||
{c.name}
|
||||
{c.code && <span className="faint" style={{ fontWeight: 400 }}>· {c.code}</span>}
|
||||
</div>
|
||||
<div className="muted">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 wrap" style={{ justifyContent: 'flex-end' }}>
|
||||
{isArchived(c) && <Badge tone="muted">Archiviert</Badge>}
|
||||
{st === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
|
||||
{st === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
|
||||
{st === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -147,22 +149,33 @@ function CreateLeadForm({ onCancel, onDone }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Neuer Lead (potenzieller Kunde)">
|
||||
<p className="muted" style={{ marginTop: 0, fontSize: 13 }}>
|
||||
<Dialog
|
||||
as="form"
|
||||
title="Neuer Lead (potenzieller Kunde)"
|
||||
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 …' : 'Lead anlegen + Akquise-Ticket'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <div className="msg err" role="alert">{error}</div>}
|
||||
<p className="muted small">
|
||||
Fuer einen Lead reichen Unternehmen und E-Mail. Es wird automatisch ein Akquise-Ticket erstellt.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="Unternehmen"><input className="form-control" value={company} onChange={(e) => setCompany(e.target.value)} required /></Field>
|
||||
<Field label="E-Mail"><input className="form-control" type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></Field>
|
||||
<div className="field-row">
|
||||
<Field label="Unternehmen" htmlFor="lead-company">
|
||||
<input id="lead-company" className="input" value={company} onChange={(e) => setCompany(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="E-Mail" htmlFor="lead-email">
|
||||
<input id="lead-email" className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Lead anlegen + Akquise-Ticket'}</Button>
|
||||
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -185,24 +198,44 @@ export function CreateCustomerForm({ onCancel, onCreate }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Neuer Kunde mit Portal-Zugang">
|
||||
<form onSubmit={submit}>
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="Unternehmen / Name"><input className="form-control" name="name" value={form.name} onChange={set('name')} required /></Field>
|
||||
<Field label="E-Mail (Portal-Login)"><input className="form-control" type="email" name="email" value={form.email} onChange={set('email')} required /></Field>
|
||||
<Field label="Kundennummer"><input className="form-control" name="code" value={form.code} onChange={set('code')} /></Field>
|
||||
<Dialog
|
||||
as="form"
|
||||
wide
|
||||
title="Neuer Kunde mit Portal-Zugang"
|
||||
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 …' : 'Kunde anlegen'}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <div className="msg err" role="alert">{error}</div>}
|
||||
<div className="field-row-3">
|
||||
<Field label="Unternehmen / Name" htmlFor="customer-name">
|
||||
<input id="customer-name" className="input" name="name" value={form.name} onChange={set('name')} required />
|
||||
</Field>
|
||||
<Field label="E-Mail (Portal-Login)" htmlFor="customer-email">
|
||||
<input id="customer-email" className="input" type="email" name="email" value={form.email} onChange={set('email')} required />
|
||||
</Field>
|
||||
<Field label="Kundennummer" htmlFor="customer-code">
|
||||
<input id="customer-code" className="input" name="code" value={form.code} onChange={set('code')} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="Telefon"><input className="form-control" name="phone" value={form.phone} onChange={set('phone')} /></Field>
|
||||
<Field label="Standort"><input className="form-control" name="location" value={form.location} onChange={set('location')} /></Field>
|
||||
<Field label="Portal-Passwort (mind. 8 Zeichen)"><input className="form-control" type="password" autoComplete="new-password" name="password" value={form.password} onChange={set('password')} required /></Field>
|
||||
<div className="field-row-3">
|
||||
<Field label="Telefon" htmlFor="customer-phone">
|
||||
<input id="customer-phone" className="input" name="phone" value={form.phone} onChange={set('phone')} />
|
||||
</Field>
|
||||
<Field label="Standort" htmlFor="customer-location">
|
||||
<input id="customer-location" className="input" name="location" value={form.location} onChange={set('location')} />
|
||||
</Field>
|
||||
<Field label="Portal-Passwort (mind. 8 Zeichen)" htmlFor="customer-password">
|
||||
<input id="customer-password" className="input" type="password" autoComplete="new-password" name="password" value={form.password} onChange={set('password')} required />
|
||||
</Field>
|
||||
</div>
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Kunde anlegen'}</Button>
|
||||
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1124,12 +1124,8 @@ select.form-control {
|
||||
.text-grey { color: var(--text-2); }
|
||||
.text-red { color: var(--red-fg); }
|
||||
|
||||
/* Zaehler und Initiale der Altlisten: .side-count bis P6 (Chat-Liste), .side-logo bis P4 (Kundenliste) */
|
||||
/* Zaehler der Altliste: .side-count bis P6 (Chat-Liste) */
|
||||
.side-count { margin-left: auto; font-size: 12px; font-weight: 500; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
||||
.side-logo {
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 32px; height: 32px;
|
||||
border-radius: var(--radius); background: var(--grey-bg); color: var(--grey-fg); font-weight: 600;
|
||||
}
|
||||
|
||||
/* Neutral: Karte, Raster */
|
||||
.card { margin-bottom: 16px; background: none; border: 0; border-radius: 0; color: var(--text); }
|
||||
|
||||
Reference in new issue
Block a user