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
@@ -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 />
|
||||
|
||||
Reference in new issue
Block a user