From d2883018fc0a9231111fa5e2fa30235e4310bc8c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:48:14 +0000 Subject: [PATCH] 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 --- src/components/customer/DuplicateWarning.jsx | 31 ++++++ src/lib/customerList.js | 9 +- src/lib/leadDuplicates.js | 107 +++++++++++++++++++ src/lib/leads.js | 43 +++++++- src/pages/AssignProjectPage.jsx | 1 + src/pages/CustomerDetailPage.jsx | 86 ++++++++++++++- src/pages/CustomersPage.jsx | 35 ++++-- src/styles/global.css | 8 ++ tests/unit/customerList.test.mjs | 15 +-- tests/unit/leadDuplicates.test.mjs | 66 ++++++++++++ 10 files changed, 378 insertions(+), 23 deletions(-) create mode 100644 src/components/customer/DuplicateWarning.jsx create mode 100644 src/lib/leadDuplicates.js create mode 100644 tests/unit/leadDuplicates.test.mjs diff --git a/src/components/customer/DuplicateWarning.jsx b/src/components/customer/DuplicateWarning.jsx new file mode 100644 index 0000000..c3c02c0 --- /dev/null +++ b/src/components/customer/DuplicateWarning.jsx @@ -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 ( +
+ {anyLost ? 'Achtung: hat bereits abgesagt – bitte nicht erneut anrufen.' : 'Diesen Kontakt gibt es vermutlich schon.'} +
    + {matches.slice(0, 5).map(({ customer, reasons }) => { + const note = lastRejection(customer.notes) + return ( +
  • + {customerTitle(customer)}{' '} + {customerBadges(customer).map((b) => ( + {b.label} + ))}{' '} + gleich: {reasons.join(', ')} + {note &&
    {note}
    } +
  • + ) + })} +
+ {matches.length > 5 &&
… und {matches.length - 5} weitere
} +
+ ) +} diff --git a/src/lib/customerList.js b/src/lib/customerList.js index 500b956..0b3cd0d 100644 --- a/src/lib/customerList.js +++ b/src/lib/customerList.js @@ -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 diff --git a/src/lib/leadDuplicates.js b/src/lib/leadDuplicates.js new file mode 100644 index 0000000..8b77aaf --- /dev/null +++ b/src/lib/leadDuplicates.js @@ -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() : '' +} diff --git a/src/lib/leads.js b/src/lib/leads.js index 1b9c4bc..65a1e45 100644 --- a/src/lib/leads.js +++ b/src/lib/leads.js @@ -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(), + }) +} diff --git a/src/pages/AssignProjectPage.jsx b/src/pages/AssignProjectPage.jsx index 998b88c..2993737 100644 --- a/src/pages/AssignProjectPage.jsx +++ b/src/pages/AssignProjectPage.jsx @@ -282,6 +282,7 @@ export default function AssignProjectPage() { {showNewCustomer && isAdmin && ( setShowNewCustomer(false)} onCreate={async (data) => { const r = await createCustomer(data) diff --git a/src/pages/CustomerDetailPage.jsx b/src/pages/CustomerDetailPage.jsx index 94b588d..3bf4f91 100644 --- a/src/pages/CustomerDetailPage.jsx +++ b/src/pages/CustomerDetailPage.jsx @@ -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() { - {activeTab === 'overview' && } + {/* key: nach Absage/Umstufung neu aufbauen, sonst ueberschreibt ein spaeteres Speichern die neuen Notizen */} + {activeTab === 'overview' && } {activeTab === 'tickets' && } {activeTab === 'invoices' && setTab('overview')} />} {activeTab === 'projects' && } @@ -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 (
+ {stage === 'lost' && ( +
+ Hat abgesagt – bitte nicht erneut anrufen. + {rejection &&
{rejection}
} +
+ +
+
+ )}
@@ -207,9 +240,11 @@ function OverviewTab({ customer, stage, onSaved }) {
{stage === 'lead' && } + {stage === 'lead' && }
+ {showLost && setShowLost(false)} onSave={saveLost} />} {!ready &&
Adresse unvollständig
} @@ -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 ( + + + + + + } + > + {error &&
{error}
} +

+ 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. +

+ +