Kunden: Reiter "Abgesagt", Absage eintragen und Warnung vor Doppelten
- Kundenliste mit Reiter "Abgesagt" (vorher nur unter "Alle"), rotes Abzeichen, Suche auch nach Telefon - Kundenseite: "Absage eintragen" fuer Leads (Grund freiwillig); Vermerk "Abgesagt am TT.MM.JJJJ (Name): Grund" oben in die Notizen, offene Akquise-Tickets auf "Abgesagt"; roter Hinweis "nicht erneut anrufen" und "Wieder als Lead fuehren" - Neuer Lead (jetzt mit Telefon) und Neuer Kunde gleichen live mit allen Kunden ab (auch archivierte/abgesagte): Name ohne Rechtsform, E-Mail, Firmen-Domain (ohne Freemailer), Telefon; Abgesagte rot und zuerst, Knopf heisst dann "Trotzdem anlegen" - Uebersicht wird nach Speichern neu aufgebaut (key), damit kein alter Notizstand den Vermerk ueberschreibt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5d8130e8ef
commit
d2883018fc
10 files changed
+378
-23
No files matched your search
@@ -0,0 +1,31 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Badge } from '../ui'
|
||||
import { customerBadges, customerTitle } from '../../lib/customerList'
|
||||
import { lastRejection } from '../../lib/leadDuplicates'
|
||||
|
||||
// Hinweis in den Anlege-Formularen: diesen Betrieb gibt es schon (Abgesagte rot und zuerst).
|
||||
export default function DuplicateWarning({ matches }) {
|
||||
if (!matches || matches.length === 0) return null
|
||||
const anyLost = matches.some((m) => String(m.customer.customerStatus || '').toLowerCase() === 'lost')
|
||||
return (
|
||||
<div className={`msg ${anyLost ? 'err' : 'warn'} dup-warning`} role="alert">
|
||||
<strong>{anyLost ? 'Achtung: hat bereits abgesagt – bitte nicht erneut anrufen.' : 'Diesen Kontakt gibt es vermutlich schon.'}</strong>
|
||||
<ul className="dup-list">
|
||||
{matches.slice(0, 5).map(({ customer, reasons }) => {
|
||||
const note = lastRejection(customer.notes)
|
||||
return (
|
||||
<li key={customer.$id}>
|
||||
<Link to={`/customers/${customer.$id}`} target="_blank" rel="noreferrer">{customerTitle(customer)}</Link>{' '}
|
||||
{customerBadges(customer).map((b) => (
|
||||
<Badge key={b.key} tone={b.tone}>{b.label}</Badge>
|
||||
))}{' '}
|
||||
<span className="muted small">gleich: {reasons.join(', ')}</span>
|
||||
{note && <div className="small">{note}</div>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{matches.length > 5 && <div className="small">… und {matches.length - 5} weitere</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,13 +17,14 @@ export function isArchived(c) {
|
||||
export const CUSTOMER_TABS = [
|
||||
{ id: 'customer', label: 'Feste Kunden' },
|
||||
{ id: 'lead', label: 'Leads' },
|
||||
{ id: 'lost', label: 'Abgesagt' },
|
||||
{ id: 'archived', label: 'Archiviert' },
|
||||
{ id: 'all', label: 'Alle' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Filtert die Kundenliste nach Reiter und Suchtext. Die Reihenfolge der Eingabe bleibt.
|
||||
* Archivierte erscheinen nur im Reiter `archived`. Die Stufe `lost` erscheint nur im Reiter `all`.
|
||||
* Archivierte erscheinen nur im Reiter `archived`.
|
||||
*/
|
||||
export function filterCustomers(customers, { stage = 'customer', search = '' } = {}) {
|
||||
const s = String(search || '').toLowerCase()
|
||||
@@ -32,13 +33,13 @@ export function filterCustomers(customers, { stage = 'customer', search = '' } =
|
||||
if (stage === 'archived') return archived
|
||||
if (archived) return false
|
||||
if (stage === 'all') return true
|
||||
if (stage === 'customer' || stage === 'lead') return customerStage(c) === stage
|
||||
if (stage === 'customer' || stage === 'lead' || stage === 'lost') return customerStage(c) === stage
|
||||
return false
|
||||
}
|
||||
return (customers || []).filter((c) => {
|
||||
if (!matchesStage(c)) return false
|
||||
if (!s) return true
|
||||
return `${c.name || ''} ${c.code || ''} ${c.location || ''} ${c.email || ''} ${c.contactName || ''}`.toLowerCase().includes(s)
|
||||
return `${c.name || ''} ${c.code || ''} ${c.location || ''} ${c.email || ''} ${c.contactName || ''} ${c.phone || ''}`.toLowerCase().includes(s)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,7 +66,7 @@ export function customerBadges(c, { stage = true } = {}) {
|
||||
if (stage) {
|
||||
const st = customerStage(c)
|
||||
if (st === 'lead') badges.push({ key: 'lead', label: stageLabel('lead'), tone: 'muted' })
|
||||
else if (st === 'lost') badges.push({ key: 'lost', label: stageLabel('lost'), tone: 'plain' })
|
||||
else if (st === 'lost') badges.push({ key: 'lost', label: stageLabel('lost'), tone: 'danger' })
|
||||
}
|
||||
if (isArchived(c)) badges.push({ key: 'archived', label: 'Archiviert', tone: 'plain' })
|
||||
return badges
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Doppelte Kontakte erkennen und Absagen vermerken, damit niemand zweimal angerufen wird.
|
||||
// Reine Funktionen ohne Importe (Liste, Formulare und Tests benutzen dieselbe Logik).
|
||||
|
||||
// Rechtsformen und Füllwörter, die beim Namensvergleich nicht zählen
|
||||
const LEGAL_WORDS = new Set([
|
||||
'gmbh', 'mbh', 'ug', 'haftungsbeschraenkt', 'ag', 'kg', 'ohg', 'gbr', 'ek', 'ev', 'eg', 'co', 'und', 'inh', 'inhaber',
|
||||
])
|
||||
|
||||
// Freemailer: gleiche Domain heißt hier nicht gleicher Betrieb
|
||||
const FREEMAIL = new Set([
|
||||
'gmail.com', 'googlemail.com', 'web.de', 'gmx.de', 'gmx.net', 'gmx.com', 't-online.de', 'outlook.com', 'outlook.de',
|
||||
'hotmail.com', 'hotmail.de', 'live.com', 'live.de', 'yahoo.com', 'yahoo.de', 'icloud.com', 'me.com', 'mac.com',
|
||||
'posteo.de', 'freenet.de', 'aol.com', 'aol.de', 'mail.de', 'online.de', 'arcor.de', 'vodafone.de', 'email.de',
|
||||
])
|
||||
|
||||
/** Name vergleichbar machen: klein, Umlaute als ae/oe/ue, ohne Akzente, Satzzeichen, Rechtsform und einzelne Buchstaben (e.K., e.V.). */
|
||||
export function normalizeName(name) {
|
||||
const plain = String(name || '')
|
||||
.toLowerCase()
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') // „Baeckerei“ und „Bäckerei“ gleich
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/&/g, ' ')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
return plain.split(' ').filter((w) => w.length > 1 && !LEGAL_WORDS.has(w)).join(' ')
|
||||
}
|
||||
|
||||
/** Telefonnummer auf Ziffern bringen, +49 / 0049 als führende 0. Unter 6 Ziffern zählt nicht. */
|
||||
export function normalizePhone(phone) {
|
||||
let d = String(phone || '').replace(/\D/g, '')
|
||||
if (d.startsWith('0049')) d = '0' + d.slice(4)
|
||||
else if (d.startsWith('49') && String(phone || '').trim().startsWith('+')) d = '0' + d.slice(2)
|
||||
return d.length >= 6 ? d : ''
|
||||
}
|
||||
|
||||
export function normalizeEmail(email) {
|
||||
return String(email || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
/** Firmen-Domain einer E-Mail (leer bei Freemailern). */
|
||||
export function companyDomain(email) {
|
||||
const at = normalizeEmail(email).split('@')
|
||||
if (at.length !== 2 || !at[1].includes('.')) return ''
|
||||
const domain = at[1].replace(/^www\./, '')
|
||||
return FREEMAIL.has(domain) ? '' : domain
|
||||
}
|
||||
|
||||
function sameName(a, b) {
|
||||
if (!a || !b) return false
|
||||
if (a === b) return true
|
||||
// Enthaltensein nur bei ausreichend langem Namen ganzer Wörter („Bäckerei“ allein ist zu allgemein)
|
||||
const [short, long] = a.length <= b.length ? [a, b] : [b, a]
|
||||
return short.length >= 8 && short.includes(' ') && ` ${long} `.includes(` ${short} `)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht in allen Kunden (auch archivierten) nach Einträgen, die zu den Eingaben passen.
|
||||
* Rückgabe: [{ customer, reasons: ['Name' | 'E-Mail' | 'E-Mail-Domain' | 'Telefon'] }], Abgesagte zuerst.
|
||||
* `excludeId` lässt den eigenen Eintrag weg (Kundenseite).
|
||||
*/
|
||||
export function findDuplicates(customers, { name = '', email = '', phone = '' } = {}, { excludeId = '' } = {}) {
|
||||
const n = normalizeName(name)
|
||||
const e = normalizeEmail(email)
|
||||
const dom = companyDomain(email)
|
||||
const p = normalizePhone(phone)
|
||||
if (!n && !e && !p) return []
|
||||
const out = []
|
||||
for (const c of customers || []) {
|
||||
if (!c || (excludeId && c.$id === excludeId)) continue
|
||||
const reasons = []
|
||||
if (n && (sameName(n, normalizeName(c.name)) || sameName(n, normalizeName(c.companyName)))) reasons.push('Name')
|
||||
const ce = normalizeEmail(c.email)
|
||||
if (e && ce && e === ce) reasons.push('E-Mail')
|
||||
else if (dom && dom === companyDomain(c.email)) reasons.push('E-Mail-Domain')
|
||||
if (p && p === normalizePhone(c.phone)) reasons.push('Telefon')
|
||||
if (reasons.length) out.push({ customer: c, reasons })
|
||||
}
|
||||
const lost = (c) => String(c?.customerStatus || '').toLowerCase() === 'lost'
|
||||
return out.sort((a, b) => Number(lost(b.customer)) - Number(lost(a.customer)))
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
const d = date instanceof Date ? date : new Date(date)
|
||||
const pad = (x) => String(x).padStart(2, '0')
|
||||
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()}`
|
||||
}
|
||||
|
||||
/** Vermerk-Zeile für die Notizen: „Abgesagt am 06.10.2026 (Kenso): Grund“. */
|
||||
export function rejectionLine({ date = new Date(), by = '', reason = '' } = {}) {
|
||||
const who = String(by || '').trim()
|
||||
const why = String(reason || '').replace(/\s+/g, ' ').trim()
|
||||
return `Abgesagt am ${formatDate(date)}${who ? ` (${who})` : ''}${why ? `: ${why}` : ''}`
|
||||
}
|
||||
|
||||
/** Notizen mit dem Vermerk oben; bleibt unter `max` Zeichen (Appwrite-Feld notes hat 5000). */
|
||||
export function notesWithRejection(notes, line, max = 5000) {
|
||||
const rest = String(notes || '').trim()
|
||||
const joined = rest ? `${line}\n${rest}` : line
|
||||
return joined.length > max ? joined.slice(0, max) : joined
|
||||
}
|
||||
|
||||
/** Jüngster Absage-Vermerk aus den Notizen (oder ''). */
|
||||
export function lastRejection(notes) {
|
||||
const line = String(notes || '').split('\n').find((l) => /^Abgesagt am \d{2}\.\d{2}\.\d{4}/.test(l.trim()))
|
||||
return line ? line.trim() : ''
|
||||
}
|
||||
+41
-2
@@ -1,4 +1,5 @@
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from './appwrite'
|
||||
import { rejectionLine, notesWithRejection } from './leadDuplicates'
|
||||
|
||||
/** Pflichtfelder eines festen Kunden: Name, E-Mail und vollständige Anschrift (Voraussetzung für „Zum festen Kunden machen“). */
|
||||
export function isInvoiceReady(c) {
|
||||
@@ -54,12 +55,13 @@ export async function createAcquisitionTicket(customer, user) {
|
||||
return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ID.unique(), data)
|
||||
}
|
||||
|
||||
/** Legt einen Lead an (nur Unternehmen + E-Mail) und erstellt das Akquise-Ticket. */
|
||||
export async function createLead({ company, email }, user) {
|
||||
/** Legt einen Lead an (Unternehmen, E-Mail, Telefon) und erstellt das Akquise-Ticket. */
|
||||
export async function createLead({ company, email, phone }, user) {
|
||||
const customer = await databases.createDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ID.unique(), {
|
||||
name: company,
|
||||
companyName: company,
|
||||
email: email || '',
|
||||
phone: phone || '',
|
||||
customerStatus: 'lead',
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
@@ -80,3 +82,40 @@ export async function upgradeToCustomer(customer, fullData) {
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Ticket-Status, die als erledigt gelten (offene Akquise-Tickets werden bei einer Absage geschlossen)
|
||||
const DONE_STATUS = ['Closed', 'Cancelled', 'Aborted', 'Abgesagt', 'Zugesagt']
|
||||
|
||||
/**
|
||||
* Trägt eine Absage ein: Stufe „Abgesagt“, Vermerk mit Datum, Person und Grund oben in den Notizen,
|
||||
* offene Akquise-Tickets des Kunden auf „Abgesagt“. Liefert das gespeicherte Kunden-Dokument.
|
||||
*/
|
||||
export async function markLost(customer, { reason = '', by = '' } = {}) {
|
||||
const line = rejectionLine({ date: new Date(), by, reason })
|
||||
const saved = await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
|
||||
customerStatus: 'lost',
|
||||
notes: notesWithRejection(customer.notes, line),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
try {
|
||||
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
|
||||
Query.equal('customerId', customer.$id),
|
||||
Query.equal('type', 'Akquise'),
|
||||
Query.limit(100),
|
||||
])
|
||||
for (const t of res.documents.filter((d) => !DONE_STATUS.includes(d.status))) {
|
||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, t.$id, { status: 'Abgesagt' })
|
||||
}
|
||||
} catch {
|
||||
/* nicht kritisch - die Absage am Kunden steht */
|
||||
}
|
||||
return saved
|
||||
}
|
||||
|
||||
/** Macht eine Absage rückgängig: wieder Lead (der Vermerk in den Notizen bleibt als Verlauf). */
|
||||
export async function reopenAsLead(customer) {
|
||||
return databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
|
||||
customerStatus: 'lead',
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
@@ -282,6 +282,7 @@ export default function AssignProjectPage() {
|
||||
</div>
|
||||
{showNewCustomer && isAdmin && (
|
||||
<CreateCustomerForm
|
||||
customers={customers}
|
||||
onCancel={() => setShowNewCustomer(false)}
|
||||
onCreate={async (data) => {
|
||||
const r = await createCustomer(data)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 } from '../components/ui'
|
||||
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Dialog, Field } from '../components/ui'
|
||||
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
||||
import CustomerInvoices from '../components/finance/CustomerInvoices'
|
||||
import AssignedProjectCard from '../components/AssignedProjectCard'
|
||||
@@ -10,7 +10,8 @@ import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
|
||||
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
|
||||
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer, markLost, reopenAsLead } from '../lib/leads'
|
||||
import { lastRejection } from '../lib/leadDuplicates'
|
||||
import { customerStage, isArchived, customerBadges, customerEyebrow, customerTitle } from '../lib/customerList'
|
||||
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
|
||||
import { ticketNumber } from '../lib/ticketView'
|
||||
@@ -117,7 +118,8 @@ export default function CustomerDetailPage() {
|
||||
|
||||
<Tabs tabs={customerTabs(isAdmin)} active={activeTab} onChange={setTab} />
|
||||
|
||||
{activeTab === 'overview' && <OverviewTab customer={customer} stage={stage} onSaved={load} />}
|
||||
{/* 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' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />}
|
||||
{activeTab === 'projects' && <CustomerProjects customerId={customer.$id} />}
|
||||
@@ -154,9 +156,29 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
|
||||
const [showLost, setShowLost] = useState(false)
|
||||
const { user, employee } = useAuth()
|
||||
|
||||
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 {
|
||||
@@ -180,9 +202,20 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
} 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>
|
||||
@@ -207,9 +240,11 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
<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>}
|
||||
@@ -235,6 +270,51 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useAuth } from '../context/AuthContext'
|
||||
import { createLead } from '../lib/leads'
|
||||
import { CUSTOMER_TABS, customerBadges, customerTitle, filterCustomers, stageCounts } from '../lib/customerList'
|
||||
import { isRowActivation } from '../lib/rowClick'
|
||||
import { findDuplicates } from '../lib/leadDuplicates'
|
||||
import DuplicateWarning from '../components/customer/DuplicateWarning'
|
||||
import { PageHeader, Tabs, Button, Badge, EmptyState, Field, Dialog } from '../components/ui'
|
||||
|
||||
export default function CustomersPage() {
|
||||
@@ -13,7 +15,7 @@ export default function CustomersPage() {
|
||||
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 | archived | all
|
||||
const [stage, setStage] = useState('customer') // customer | lead | lost | archived | all
|
||||
const [showCreate, setShowCreate] = useState(null) // null | 'lead' | 'customer'
|
||||
|
||||
const filtered = useMemo(() => filterCustomers(customers, { stage, search }), [customers, stage, search])
|
||||
@@ -91,7 +93,7 @@ export default function CustomersPage() {
|
||||
<input
|
||||
className="input search"
|
||||
type="search"
|
||||
placeholder="Kunde suchen …"
|
||||
placeholder="Name, E-Mail oder Telefon …"
|
||||
aria-label="Kunde suchen"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -102,6 +104,7 @@ export default function CustomersPage() {
|
||||
|
||||
{showCreate === 'lead' && (
|
||||
<CreateLeadForm
|
||||
customers={customers}
|
||||
onCancel={() => setShowCreate(null)}
|
||||
onDone={async (data) => {
|
||||
const r = await createLead(data, user)
|
||||
@@ -114,6 +117,7 @@ export default function CustomersPage() {
|
||||
|
||||
{showCreate === 'customer' && isAdmin && (
|
||||
<CreateCustomerForm
|
||||
customers={customers}
|
||||
onCancel={() => setShowCreate(null)}
|
||||
onCreate={async (data) => {
|
||||
const r = await createCustomer(data)
|
||||
@@ -130,18 +134,21 @@ export default function CustomersPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function CreateLeadForm({ onCancel, onDone }) {
|
||||
function CreateLeadForm({ customers, onCancel, onDone }) {
|
||||
const [company, setCompany] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
// Abgleich mit allen Kunden (auch archivierten und abgesagten), damit niemand doppelt angerufen wird
|
||||
const matches = useMemo(() => findDuplicates(customers, { name: company, email, phone }), [customers, company, email, phone])
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!company.trim()) { setError('Unternehmen erforderlich'); return }
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await onDone({ company: company.trim(), email: email.trim() })
|
||||
await onDone({ company: company.trim(), email: email.trim(), phone: phone.trim() })
|
||||
} catch (err) {
|
||||
setError(err.message || 'Fehler')
|
||||
setBusy(false)
|
||||
@@ -151,6 +158,7 @@ function CreateLeadForm({ onCancel, onDone }) {
|
||||
return (
|
||||
<Dialog
|
||||
as="form"
|
||||
wide
|
||||
title="Neuer Lead (potenzieller Kunde)"
|
||||
busy={busy}
|
||||
onClose={onCancel}
|
||||
@@ -159,31 +167,41 @@ function CreateLeadForm({ onCancel, onDone }) {
|
||||
<>
|
||||
<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>
|
||||
<Button variant="primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Speichert …' : matches.length ? 'Trotzdem anlegen' : '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.
|
||||
Fuer einen Lead reichen Unternehmen und E-Mail oder Telefon. Es wird automatisch ein Akquise-Ticket erstellt.
|
||||
</p>
|
||||
<div className="field-row">
|
||||
<DuplicateWarning matches={matches} />
|
||||
<div className="field-row-3">
|
||||
<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>
|
||||
<Field label="Telefon" htmlFor="lead-phone">
|
||||
<input id="lead-phone" className="input" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Fester Kunde mit Zugang zum Kundenportal (nur Admins; legt den Login im Portal an)
|
||||
export function CreateCustomerForm({ onCancel, onCreate }) {
|
||||
export function CreateCustomerForm({ customers, onCancel, onCreate }) {
|
||||
const [form, setForm] = useState({ name: '', email: '', code: '', phone: '', location: '', password: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const matches = useMemo(
|
||||
() => findDuplicates(customers, { name: form.name, email: form.email, phone: form.phone }),
|
||||
[customers, form.name, form.email, form.phone]
|
||||
)
|
||||
const set = (field) => (e) => setForm((f) => ({ ...f, [field]: e.target.value }))
|
||||
|
||||
const submit = async (e) => {
|
||||
@@ -214,6 +232,7 @@ export function CreateCustomerForm({ onCancel, onCreate }) {
|
||||
}
|
||||
>
|
||||
{error && <div className="msg err" role="alert">{error}</div>}
|
||||
<DuplicateWarning matches={matches} />
|
||||
<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 />
|
||||
|
||||
@@ -986,6 +986,14 @@ button.link { font-weight: 500; } /* font: inherit setzt sonst das Gewicht von .
|
||||
.kv + .check { margin-top: 16px; }
|
||||
.kv ~ .toolbar { margin-top: 16px; }
|
||||
.kunde-hinweis { margin-bottom: 12px; }
|
||||
/* Doppelte beim Anlegen und Absage-Hinweis auf der Kundenseite: farbige Flaeche, Abgesagte rot */
|
||||
.dup-warning { display: block; padding: 10px 12px; border-radius: var(--radius); background: var(--orange-bg); margin-bottom: 12px; }
|
||||
.dup-warning.err, .kunde-absage { background: var(--red-bg); color: var(--red-fg); }
|
||||
.dup-list { margin: 6px 0 0; padding-left: 18px; }
|
||||
.dup-list li + li { margin-top: 4px; }
|
||||
.dup-list .badge { margin-right: 4px; vertical-align: top; }
|
||||
.kunde-absage { padding: 10px 12px; border-radius: var(--radius); margin-bottom: 16px; font-size: 13px; }
|
||||
.kunde-absage .toolbar { margin-top: 8px; }
|
||||
/* 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) */
|
||||
|
||||
@@ -16,7 +16,7 @@ const ids = (list) => list.map((c) => c.$id)
|
||||
|
||||
test('CUSTOMER_TABS, customerStage, isArchived', () => {
|
||||
assert.deepEqual(CUSTOMER_TABS, [
|
||||
{ id: 'customer', label: 'Feste Kunden' }, { id: 'lead', label: 'Leads' },
|
||||
{ id: 'customer', label: 'Feste Kunden' }, { id: 'lead', label: 'Leads' }, { id: 'lost', label: 'Abgesagt' },
|
||||
{ id: 'archived', label: 'Archiviert' }, { id: 'all', label: 'Alle' },
|
||||
])
|
||||
assert.equal(customerStage({ customerStatus: 'LEAD' }), 'lead')
|
||||
@@ -29,6 +29,8 @@ test('CUSTOMER_TABS, customerStage, isArchived', () => {
|
||||
test('filterCustomers: jeder Reiter', () => {
|
||||
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'customer' })), ['a', 'd'])
|
||||
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'lead' })), ['b'])
|
||||
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'lost' })), ['c'])
|
||||
assert.deepEqual(ids(filterCustomers([{ $id: 'x', customerStatus: 'lost', archived: true }], { stage: 'lost' })), [])
|
||||
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'archived' })), ['e', 'f'])
|
||||
assert.deepEqual(ids(filterCustomers(ALL, { stage: 'all' })), ['a', 'b', 'c', 'd'])
|
||||
assert.deepEqual(ids(filterCustomers(ALL)), ['a', 'd'])
|
||||
@@ -36,19 +38,20 @@ test('filterCustomers: jeder Reiter', () => {
|
||||
assert.deepEqual(filterCustomers(undefined, { stage: 'all' }), [])
|
||||
})
|
||||
|
||||
test('filterCustomers: Suche in Name, Code, Standort, E-Mail und Ansprechpartner', () => {
|
||||
test('filterCustomers: Suche in Name, Code, Standort, E-Mail, Ansprechpartner und Telefon', () => {
|
||||
const find = (search, stage = 'all') => ids(filterCustomers(ALL, { stage, search }))
|
||||
for (const s of ['ALPHA', 'k-1', 'kaisers', 'info@alpha', 'anna']) assert.deepEqual(find(s), ['a'], s)
|
||||
assert.deepEqual(find('xyz'), [])
|
||||
assert.deepEqual(ids(filterCustomers([{ $id: 'p', phone: '0631 12345' }], { stage: 'all', search: '0631 123' })), ['p'])
|
||||
assert.deepEqual(find('eta', 'lead'), ['b'])
|
||||
assert.deepEqual(find(''), ['a', 'b', 'c', 'd'])
|
||||
assert.deepEqual(ids(filterCustomers([{ $id: 'n' }], { stage: 'all', search: 'undefined' })), [])
|
||||
})
|
||||
|
||||
test('stageCounts: dieselbe Filterfunktion wie die Liste', () => {
|
||||
assert.deepEqual(stageCounts(ALL), { customer: 2, lead: 1, archived: 2, all: 4 })
|
||||
assert.deepEqual(stageCounts(ALL, 'eta'), { customer: 0, lead: 1, archived: 1, all: 1 })
|
||||
assert.deepEqual(stageCounts([]), { customer: 0, lead: 0, archived: 0, all: 0 })
|
||||
assert.deepEqual(stageCounts(ALL), { customer: 2, lead: 1, lost: 1, archived: 2, all: 4 })
|
||||
assert.deepEqual(stageCounts(ALL, 'eta'), { customer: 0, lead: 1, lost: 0, archived: 1, all: 1 })
|
||||
assert.deepEqual(stageCounts([]), { customer: 0, lead: 0, lost: 0, archived: 0, all: 0 })
|
||||
})
|
||||
|
||||
test('customerBadges: Reihenfolge, Töne, Option stage', () => {
|
||||
@@ -57,7 +60,7 @@ test('customerBadges: Reihenfolge, Töne, Option stage', () => {
|
||||
{ key: 'lead', label: 'Lead', tone: 'muted' },
|
||||
{ key: 'archived', label: 'Archiviert', tone: 'plain' },
|
||||
])
|
||||
assert.deepEqual(customerBadges(C), [{ key: 'lost', label: 'Abgesagt', tone: 'plain' }])
|
||||
assert.deepEqual(customerBadges(C), [{ key: 'lost', label: 'Abgesagt', tone: 'danger' }])
|
||||
for (const c of [A, D, null]) assert.deepEqual(customerBadges(c), [])
|
||||
assert.deepEqual(customerBadges(E, { stage: false }).map((b) => b.key), ['important', 'archived'])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { normalizeName, normalizePhone, companyDomain, findDuplicates, rejectionLine, notesWithRejection, lastRejection } =
|
||||
await loadModule('src/lib/leadDuplicates.js')
|
||||
|
||||
test('normalizeName: Rechtsform, Umlaute, Satzzeichen, einzelne Buchstaben', () => {
|
||||
assert.equal(normalizeName('Bäckerei Müller GmbH & Co. KG'), 'baeckerei mueller')
|
||||
assert.equal(normalizeName('Café Rosé'), 'cafe rose')
|
||||
assert.equal(normalizeName('Friseur Amin e.K.'), 'friseur amin')
|
||||
assert.equal(normalizeName('Straße-Bau UG (haftungsbeschränkt)'), 'strasse bau')
|
||||
assert.equal(normalizeName(''), '')
|
||||
assert.equal(normalizeName(undefined), '')
|
||||
})
|
||||
|
||||
test('normalizePhone: Ziffern, +49 und 0049 als 0, zu kurz zählt nicht', () => {
|
||||
assert.equal(normalizePhone('+49 631 / 123 45-6'), '0631123456')
|
||||
assert.equal(normalizePhone('0049 631 1234560'), '06311234560')
|
||||
assert.equal(normalizePhone('0631 1234560'), '06311234560')
|
||||
assert.equal(normalizePhone('12 34'), '')
|
||||
assert.equal(normalizePhone(null), '')
|
||||
})
|
||||
|
||||
test('companyDomain: Freemailer zählen nicht', () => {
|
||||
assert.equal(companyDomain('Info@Baecker-Mueller.de'), 'baecker-mueller.de')
|
||||
assert.equal(companyDomain('x@gmail.com'), '')
|
||||
assert.equal(companyDomain('x@web.de'), '')
|
||||
assert.equal(companyDomain('kaputt'), '')
|
||||
})
|
||||
|
||||
const LIST = [
|
||||
{ $id: 'a', name: 'Bäckerei Müller GmbH', email: 'info@mueller-backt.de', phone: '0631 123456', customerStatus: 'lead' },
|
||||
{ $id: 'b', name: 'Friseur Amin', email: 'amin@gmail.com', customerStatus: 'customer' },
|
||||
{ $id: 'c', name: 'Autohaus Schmidt Kaiserslautern', phone: '+49 6301 9999', customerStatus: 'lost', notes: 'Abgesagt am 01.10.2026 (Kenso): kein Bedarf\nalt' },
|
||||
{ $id: 'd', name: 'Bäckerei', customerStatus: 'lead', archived: true },
|
||||
]
|
||||
const hit = (input, opts) => findDuplicates(LIST, input, opts).map((m) => `${m.customer.$id}:${m.reasons.join('+')}`)
|
||||
|
||||
test('findDuplicates: Name, E-Mail, Domain, Telefon; Abgesagte zuerst', () => {
|
||||
assert.deepEqual(hit({ name: 'Baeckerei Mueller' }), ['a:Name']) // ae/ue wie ä/ü
|
||||
assert.deepEqual(hit({ name: 'bäckerei müller' }), ['a:Name'])
|
||||
assert.deepEqual(hit({ name: 'Bäckerei' }), ['d:Name']) // archiviert zählt mit; kurzer Name nur exakt
|
||||
assert.deepEqual(hit({ email: 'INFO@mueller-backt.de' }), ['a:E-Mail'])
|
||||
assert.deepEqual(hit({ email: 'chef@mueller-backt.de' }), ['a:E-Mail-Domain'])
|
||||
assert.deepEqual(hit({ email: 'jemand@gmail.com' }), [])
|
||||
assert.deepEqual(hit({ phone: '06301/9999' }), ['c:Telefon'])
|
||||
assert.deepEqual(hit({ name: 'Autohaus Schmidt' }), ['c:Name']) // Teilname ab 2 Wörtern und 8 Zeichen
|
||||
assert.deepEqual(hit({ name: 'Schmidt' }), [])
|
||||
assert.deepEqual(hit({ name: 'Bäckerei Müller', phone: '0049 6301 9999' }), ['c:Telefon', 'a:Name'])
|
||||
assert.deepEqual(hit({ name: 'bäckerei müller' }, { excludeId: 'a' }), [])
|
||||
assert.deepEqual(hit({}), [])
|
||||
assert.deepEqual(findDuplicates(undefined, { name: 'x' }), [])
|
||||
})
|
||||
|
||||
test('Absage-Vermerk: Zeile, Notizen, letzter Vermerk', () => {
|
||||
const d = new Date(2026, 9, 6, 12)
|
||||
assert.equal(rejectionLine({ date: d, by: 'Kenso Grimm', reason: ' kein\n Bedarf ' }), 'Abgesagt am 06.10.2026 (Kenso Grimm): kein Bedarf')
|
||||
assert.equal(rejectionLine({ date: d }), 'Abgesagt am 06.10.2026')
|
||||
assert.equal(notesWithRejection('', 'X'), 'X')
|
||||
assert.equal(notesWithRejection(' alt \n', 'X'), 'X\nalt')
|
||||
assert.equal(notesWithRejection('a'.repeat(5000), 'X').length, 5000)
|
||||
assert.equal(lastRejection(LIST[2].notes), 'Abgesagt am 01.10.2026 (Kenso): kein Bedarf')
|
||||
assert.equal(lastRejection('nur Notiz'), '')
|
||||
assert.equal(lastRejection(undefined), '')
|
||||
})
|
||||
Reference in new issue
Block a user