feat: CRM-Tab Leads - Lead-Visualisierung aus Appwrite + Sucheinstellungen der 06:00-Routine
- Neue Seite /leads unter CRM: Lead-Karten (Score, Google-Bewertung, Website-Status, Kontakt, Maps/Website/Repo-Links), KPI-Zeile, Status-Workflow (neu/kontaktiert/interessiert/gewonnen/abgelehnt), Notizen, Portal-Zugangsdaten - Sucheinstellungen (Branche, Staedte oder ganz Deutschland, max. Leads pro Stadt, Mindestbewertung, E-Mail-Pflicht, Website-Kriterium, Zusatzhinweise) -> Appwrite leadSettings, gelesen von der Routine ueber webklar-hook /config - Ersetzt die Excel-Masterliste als Lead-Ansicht Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import ReportsPage from './pages/ReportsPage'
|
||||
import DocsPage from './pages/DocsPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import CustomersPage from './pages/CustomersPage'
|
||||
import LeadsPage from './pages/LeadsPage'
|
||||
import CustomerDetailPage from './pages/CustomerDetailPage'
|
||||
import FinancePage from './pages/FinancePage'
|
||||
import DocumentsPage from './pages/DocumentsPage'
|
||||
@@ -45,6 +46,7 @@ function AppRoutes() {
|
||||
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
||||
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
|
||||
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/leads" element={<ProtectedRoute><LeadsPage /></ProtectedRoute>} />
|
||||
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
|
||||
<Route path="/documents" element={<ProtectedRoute><DocumentsPage /></ProtectedRoute>} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext'
|
||||
import {
|
||||
IconTicket,
|
||||
IconUsers,
|
||||
IconTargetArrow,
|
||||
IconReportMoney,
|
||||
IconFiles,
|
||||
IconFolders,
|
||||
@@ -40,7 +41,10 @@ export default function Sidebar() {
|
||||
},
|
||||
{
|
||||
label: 'CRM',
|
||||
links: [{ label: 'Kunden', href: '/customers', icon: <IconUsers /> }],
|
||||
links: [
|
||||
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
|
||||
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Finanzen',
|
||||
|
||||
141
src/hooks/useLeads.js
Normal file
141
src/hooks/useLeads.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { COLLECTIONS, DATABASE_ID, Query, databases } from '../lib/appwrite'
|
||||
|
||||
const PAGE = 100
|
||||
|
||||
/** Recherche-Leads aus der taeglichen 06:00-Routine (Appwrite `leads`). */
|
||||
export function useLeads() {
|
||||
const [leads, setLeads] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const fetchLeads = useCallback(async () => {
|
||||
try {
|
||||
const out = []
|
||||
let offset = 0
|
||||
for (;;) {
|
||||
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.LEADS, [
|
||||
Query.orderDesc('leadScore'),
|
||||
Query.limit(PAGE),
|
||||
Query.offset(offset),
|
||||
])
|
||||
out.push(...(res.documents || []))
|
||||
if ((res.documents || []).length < PAGE) break
|
||||
offset += PAGE
|
||||
}
|
||||
setLeads(out)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
if (err.code === 404 || err.message?.includes('not found')) {
|
||||
setLeads([])
|
||||
setError(null)
|
||||
} else {
|
||||
setError(err.message)
|
||||
setLeads([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLeads()
|
||||
}, [fetchLeads])
|
||||
|
||||
const updateLead = async (id, data) => {
|
||||
try {
|
||||
const doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEADS, id, {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
setLeads((prev) => prev.map((l) => (l.$id === id ? doc : l)))
|
||||
return { success: true, data: doc }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
const deleteLead = async (id) => {
|
||||
try {
|
||||
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.LEADS, id)
|
||||
setLeads((prev) => prev.filter((l) => l.$id !== id))
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
return { leads, loading, error, refresh: fetchLeads, updateLead, deleteLead }
|
||||
}
|
||||
|
||||
export const DEFAULT_LEAD_SETTINGS = {
|
||||
branche: 'Autolackiererei',
|
||||
staedte: [],
|
||||
ganzDeutschland: false,
|
||||
maxLeadsProStadt: 5,
|
||||
minBewertung: 4.0,
|
||||
emailPflicht: true,
|
||||
nurSchlechteWebsite: true,
|
||||
zusatzHinweise: '',
|
||||
}
|
||||
|
||||
/** Sucheinstellungen der Routine (Appwrite `leadSettings`, Dokument `main`).
|
||||
* Die Cloud-Routine liest sie vor jedem Lauf ueber den Server-Hook (/config). */
|
||||
export function useLeadSettings() {
|
||||
const [settings, setSettings] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const doc = await databases.getDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main')
|
||||
setSettings(doc)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
if (err.code === 404 || err.message?.includes('not found')) {
|
||||
setSettings({ ...DEFAULT_LEAD_SETTINGS })
|
||||
setError(null)
|
||||
} else {
|
||||
setError(err.message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
}, [fetchSettings])
|
||||
|
||||
const saveSettings = async (data) => {
|
||||
const payload = {
|
||||
branche: data.branche || '',
|
||||
staedte: data.staedte || [],
|
||||
ganzDeutschland: Boolean(data.ganzDeutschland),
|
||||
maxLeadsProStadt: Number(data.maxLeadsProStadt) || 5,
|
||||
minBewertung: Number(data.minBewertung) || 0,
|
||||
emailPflicht: Boolean(data.emailPflicht),
|
||||
nurSchlechteWebsite: Boolean(data.nurSchlechteWebsite),
|
||||
zusatzHinweise: data.zusatzHinweise || '',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
try {
|
||||
let doc
|
||||
try {
|
||||
doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main', payload)
|
||||
} catch (err) {
|
||||
if (err.code === 404) {
|
||||
doc = await databases.createDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main', payload)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
setSettings(doc)
|
||||
return { success: true, data: doc }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
return { settings, loading, error, refresh: fetchSettings, saveSettings }
|
||||
}
|
||||
@@ -40,6 +40,8 @@ export const COLLECTIONS = {
|
||||
USERS: 'users',
|
||||
ATTACHMENTS: 'attachments',
|
||||
WEBSITE_PROJECTS: 'websiteProjects',
|
||||
LEADS: 'leads',
|
||||
LEAD_SETTINGS: 'leadSettings',
|
||||
}
|
||||
|
||||
export const WEBPAGE_TICKET_TYPE = 'Webpage'
|
||||
|
||||
441
src/pages/LeadsPage.jsx
Normal file
441
src/pages/LeadsPage.jsx
Normal file
@@ -0,0 +1,441 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
FaSpinner,
|
||||
FaStar,
|
||||
FaGear,
|
||||
FaPlus,
|
||||
FaXmark,
|
||||
FaMapLocationDot,
|
||||
FaGlobe,
|
||||
FaPhone,
|
||||
FaEnvelope,
|
||||
FaChevronDown,
|
||||
FaChevronUp,
|
||||
FaGitAlt,
|
||||
FaFloppyDisk,
|
||||
} from 'react-icons/fa6'
|
||||
import { useLeads, useLeadSettings } from '../hooks/useLeads'
|
||||
import { PageHeader, Card, Button, Badge, EmptyState, Field, Kpi } from '../components/ui'
|
||||
import CopyableCredential from '../components/CopyableCredential'
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ id: 'neu', label: 'Neu', tone: 'info' },
|
||||
{ id: 'kontaktiert', label: 'Kontaktiert', tone: 'warn' },
|
||||
{ id: 'interessiert', label: 'Interessiert', tone: 'warn' },
|
||||
{ id: 'gewonnen', label: 'Gewonnen', tone: 'ok' },
|
||||
{ id: 'abgelehnt', label: 'Abgelehnt', tone: 'danger' },
|
||||
]
|
||||
|
||||
const statusMeta = (id) => STATUS_OPTIONS.find((s) => s.id === id) || STATUS_OPTIONS[0]
|
||||
|
||||
function scoreColor(score) {
|
||||
if (score >= 85) return '#34d399'
|
||||
if (score >= 70) return '#fbbf24'
|
||||
return '#f87171'
|
||||
}
|
||||
|
||||
export default function LeadsPage() {
|
||||
const { leads, loading, error, updateLead } = useLeads()
|
||||
const [search, setSearch] = useState('')
|
||||
const [status, setStatus] = useState('all')
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return (leads || [])
|
||||
.filter((l) => (status === 'all' ? true : (l.crmStatus || 'neu') === status))
|
||||
.filter((l) => {
|
||||
if (!search) return true
|
||||
const s = search.toLowerCase()
|
||||
return `${l.firma} ${l.ort || ''} ${l.email || ''} ${l.branche || ''}`.toLowerCase().includes(s)
|
||||
})
|
||||
}, [leads, search, status])
|
||||
|
||||
const kpis = useMemo(() => {
|
||||
const all = leads || []
|
||||
const neu = all.filter((l) => (l.crmStatus || 'neu') === 'neu').length
|
||||
const gewonnen = all.filter((l) => l.crmStatus === 'gewonnen').length
|
||||
const avg = all.length
|
||||
? Math.round(all.reduce((sum, l) => sum + (l.leadScore || 0), 0) / all.length)
|
||||
: 0
|
||||
const orte = new Set(all.map((l) => (l.ort || '').trim().toLowerCase()).filter(Boolean))
|
||||
return { total: all.length, neu, gewonnen, avg, orte: orte.size }
|
||||
}, [leads])
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Leads"
|
||||
subtitle="Recherche-Ergebnisse der täglichen 06:00-Routine — gespeichert in der Datenbank statt Excel"
|
||||
actions={
|
||||
<Button variant={showSettings ? 'primary' : 'default'} onClick={() => setShowSettings((s) => !s)}>
|
||||
<FaGear /> Sucheinstellungen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{showSettings && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<LeadSettingsCard onClose={() => setShowSettings(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="kpi-grid" style={{ marginBottom: 16 }}>
|
||||
<Kpi label="Leads gesamt" value={kpis.total} />
|
||||
<Kpi label="Neu" value={kpis.neu} accent="#3b82f6" />
|
||||
<Kpi label="Gewonnen" value={kpis.gewonnen} accent="#10b981" />
|
||||
<Kpi label="Ø Lead-Score" value={kpis.avg} accent="#f59e0b" />
|
||||
<Kpi label="Orte" value={kpis.orte} accent="#8b5cf6" />
|
||||
</div>
|
||||
|
||||
<Card pad={false}>
|
||||
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="seg">
|
||||
<button className={status === 'all' ? 'active' : ''} onClick={() => setStatus('all')}>Alle</button>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<button key={s.id} className={status === s.id ? 'active' : ''} onClick={() => setStatus(s.id)}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="form-control search-input"
|
||||
placeholder="Lead suchen..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="ui-card-body">
|
||||
{loading ? (
|
||||
<div className="empty"><FaSpinner className="spinner" /></div>
|
||||
) : error ? (
|
||||
<EmptyState title="Fehler beim Laden" hint={error} />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Keine Leads"
|
||||
hint="Die 06:00-Routine liefert neue Leads automatisch hierher."
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
{filtered.map((lead) => (
|
||||
<LeadCard key={lead.$id} lead={lead} onUpdate={updateLead} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LeadCard({ lead, onUpdate }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [notiz, setNotiz] = useState(lead.notiz || '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const meta = statusMeta(lead.crmStatus || 'neu')
|
||||
const score = lead.leadScore || 0
|
||||
|
||||
const setStatus = async (e) => {
|
||||
setBusy(true)
|
||||
await onUpdate(lead.$id, { crmStatus: e.target.value })
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
const saveNotiz = async () => {
|
||||
if (notiz === (lead.notiz || '')) return
|
||||
await onUpdate(lead.$id, { notiz })
|
||||
}
|
||||
|
||||
const websiteUrl = /^https?:\/\//.test(lead.website || '') ? lead.website : null
|
||||
|
||||
return (
|
||||
<div className="ui-card pad" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||
<div className="grow" style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{lead.firma}
|
||||
{lead.kategorie && <Badge tone={lead.kategorie === 'A' ? 'ok' : 'muted'}>Kat. {lead.kategorie}</Badge>}
|
||||
</div>
|
||||
<div className="muted">
|
||||
{[lead.ort, lead.branche].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontWeight: 800, fontSize: 20, color: scoreColor(score) }}>{score}</div>
|
||||
<div className="faint" style={{ fontSize: 11 }}>Score</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 6, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${Math.min(100, score)}%`, height: '100%', background: scoreColor(score) }} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
|
||||
<Badge tone={meta.tone} dot>{meta.label}</Badge>
|
||||
{lead.googleNote && (
|
||||
<Badge tone="warn">
|
||||
<FaStar /> {lead.googleNote} ({lead.anzahlBewertungen || 0})
|
||||
</Badge>
|
||||
)}
|
||||
{lead.processed && <Badge tone="ok">Kunde angelegt</Badge>}
|
||||
</div>
|
||||
|
||||
{lead.websiteStatus && (
|
||||
<div className="muted" style={{ fontSize: 13 }}>
|
||||
<strong>Website:</strong> {lead.websiteStatus.length > 120 && !open
|
||||
? lead.websiteStatus.slice(0, 120) + '…'
|
||||
: lead.websiteStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 wrap" style={{ fontSize: 13 }}>
|
||||
{lead.email && (
|
||||
<a href={`mailto:${lead.email}`} className="muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<FaEnvelope /> {lead.email}
|
||||
</a>
|
||||
)}
|
||||
{lead.telefon && (
|
||||
<a href={`tel:${lead.telefon.replace(/\s/g, '')}`} className="muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<FaPhone /> {lead.telefon}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 wrap">
|
||||
{lead.googleMapsLink && (
|
||||
<Button size="sm" as="a" href={lead.googleMapsLink} target="_blank" rel="noreferrer" title="Google Maps">
|
||||
<FaMapLocationDot /> Maps
|
||||
</Button>
|
||||
)}
|
||||
{websiteUrl && (
|
||||
<Button size="sm" as="a" href={websiteUrl} target="_blank" rel="noreferrer" title="Website">
|
||||
<FaGlobe /> Website
|
||||
</Button>
|
||||
)}
|
||||
{lead.repoUrl && (
|
||||
<Button size="sm" as="a" href={lead.repoUrl} target="_blank" rel="noreferrer" title="Preview-Repo">
|
||||
<FaGitAlt /> Repo
|
||||
</Button>
|
||||
)}
|
||||
<select
|
||||
className="form-control"
|
||||
value={lead.crmStatus || 'neu'}
|
||||
onChange={setStatus}
|
||||
disabled={busy}
|
||||
style={{ width: 'auto', marginLeft: 'auto', padding: '4px 8px', fontSize: 13 }}
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button size="sm" variant="ghost" onClick={() => setOpen((o) => !o)} title={open ? 'Weniger' : 'Details'}>
|
||||
{open ? <FaChevronUp /> : <FaChevronDown />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13 }}>
|
||||
{lead.ansprechpartner && <div><strong>Ansprechpartner:</strong> {lead.ansprechpartner}</div>}
|
||||
{lead.adresse && <div><strong>Adresse:</strong> {lead.adresse}</div>}
|
||||
{lead.kernleistungen && <div><strong>Leistungen:</strong> {lead.kernleistungen}</div>}
|
||||
{lead.hauptmaengel && <div><strong>Hauptmängel:</strong> {lead.hauptmaengel}</div>}
|
||||
{lead.sympathie && <div><strong>Eindruck:</strong> {lead.sympathie}</div>}
|
||||
{(lead.portalLogin || lead.portalPasswort) && (
|
||||
<div className="flex gap-3 wrap">
|
||||
{lead.portalLogin && <CopyableCredential label="Portal-Login" value={lead.portalLogin} />}
|
||||
{lead.portalPasswort && <CopyableCredential label="Portal-Passwort" value={lead.portalPasswort} secret />}
|
||||
</div>
|
||||
)}
|
||||
<Field label="Notiz">
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={2}
|
||||
value={notiz}
|
||||
onChange={(e) => setNotiz(e.target.value)}
|
||||
onBlur={saveNotiz}
|
||||
placeholder="Interne Notiz zum Lead..."
|
||||
/>
|
||||
</Field>
|
||||
<div className="faint" style={{ fontSize: 11 }}>
|
||||
Aufgenommen: {lead.addedAt ? new Date(lead.addedAt).toLocaleDateString('de-DE') : '-'}
|
||||
{lead.leadId ? ` · ${lead.leadId}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LeadSettingsCard({ onClose }) {
|
||||
const { settings, loading, saveSettings } = useLeadSettings()
|
||||
const [form, setForm] = useState(null)
|
||||
const [newStadt, setNewStadt] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState(null)
|
||||
|
||||
// Formular einmalig aus den geladenen Settings befuellen
|
||||
if (!form && !loading && settings) {
|
||||
setForm({
|
||||
branche: settings.branche || '',
|
||||
staedte: settings.staedte || [],
|
||||
ganzDeutschland: Boolean(settings.ganzDeutschland),
|
||||
maxLeadsProStadt: settings.maxLeadsProStadt ?? 5,
|
||||
minBewertung: settings.minBewertung ?? 4.0,
|
||||
emailPflicht: settings.emailPflicht !== false,
|
||||
nurSchlechteWebsite: settings.nurSchlechteWebsite !== false,
|
||||
zusatzHinweise: settings.zusatzHinweise || '',
|
||||
})
|
||||
}
|
||||
|
||||
const set = (key, value) => setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const addStadt = () => {
|
||||
const s = newStadt.trim()
|
||||
if (!s) return
|
||||
if (!form.staedte.some((x) => x.toLowerCase() === s.toLowerCase())) {
|
||||
set('staedte', [...form.staedte, s])
|
||||
}
|
||||
setNewStadt('')
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true)
|
||||
setMsg(null)
|
||||
const r = await saveSettings(form)
|
||||
setBusy(false)
|
||||
setMsg(r.success ? 'Gespeichert — gilt ab dem nächsten 06:00-Lauf.' : `Fehler: ${r.error}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Sucheinstellungen der 06:00-Routine"
|
||||
actions={<Button variant="ghost" size="sm" onClick={onClose}><FaXmark /></Button>}
|
||||
>
|
||||
{loading || !form ? (
|
||||
<div className="empty"><FaSpinner className="spinner" /></div>
|
||||
) : (
|
||||
<>
|
||||
<p className="muted" style={{ marginTop: 0, fontSize: 13 }}>
|
||||
Diese Einstellungen liest die tägliche Recherche-Routine vor jedem Lauf.
|
||||
Bereits vorhandene Leads und Kunden werden automatisch übersprungen.
|
||||
</p>
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="Branche" hint="z. B. Autolackiererei, Kfz-Werkstatt, Dachdecker">
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.branche}
|
||||
onChange={(e) => set('branche', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max. neue Leads pro Stadt">
|
||||
<input
|
||||
className="form-control"
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={form.maxLeadsProStadt}
|
||||
onChange={(e) => set('maxLeadsProStadt', e.target.value)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Min. Google-Bewertung">
|
||||
<input
|
||||
className="form-control"
|
||||
type="number"
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={5}
|
||||
value={form.minBewertung}
|
||||
onChange={(e) => set('minBewertung', e.target.value)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Städte / Regionen"
|
||||
hint={form.ganzDeutschland ? 'Ganz Deutschland aktiv — die Routine wählt selbst passende Regionen.' : 'Die Routine recherchiert in diesen Regionen.'}
|
||||
>
|
||||
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
|
||||
{form.staedte.map((s) => (
|
||||
<Badge key={s} tone={form.ganzDeutschland ? 'muted' : 'info'}>
|
||||
{s}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set('staedte', form.staedte.filter((x) => x !== s))}
|
||||
style={{ background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', padding: '0 0 0 4px', display: 'inline-flex' }}
|
||||
title={`${s} entfernen`}
|
||||
>
|
||||
<FaXmark />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Stadt hinzufügen..."
|
||||
value={newStadt}
|
||||
onChange={(e) => setNewStadt(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addStadt() } }}
|
||||
disabled={form.ganzDeutschland}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Button size="sm" type="button" onClick={addStadt} disabled={form.ganzDeutschland}>
|
||||
<FaPlus />
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex gap-3 wrap" style={{ margin: '10px 0' }}>
|
||||
<label className="flex gap-2" style={{ alignItems: 'center', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.ganzDeutschland}
|
||||
onChange={(e) => set('ganzDeutschland', e.target.checked)}
|
||||
/>
|
||||
Komplett Deutschland
|
||||
</label>
|
||||
<label className="flex gap-2" style={{ alignItems: 'center', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.emailPflicht}
|
||||
onChange={(e) => set('emailPflicht', e.target.checked)}
|
||||
/>
|
||||
Nur Leads mit auffindbarer E-Mail
|
||||
</label>
|
||||
<label className="flex gap-2" style={{ alignItems: 'center', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.nurSchlechteWebsite}
|
||||
onChange={(e) => set('nurSchlechteWebsite', e.target.checked)}
|
||||
/>
|
||||
Nur ohne / mit veralteter Website
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Field label="Zusätzliche Hinweise für die Recherche" hint="Freitext, wird der Routine wörtlich mitgegeben.">
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={3}
|
||||
value={form.zusatzHinweise}
|
||||
onChange={(e) => set('zusatzHinweise', e.target.value)}
|
||||
placeholder="z. B. Familienbetriebe bevorzugen, keine Franchise-Ketten..."
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{msg && <div className={msg.startsWith('Fehler') ? 'text-red' : 'muted'} style={{ marginBottom: 8, fontSize: 13 }}>{msg}</div>}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" onClick={save} disabled={busy}>
|
||||
{busy ? <FaSpinner className="spinner" /> : <><FaFloppyDisk /> Speichern</>}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user