diff --git a/src/App.jsx b/src/App.jsx index d8be347..e5760af 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 310a0e5..a6308fc 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -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: }], + links: [ + { label: 'Kunden', href: '/customers', icon: }, + { label: 'Leads', href: '/leads', icon: }, + ], }, { label: 'Finanzen', diff --git a/src/hooks/useLeads.js b/src/hooks/useLeads.js new file mode 100644 index 0000000..ce819f8 --- /dev/null +++ b/src/hooks/useLeads.js @@ -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 } +} diff --git a/src/lib/appwrite.js b/src/lib/appwrite.js index 5174d89..df3534a 100644 --- a/src/lib/appwrite.js +++ b/src/lib/appwrite.js @@ -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' diff --git a/src/pages/LeadsPage.jsx b/src/pages/LeadsPage.jsx new file mode 100644 index 0000000..92f6f9b --- /dev/null +++ b/src/pages/LeadsPage.jsx @@ -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 ( +
+ setShowSettings((s) => !s)}> + Sucheinstellungen + + } + /> + + {showSettings && ( +
+ setShowSettings(false)} /> +
+ )} + +
+ + + + + +
+ + +
+
+ + {STATUS_OPTIONS.map((s) => ( + + ))} +
+ setSearch(e.target.value)} + style={{ maxWidth: 320 }} + /> +
+
+ {loading ? ( +
+ ) : error ? ( + + ) : filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map((lead) => ( + + ))} +
+ )} +
+
+
+ ) +} + +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 ( +
+
+
+
+ {lead.firma} + {lead.kategorie && Kat. {lead.kategorie}} +
+
+ {[lead.ort, lead.branche].filter(Boolean).join(' · ')} +
+
+
+
{score}
+
Score
+
+
+ +
+
+
+ +
+ {meta.label} + {lead.googleNote && ( + + {lead.googleNote} ({lead.anzahlBewertungen || 0}) + + )} + {lead.processed && Kunde angelegt} +
+ + {lead.websiteStatus && ( +
+ Website: {lead.websiteStatus.length > 120 && !open + ? lead.websiteStatus.slice(0, 120) + '…' + : lead.websiteStatus} +
+ )} + +
+ {lead.email && ( + + {lead.email} + + )} + {lead.telefon && ( + + {lead.telefon} + + )} +
+ +
+ {lead.googleMapsLink && ( + + )} + {websiteUrl && ( + + )} + {lead.repoUrl && ( + + )} + + +
+ + {open && ( +
+ {lead.ansprechpartner &&
Ansprechpartner: {lead.ansprechpartner}
} + {lead.adresse &&
Adresse: {lead.adresse}
} + {lead.kernleistungen &&
Leistungen: {lead.kernleistungen}
} + {lead.hauptmaengel &&
Hauptmängel: {lead.hauptmaengel}
} + {lead.sympathie &&
Eindruck: {lead.sympathie}
} + {(lead.portalLogin || lead.portalPasswort) && ( +
+ {lead.portalLogin && } + {lead.portalPasswort && } +
+ )} + +