feat: Leads-Aktionsbuttons icon-only + WOID des Akquise-Tickets
- Maps/Website/Repo nur noch Icon (title/aria-label bleiben) - WOID je Lead als klickbarer Chip -> /tickets?woid= (Lead->Kunde->Akquise-Ticket, live in useLeads gejoint) - useWorkorders: woid server-seitig filterbar (macht den bisher wirkungslosen WOID-Filter nutzbar) - TicketsPage: ?woid= Deep-Link liest Param, filtert ueber alle Status Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,51 @@ async function listAll(collection, queries = []) {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Haengt an jeden Lead die WOID seines Akquise-Tickets:
|
||||||
|
* Lead -> Kunde (per E-Mail) -> Akquise-Workorder -> woid. Best effort;
|
||||||
|
* am Lead selbst ist keine WOID gespeichert, daher live gejoint. */
|
||||||
|
async function attachWoids(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) an die Leads haengen
|
||||||
|
return leads.map((l) => {
|
||||||
|
const hit = customerToWoid[emailToCustomer[norm(l)]]
|
||||||
|
return hit ? { ...l, woid: hit.woid, ticketId: hit.ticketId } : l
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Recherche-Leads aus der taeglichen 06:00-Routine (Appwrite `leads`).
|
/** Recherche-Leads aus der taeglichen 06:00-Routine (Appwrite `leads`).
|
||||||
* Pollt automatisch nach — schneller, solange die 5-Minuten-Pipeline
|
* Pollt automatisch nach — schneller, solange die 5-Minuten-Pipeline
|
||||||
* (processor.py auf dem Server) gerade Leads verarbeitet. */
|
* (processor.py auf dem Server) gerade Leads verarbeitet. */
|
||||||
@@ -31,7 +76,9 @@ export function useLeads() {
|
|||||||
const fetchLeads = useCallback(async () => {
|
const fetchLeads = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const out = await listAll(COLLECTIONS.LEADS, [Query.orderDesc('leadScore')])
|
const out = await listAll(COLLECTIONS.LEADS, [Query.orderDesc('leadScore')])
|
||||||
setLeads(out)
|
let enriched = out
|
||||||
|
try { enriched = await attachWoids(out) } catch { /* WOID-Join best effort */ }
|
||||||
|
setLeads(enriched)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 404 || err.message?.includes('not found')) {
|
if (err.code === 404 || err.message?.includes('not found')) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function buildFiltersKey(filters = {}) {
|
|||||||
status: filters.status ?? null,
|
status: filters.status ?? null,
|
||||||
type: filters.type ?? null,
|
type: filters.type ?? null,
|
||||||
priority: filters.priority ?? null,
|
priority: filters.priority ?? null,
|
||||||
|
woid: filters.woid ?? null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ export function useWorkorders(filters = {}) {
|
|||||||
filters.status,
|
filters.status,
|
||||||
filters.type,
|
filters.type,
|
||||||
filters.priority,
|
filters.priority,
|
||||||
|
filters.woid,
|
||||||
])
|
])
|
||||||
|
|
||||||
const fetchWorkorders = useCallback(async () => {
|
const fetchWorkorders = useCallback(async () => {
|
||||||
@@ -102,6 +104,9 @@ export function useWorkorders(filters = {}) {
|
|||||||
if (activeFilters.priority?.length > 0) {
|
if (activeFilters.priority?.length > 0) {
|
||||||
filtered = filtered.filter(wo => activeFilters.priority.includes(wo.priority))
|
filtered = filtered.filter(wo => activeFilters.priority.includes(wo.priority))
|
||||||
}
|
}
|
||||||
|
if (activeFilters.woid) {
|
||||||
|
filtered = filtered.filter(wo => String(wo.woid) === String(activeFilters.woid))
|
||||||
|
}
|
||||||
if (activeFilters.limit) {
|
if (activeFilters.limit) {
|
||||||
filtered = filtered.slice(0, activeFilters.limit)
|
filtered = filtered.slice(0, activeFilters.limit)
|
||||||
}
|
}
|
||||||
@@ -140,6 +145,11 @@ export function useWorkorders(filters = {}) {
|
|||||||
queries.push(Query.equal('assignedTo', activeFilters.assignedTo))
|
queries.push(Query.equal('assignedTo', activeFilters.assignedTo))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WOID-Direktsuche (z. B. Deep-Link aus den Leads: /tickets?woid=123)
|
||||||
|
if (activeFilters.woid) {
|
||||||
|
queries.push(Query.equal('woid', [String(activeFilters.woid)]))
|
||||||
|
}
|
||||||
|
|
||||||
// Debug: Zeige Collection ID
|
// Debug: Zeige Collection ID
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log('📋 Fetching workorders:')
|
console.log('📋 Fetching workorders:')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
FaSpinner,
|
FaSpinner,
|
||||||
FaStar,
|
FaStar,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
FaArrowsRotate,
|
FaArrowsRotate,
|
||||||
FaCheck,
|
FaCheck,
|
||||||
FaCopy,
|
FaCopy,
|
||||||
|
FaHashtag,
|
||||||
FaTriangleExclamation,
|
FaTriangleExclamation,
|
||||||
} from 'react-icons/fa6'
|
} from 'react-icons/fa6'
|
||||||
import { useLeads, useLeadSettings, useLeadCycle } from '../hooks/useLeads'
|
import { useLeads, useLeadSettings, useLeadCycle } from '../hooks/useLeads'
|
||||||
@@ -410,20 +412,32 @@ function LeadCard({ lead, pipeline, onUpdate }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 wrap">
|
<div className="flex gap-2 wrap" style={{ alignItems: 'center' }}>
|
||||||
{lead.googleMapsLink && (
|
{lead.googleMapsLink && (
|
||||||
<Button size="sm" as="a" href={lead.googleMapsLink} target="_blank" rel="noreferrer" title="Google Maps">
|
<Button size="sm" as="a" href={lead.googleMapsLink} target="_blank" rel="noreferrer" title="Google Maps" aria-label="Google Maps">
|
||||||
<FaMapLocationDot /> Maps
|
<FaMapLocationDot />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{websiteUrl && (
|
{websiteUrl && (
|
||||||
<Button size="sm" as="a" href={websiteUrl} target="_blank" rel="noreferrer" title="Website">
|
<Button size="sm" as="a" href={websiteUrl} target="_blank" rel="noreferrer" title="Bestehende Website" aria-label="Bestehende Website">
|
||||||
<FaGlobe /> Website
|
<FaGlobe />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{lead.repoUrl && (
|
{lead.repoUrl && (
|
||||||
<Button size="sm" as="a" href={lead.repoUrl} target="_blank" rel="noreferrer" title="Preview-Repo">
|
<Button size="sm" as="a" href={lead.repoUrl} target="_blank" rel="noreferrer" title="Preview-Repo (Gitea)" aria-label="Preview-Repo">
|
||||||
<FaGitAlt /> Repo
|
<FaGitAlt />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{lead.woid && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
as={Link}
|
||||||
|
to={`/tickets?woid=${lead.woid}`}
|
||||||
|
title={`Akquise-Ticket WOID ${lead.woid} öffnen`}
|
||||||
|
aria-label={`Ticket WOID ${lead.woid}`}
|
||||||
|
>
|
||||||
|
<FaHashtag />{lead.woid}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { FaAngleDown, FaSpinner, FaPlus, FaList, FaSliders } from 'react-icons/fa6'
|
import { FaAngleDown, FaSpinner, FaPlus, FaList, FaSliders } from 'react-icons/fa6'
|
||||||
import { useWorkorders } from '../hooks/useWorkorders'
|
import { useWorkorders } from '../hooks/useWorkorders'
|
||||||
import { useCustomers } from '../hooks/useCustomers'
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
@@ -35,6 +36,18 @@ export default function TicketsPage() {
|
|||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [showOverviewModal, setShowOverviewModal] = useState(false)
|
const [showOverviewModal, setShowOverviewModal] = useState(false)
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
|
// Deep-Link aus den Leads: /tickets?woid=123 -> Filter vorbelegen + anwenden
|
||||||
|
// (ueber ALLE Status, damit das Ticket unabhaengig vom Status gefunden wird)
|
||||||
|
useEffect(() => {
|
||||||
|
const w = searchParams.get('woid')
|
||||||
|
if (!w) return
|
||||||
|
setLocalFilters((p) => ({ ...p, woid: w }))
|
||||||
|
setShowAdvanced(true)
|
||||||
|
setStatusSel(ALL_STATUSES)
|
||||||
|
setFilters((prev) => ({ ...prev, status: ALL_STATUSES, woid: w }))
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
const applyFilters = () => {
|
const applyFilters = () => {
|
||||||
setFilters((prev) => ({
|
setFilters((prev) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user