- Vorschau-Button (Auge, primary) je Lead -> previewUrl der deployten Website - previewUrl live gejoint (Lead->Kunde per E-Mail->websiteProjects status deployed/ready); Preview haengt an anderem Repo als lead.repoUrl -> Kunden-Join - attachWoids -> attachLeadLinks (haengt jetzt woid + previewUrl an) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
283 lines
9.1 KiB
JavaScript
283 lines
9.1 KiB
JavaScript
import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
import { COLLECTIONS, DATABASE_ID, Query, databases } from '../lib/appwrite'
|
|
|
|
const PAGE = 100
|
|
|
|
async function listAll(collection, queries = []) {
|
|
const out = []
|
|
let offset = 0
|
|
for (;;) {
|
|
const res = await databases.listDocuments(DATABASE_ID, collection, [
|
|
...queries,
|
|
Query.limit(PAGE),
|
|
Query.offset(offset),
|
|
])
|
|
out.push(...(res.documents || []))
|
|
if ((res.documents || []).length < PAGE) break
|
|
offset += PAGE
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Haengt an jeden Lead Verweise, die nicht am Lead selbst gespeichert sind,
|
|
* sondern ueber den Kunden verknuepft werden — via Lead -> Kunde (per E-Mail):
|
|
* - woid: WOID des Akquise-Tickets (workorders type=Akquise)
|
|
* - previewUrl: URL der erstellten, deployten Website (websiteProjects)
|
|
* Best effort; Fehler brechen die Lead-Liste nicht. */
|
|
async function attachLeadLinks(leads) {
|
|
const norm = (l) => (l.email || l.portalLogin || '').trim().toLowerCase()
|
|
const emails = [...new Set(leads.map(norm).filter(Boolean))]
|
|
if (!emails.length) return leads
|
|
|
|
// 1) Kunden per E-Mail -> customerId
|
|
const emailToCustomer = {}
|
|
const customerIds = []
|
|
for (let i = 0; i < emails.length; i += 90) {
|
|
const docs = await listAll(COLLECTIONS.CUSTOMERS, [Query.equal('email', emails.slice(i, i + 90))])
|
|
for (const c of docs) {
|
|
const e = (c.email || '').trim().toLowerCase()
|
|
if (e && !emailToCustomer[e]) {
|
|
emailToCustomer[e] = c.$id
|
|
customerIds.push(c.$id)
|
|
}
|
|
}
|
|
}
|
|
if (!customerIds.length) return leads
|
|
|
|
// 2) Akquise-Tickets per customerId -> kleinste (urspruengliche) WOID je Kunde
|
|
const customerToWoid = {}
|
|
for (let i = 0; i < customerIds.length; i += 90) {
|
|
const docs = await listAll(COLLECTIONS.WORKORDERS, [
|
|
Query.equal('customerId', customerIds.slice(i, i + 90)),
|
|
Query.equal('type', ['Akquise']),
|
|
])
|
|
for (const w of docs) {
|
|
const n = parseInt(w.woid, 10)
|
|
if (!w.customerId || isNaN(n)) continue
|
|
const cur = customerToWoid[w.customerId]
|
|
if (!cur || n < cur.n) customerToWoid[w.customerId] = { n, woid: w.woid, ticketId: w.$id }
|
|
}
|
|
}
|
|
|
|
// 3) deployte Preview je Kunde -> URL der erstellten Website.
|
|
// Die websiteProjects-Collection ist klein -> komplett laden + lokal zuordnen
|
|
// (die Preview haengt am selben Kunden, aber an einem anderen Repo als lead.repoUrl).
|
|
const customerToPreview = {}
|
|
const idSet = new Set(customerIds)
|
|
for (const p of await listAll(COLLECTIONS.WEBSITE_PROJECTS)) {
|
|
if (!idSet.has(p.customerId) || customerToPreview[p.customerId]) continue
|
|
const ready = p.status === 'deployed' || p.status === 'ready' || p.provisioningStatus === 'ready'
|
|
const url = p.previewUrl || (p.subdomain ? `https://${p.subdomain}.project.webklar.com` : '')
|
|
if (ready && url) customerToPreview[p.customerId] = url
|
|
}
|
|
|
|
// 4) an die Leads haengen
|
|
return leads.map((l) => {
|
|
const cid = emailToCustomer[norm(l)]
|
|
const t = customerToWoid[cid]
|
|
const previewUrl = customerToPreview[cid]
|
|
if (!t && !previewUrl) return l
|
|
return {
|
|
...l,
|
|
...(t ? { woid: t.woid, ticketId: t.ticketId } : {}),
|
|
...(previewUrl ? { previewUrl } : {}),
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Recherche-Leads aus der taeglichen 06:00-Routine (Appwrite `leads`).
|
|
* Pollt automatisch nach — schneller, solange die 5-Minuten-Pipeline
|
|
* (processor.py auf dem Server) gerade Leads verarbeitet. */
|
|
export function useLeads() {
|
|
const [leads, setLeads] = useState([])
|
|
const [pipelines, setPipelines] = useState({})
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState(null)
|
|
|
|
const fetchLeads = useCallback(async () => {
|
|
try {
|
|
const out = await listAll(COLLECTIONS.LEADS, [Query.orderDesc('leadScore')])
|
|
let enriched = out
|
|
try { enriched = await attachLeadLinks(out) } catch { /* Live-Join best effort */ }
|
|
setLeads(enriched)
|
|
setError(null)
|
|
} catch (err) {
|
|
if (err.code === 404 || err.message?.includes('not found')) {
|
|
setLeads([])
|
|
setError(null)
|
|
} else {
|
|
setError(err.message)
|
|
setLeads([])
|
|
}
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
// Schritt-Log + E-Mail-Entwurf pro Lead (Zusatz-Collection, best effort)
|
|
try {
|
|
const docs = await listAll(COLLECTIONS.LEAD_PIPELINE)
|
|
const map = {}
|
|
for (const d of docs) if (d.leadId) map[d.leadId] = d
|
|
setPipelines(map)
|
|
} catch {
|
|
setPipelines({})
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetchLeads()
|
|
}, [fetchLeads])
|
|
|
|
// Live-Polling: 5s waehrend die Pipeline arbeitet, sonst alle 20s
|
|
const pipelineActive = useMemo(
|
|
() => leads.some((l) => l.pipelineStatus === 'running' || l.pipelineStatus === 'pending'),
|
|
[leads]
|
|
)
|
|
useEffect(() => {
|
|
const t = setInterval(fetchLeads, pipelineActive ? 5000 : 20000)
|
|
return () => clearInterval(t)
|
|
}, [fetchLeads, pipelineActive])
|
|
|
|
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, pipelines, loading, error, refresh: fetchLeads, updateLead, deleteLead }
|
|
}
|
|
|
|
/** 5-Minuten-Zyklus des Server-Processors (leadSettings: lastRunAt/nextRunAt/
|
|
* workerBusy) + manueller Sofort-Trigger (runNow=true, der Processor pollt
|
|
* das Flag alle 5 Sekunden). */
|
|
export function useLeadCycle() {
|
|
const [cycle, setCycle] = useState(null)
|
|
const [triggering, setTriggering] = useState(false)
|
|
|
|
const fetchCycle = useCallback(async () => {
|
|
try {
|
|
const doc = await databases.getDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main')
|
|
setCycle({
|
|
lastRunAt: doc.lastRunAt || null,
|
|
nextRunAt: doc.nextRunAt || null,
|
|
runNow: Boolean(doc.runNow),
|
|
workerBusy: Boolean(doc.workerBusy),
|
|
})
|
|
} catch {
|
|
setCycle(null)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetchCycle()
|
|
const t = setInterval(fetchCycle, 10000)
|
|
return () => clearInterval(t)
|
|
}, [fetchCycle])
|
|
|
|
const triggerNow = async () => {
|
|
setTriggering(true)
|
|
try {
|
|
await databases.updateDocument(DATABASE_ID, COLLECTIONS.LEAD_SETTINGS, 'main', {
|
|
runNow: true,
|
|
updatedAt: new Date().toISOString(),
|
|
})
|
|
await fetchCycle()
|
|
return { success: true }
|
|
} catch (err) {
|
|
return { success: false, error: err.message }
|
|
} finally {
|
|
setTriggering(false)
|
|
}
|
|
}
|
|
|
|
return { cycle, triggering, triggerNow, refresh: fetchCycle }
|
|
}
|
|
|
|
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 }
|
|
}
|