feat: CRM-Stufen (Lead/Kunde) + InvoiceNinja-Sync (eine Pflege)

- Zwei Kundenstufen: Lead (potenziell) und fester Kunde; Lead braucht nur
  Unternehmen + E-Mail und erzeugt automatisch ein Akquise-Ticket
- Akquise-Worksheet-Ergebnis (In Kontakt/Zugesagt/Abgesagt) sichtbar in WSIDs;
  "Zugesagt" macht den Lead automatisch zum festen Kunden, "Abgesagt" -> verloren
- Kunde wird nur in Appwrite gepflegt und automatisch als InvoiceNinja-Client
  angelegt/aktualisiert (beim Upgrade bzw. bei Datenpflege)
- Rechnungen nur fuer feste Kunden mit vollstaendigen Daten (Strasse/PLZ/Stadt)
- Kundenliste mit Stufen-Filter, neue Rechnungs-/Adressfelder

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Webklar DeployandCursor committed 2026-06-17 11:18:28 +00:00
1 parent 7d764ce6bc
commit a1cb90372f
8 files changed
+296 -80

No files matched your search

+63 -44
View File
@@ -2,39 +2,48 @@ import { useState, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { FaSpinner, FaPlus, FaStar, FaFileInvoiceDollar, FaFileLines } from 'react-icons/fa6'
import { useCustomers } from '../hooks/useCustomers'
import { useAuth } from '../context/AuthContext'
import { createLead, customerStage } from '../lib/leads'
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
export default function CustomersPage() {
const { customers, loading, createCustomer, refresh } = useCustomers()
const { customers, loading, refresh } = useCustomers()
const { user } = useAuth()
const [search, setSearch] = useState('')
const [onlyImportant, setOnlyImportant] = useState(false)
const [stage, setStage] = useState('all') // all | lead | customer
const [showCreate, setShowCreate] = useState(false)
const filtered = useMemo(() => {
return (customers || [])
.filter((c) => (onlyImportant ? c.importantCustomer : true))
.filter((c) => {
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, onlyImportant])
}, [customers, search, stage])
return (
<div className="page">
<PageHeader
title="Kunden"
subtitle="Stammdaten, Verknuepfungen und 360-Grad-Sicht je Kunde"
actions={<Button variant="primary" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neuer Kunde</Button>}
subtitle="Potenzielle Kunden (Leads) und feste Kunden - eine Pflege, Sync mit InvoiceNinja"
actions={<Button variant="primary" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neuer Lead</Button>}
/>
{showCreate && (
<div style={{ marginBottom: 16 }}>
<CreateCustomerForm
<CreateLeadForm
onCancel={() => setShowCreate(false)}
onDone={async (data) => {
const r = await createCustomer(data)
if (r.success) { setShowCreate(false); refresh() }
const r = await createLead(data, user)
setShowCreate(false)
refresh()
return r
}}
/>
@@ -44,8 +53,9 @@ export default function CustomersPage() {
<Card pad={false}>
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
<div className="seg">
<button className={!onlyImportant ? 'active' : ''} onClick={() => setOnlyImportant(false)}>Alle</button>
<button className={onlyImportant ? 'active' : ''} onClick={() => setOnlyImportant(true)}>Wichtige</button>
<button className={stage === 'all' ? 'active' : ''} onClick={() => setStage('all')}>Alle</button>
<button className={stage === 'lead' ? 'active' : ''} onClick={() => setStage('lead')}>Leads</button>
<button className={stage === 'customer' ? 'active' : ''} onClick={() => setStage('customer')}>Feste Kunden</button>
</div>
<input className="form-control search-input" placeholder="Kunde suchen..." value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 320 }} />
</div>
@@ -53,28 +63,34 @@ export default function CustomersPage() {
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : filtered.length === 0 ? (
<EmptyState title="Keine Kunden" hint="Lege deinen ersten Kunden an." />
<EmptyState title="Keine Eintraege" hint="Lege einen neuen Lead an." />
) : (
<div className="list">
{filtered.map((c) => (
<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>}
{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="muted">{[c.location, c.email].filter(Boolean).join(' · ') || '-'}</div>
</div>
<div className="flex gap-2 wrap" style={{ justifyContent: 'flex-end' }}>
{c.invoiceNinjaClientId && <Badge tone="ok"><FaFileInvoiceDollar /> Rechnungen</Badge>}
{c.paperlessCorrespondent && <Badge tone="info"><FaFileLines /> Dokumente</Badge>}
</div>
</Link>
))}
<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' }}>
{st === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
{st === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
{st === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
{c.invoiceNinjaClientId && <Badge tone="info"><FaFileInvoiceDollar /> Ninja</Badge>}
{c.paperlessCorrespondent && <Badge tone="info"><FaFileLines /> Docs</Badge>}
</div>
</Link>
)
})}
</div>
)}
</div>
@@ -83,34 +99,37 @@ export default function CustomersPage() {
)
}
function CreateCustomerForm({ onCancel, onDone }) {
const [form, setForm] = useState({ name: '', code: '', location: '', email: '', phone: '' })
function CreateLeadForm({ onCancel, onDone }) {
const [company, setCompany] = useState('')
const [email, setEmail] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const submit = async (e) => {
e.preventDefault()
if (!form.name) { setError('Name erforderlich'); return }
if (!company.trim()) { setError('Unternehmen erforderlich'); return }
setBusy(true); setError(null)
const r = await onDone(form)
if (!r.success) { setError(r.error || 'Fehler'); setBusy(false) }
try {
await onDone({ company: company.trim(), email: email.trim() })
} catch (err) {
setError(err.message || 'Fehler')
setBusy(false)
}
}
return (
<Card title="Neuer Kunde">
<Card title="Neuer Lead (potenzieller Kunde)">
<p className="muted" style={{ marginTop: 0, fontSize: 13 }}>
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="Name"><input className="form-control" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></Field>
<Field label="Code"><input className="form-control" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></Field>
</div>
<div className="flex gap-3 wrap">
<Field label="Ort"><input className="form-control" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></Field>
<Field label="E-Mail"><input className="form-control" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></Field>
<Field label="Telefon"><input className="form-control" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></Field>
<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>
{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" /> : 'Anlegen'}</Button>
<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>