Compare commits

...
4 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 50d237a932 Projekte im Ticket als eine Liste (Paket E)
- Im Details-Reiter steht eine Projektliste mit Zuweisen, Lösen und
  "Neues Projekt"; der Knopf "Projekte zuweisen / bearbeiten", der
  Dialog und die zweite Projektliste sind weg.
- Zuweisen bietet nur passende Projekte an: gleicher Kunde oder ohne
  Kunde, nicht archiviert, keine Vorlage.
- Lösen nimmt ein Projekt nur noch vom Ticket. Bisher wurde dabei auch
  die Kundenzuordnung gelöscht und der Kunde sah seine Vorschau im
  Portal nicht mehr.
- Projektkarten ohne README-Vorschau (kostete je Karte einen Abruf) und
  ohne nie befüllte Felder (Live-Domain, Commit-Box, "Preview: aktiv");
  react-markdown entfällt damit (Lockfile mit Node 20 erzeugt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:19:10 +00:00
ClaudeandClaude Opus 5.5 92fdfdc961 Leads ohne eingefrorene Status (Paket E)
Die Status-Reiter, die Zahlen "Neu"/"Gewonnen" und das Status-Abzeichen
lasen crmStatus, das seit dem 16.07. niemand mehr pflegt (gearbeitet
wird im Akquise-Ticket). Suche, Sucheinstellungen, Pipeline und
Lead-Karten bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:19:10 +00:00
ClaudeandClaude Opus 5.5 5a066eb3c4 Kunden an einer Stelle pflegen (Paket E)
- Kundenliste startet bei "Feste Kunden" (105 von 110 Einträgen sind
  Leads der Lead-Automatik); Reihenfolge Feste Kunden, Leads, Alle, Archiv.
- Der zweite Kunden-Editor im Admin ist weg. Admins legen feste Kunden
  mit Portal-Zugang jetzt unter Kunden an ("Neuer Kunde") und löschen sie
  auf der Kundenseite ("Kunde löschen", mit Rückfrage); beides wie bisher
  über die Admin-API des Kundenportals. Die Kundenseite hat das Feld
  Standort, das es bisher nur im Admin gab.
- Die Kundenliste liest direkt aus Appwrite: die Admin-API lieferte
  Admins gekürzte Daten, Stern und InvoiceNinja-Abzeichen fehlten dort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:19:10 +00:00
ClaudeandClaude Opus 5.5 31144148d1 Menü als flache Liste (Paket E)
Acht Einträge ohne Gruppenüberschriften: Tickets, Planboard, Roadmaps,
Projekte, Kunden, Leads, Finanzen und für Admins Admin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:19:10 +00:00
18 changed files with 273 additions and 1834 deletions

No files matched your search

+5 -1199
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -9,7 +9,6 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.12.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.20.0"
},
"devDependencies": {
+11 -40
View File
@@ -1,6 +1,5 @@
import { FaTrash, FaCodeBranch } from 'react-icons/fa'
import { FaGlobe, FaCodeCommit, FaClock } from 'react-icons/fa6'
import ProjectReadme from './ProjectReadme'
import { FaCodeBranch } from 'react-icons/fa'
import { FaLinkSlash } from 'react-icons/fa6'
import GiteaLinkButton from './GiteaLinkButton'
import PreviewLinkButton from './PreviewLinkButton'
@@ -30,7 +29,6 @@ function StatusBadge({ label, value }) {
}
export default function AssignedProjectCard({ project, onUnassign, showActions = false }) {
const lastPush = project.lastPushAt || project.giteaSyncedAt
return (
<div style={{ background: 'rgba(30,41,59,0.5)', border: '1px solid rgba(59,130,246,0.25)', borderRadius: 10, padding: '12px 14px', marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', justifyContent: 'space-between' }}>
@@ -42,20 +40,20 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
<FaCodeBranch style={{ marginRight: 4 }} />{project.repoFullName}
</div>
)}
{project.liveDomain && (
<div style={{ fontSize: 12, marginTop: 4 }}>
<FaGlobe style={{ marginRight: 4, color: '#34d399' }} />
<a href={`https://${project.liveDomain}`} target="_blank" rel="noopener noreferrer" style={{ color: '#34d399', textDecoration: 'none' }}>
{project.liveDomain}
</a>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
<PreviewLinkButton href={project.previewUrl} />
<GiteaLinkButton href={project.giteaRepoUrl} />
{showActions && onUnassign && (
<button type="button" className="btn btn-sm" onClick={() => onUnassign(project.$id)}><FaTrash /></button>
<button
type="button"
title="Vom Ticket lösen"
aria-label="Vom Ticket lösen"
onClick={() => onUnassign(project.$id)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 6, background: 'rgba(239, 68, 68, 0.12)', border: '1px solid rgba(239, 68, 68, 0.35)', color: '#f87171', cursor: 'pointer' }}
>
<FaLinkSlash size={16} />
</button>
)}
</div>
</div>
@@ -64,34 +62,7 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', alignItems: 'center' }}>
<StatusBadge label="Status" value={project.status} />
<StatusBadge label="Provisioning" value={project.provisioningStatus} />
{project.hasPreview && <StatusBadge label="Preview" value="aktiv" />}
</div>
{/* Letzter Commit */}
{(project.lastCommitMessage || project.lastCommitSha) && (
<div style={{ marginTop: 8, fontSize: 12, color: '#94a3b8', background: 'rgba(0,0,0,0.2)', borderRadius: 8, padding: '8px 10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FaCodeCommit style={{ color: '#3b82f6' }} />
{project.lastCommitSha && <code style={{ color: '#cbd5e0' }}>{String(project.lastCommitSha).slice(0, 7)}</code>}
{project.lastCommitBranch || project.lastPushBranch ? (
<span className="text-grey">({project.lastPushBranch || project.lastCommitBranch})</span>
) : null}
</div>
{project.lastCommitMessage && (
<div style={{ marginTop: 4, color: '#cbd5e0', whiteSpace: 'pre-wrap' }}>{project.lastCommitMessage}</div>
)}
<div style={{ marginTop: 4, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{project.lastCommitAuthor && <span>von {project.lastCommitAuthor}</span>}
{lastPush && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<FaClock /> {new Date(lastPush).toLocaleString('de-DE')}
</span>
)}
</div>
</div>
)}
{project.repoFullName && <ProjectReadme repoFullName={project.repoFullName} />}
</div>
)
}
+23 -44
View File
@@ -26,33 +26,16 @@ export default function Sidebar() {
navigate('/login')
}
const groups = [
{
label: 'Betrieb',
links: [
{ label: 'Tickets', href: '/tickets', icon: <IconTicket /> },
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
{ label: 'Roadmaps', href: '/roadmaps', icon: <IconRoute /> },
{ label: 'Projekte', href: '/projects', icon: <IconFolders /> },
],
},
{
label: 'CRM',
links: [
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow /> },
],
},
{
label: 'Finanzen',
links: [{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> }],
},
{
label: 'System',
links: [
...(isAdmin ? [{ label: 'Admin', href: '/admin', icon: <IconSettings /> }] : []),
],
},
// Eine flache Liste: fuer ein kleines Team reichen acht Eintraege ohne Gruppen
const links = [
{ label: 'Tickets', href: '/tickets', icon: <IconTicket /> },
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
{ label: 'Roadmaps', href: '/roadmaps', icon: <IconRoute /> },
{ label: 'Projekte', href: '/projects', icon: <IconFolders /> },
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow /> },
{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> },
...(isAdmin ? [{ label: 'Admin', href: '/admin', icon: <IconSettings /> }] : []),
]
const isActive = (href) =>
@@ -69,23 +52,19 @@ export default function Sidebar() {
</div>
<nav className="side-nav">
{/* Gruppen ohne Eintrag (System fuer Nicht-Admins) nicht anzeigen */}
{groups.filter((group) => group.links.length > 0).map((group) => (
<div className="side-group" key={group.label}>
<div className="side-group-label">{group.label}</div>
{group.links.map((link) => (
<Link
key={link.href}
to={link.href}
className={`side-link ${isActive(link.href) ? 'active' : ''}`}
title={link.label}
>
{link.icon}
<span className="side-link-label">{link.label}</span>
</Link>
))}
</div>
))}
<div className="side-group">
{links.map((link) => (
<Link
key={link.href}
to={link.href}
className={`side-link ${isActive(link.href) ? 'active' : ''}`}
title={link.label}
>
{link.icon}
<span className="side-link-label">{link.label}</span>
</Link>
))}
</div>
</nav>
<div className="side-foot">
-50
View File
@@ -1,50 +0,0 @@
import { useState, useEffect } from 'react'
import { FaFileLines } from 'react-icons/fa6'
import ReactMarkdown from 'react-markdown'
import { fetchProjectReadme } from '../lib/projectAdminApi'
const markdownComponents = {
h1: ({ children }) => <h1 style={{ fontSize: '1.35rem', fontWeight: 700, margin: '0 0 10px', color: '#f8fafc' }}>{children}</h1>,
h2: ({ children }) => <h2 style={{ fontSize: '1.15rem', fontWeight: 700, margin: '14px 0 8px', color: '#f1f5f9' }}>{children}</h2>,
h3: ({ children }) => <h3 style={{ fontSize: '1rem', fontWeight: 600, margin: '12px 0 6px', color: '#e2e8f0' }}>{children}</h3>,
p: ({ children }) => <p style={{ margin: '0 0 10px', lineHeight: 1.6 }}>{children}</p>,
ul: ({ children }) => <ul style={{ margin: '0 0 10px', paddingLeft: 20 }}>{children}</ul>,
ol: ({ children }) => <ol style={{ margin: '0 0 10px', paddingLeft: 20 }}>{children}</ol>,
code: ({ inline, children }) => inline ? (
<code style={{ background: 'rgba(30,41,59,0.9)', padding: '2px 6px', borderRadius: 4, fontSize: '0.9em' }}>{children}</code>
) : <code>{children}</code>,
pre: ({ children }) => <pre style={{ background: 'rgba(15,23,42,0.95)', borderRadius: 6, padding: 10, overflowX: 'auto', margin: '0 0 10px' }}>{children}</pre>,
a: ({ href, children }) => <a href={href} target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>{children}</a>,
}
export default function ProjectReadme({ repoFullName }) {
const [state, setState] = useState({ loading: true, content: '', filename: '', error: '' })
useEffect(() => {
if (!repoFullName) { setState({ loading: false, content: '', filename: '', error: '' }); return }
let cancelled = false
setState({ loading: true, content: '', filename: '', error: '' })
fetchProjectReadme(repoFullName)
.then((data) => {
if (cancelled) return
if (!data.found) { setState({ loading: false, content: '', filename: '', error: '' }); return }
setState({ loading: false, content: data.content || '', filename: data.filename || 'README.md', error: '' })
})
.catch((err) => { if (!cancelled) setState({ loading: false, content: '', filename: '', error: err.message }) })
return () => { cancelled = true }
}, [repoFullName])
if (state.loading) return <p className="text-grey" style={{ fontSize: 13, marginTop: 8 }}>README wird geladen...</p>
if (state.error || !state.content) return null
return (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, fontSize: 13, color: '#94a3b8' }}>
<FaFileLines /><span>{state.filename}</span>
</div>
<div style={{ background: 'rgba(15,23,42,0.6)', border: '1px solid rgba(148,163,184,0.2)', borderRadius: 8, padding: '12px 14px', fontSize: 13, color: '#e2e8f0', maxHeight: 320, overflowY: 'auto' }}>
<ReactMarkdown components={markdownComponents}>{state.content}</ReactMarkdown>
</div>
</div>
)
}
@@ -1,34 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import AssignedProjectCard from './AssignedProjectCard'
export default function TicketAssignedProjectsPanel({ ticket, refreshKey = 0 }) {
const { fetchByTicketId } = useWebsiteProjects()
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(true)
const loadProjects = useCallback(async () => {
if (!ticket?.$id) return
setLoading(true)
try {
setProjects(await fetchByTicketId(ticket.$id))
} catch {
setProjects([])
} finally {
setLoading(false)
}
}, [ticket?.$id, fetchByTicketId])
useEffect(() => { loadProjects() }, [loadProjects, refreshKey])
if (loading || projects.length === 0) return null
return (
<div style={{ background: 'rgba(45,55,72,0.5)', borderRadius: 12, padding: 20, border: '1px solid rgba(59,130,246,0.3)', marginTop: 20 }}>
<h5 style={{ fontWeight: 'bold', marginBottom: 12 }}>Zugewiesene Projekte ({projects.length})</h5>
{projects.map((project) => (
<AssignedProjectCard key={project.$id} project={project} />
))}
</div>
)
}
+3 -10
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { FaChevronDown, FaPlay, FaStop, FaPlus, FaPen } from 'react-icons/fa6'
import { FaChevronDown, FaPlay, FaStop, FaPlus } from 'react-icons/fa6'
import { formatDistanceToNow, format } from 'date-fns'
import { de } from 'date-fns/locale'
import StatusDropdown from './StatusDropdown'
@@ -9,8 +9,7 @@ import CreateWorksheetModal from './CreateWorksheetModal'
import WorksheetList from './WorksheetList'
import WorksheetTimesModal from './WorksheetTimesModal'
import WorksheetSummary from './WorksheetSummary'
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
import TicketProjectsModal from './TicketProjectsModal'
import TicketProjects from './TicketProjects'
import TicketRoadmapPanel from './TicketRoadmapPanel'
import ProjectContextPanel from './ProjectContextPanel'
import InvoicePanel from './InvoicePanel'
@@ -36,8 +35,6 @@ export default function TicketCard({ ticket, onUpdate }) {
const [activeTab, setActiveTab] = useState('details')
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
const [editTimesWorksheet, setEditTimesWorksheet] = useState(null)
const [showProjectsModal, setShowProjectsModal] = useState(false)
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
const { worksheets, loading: worksheetsLoading, createWorksheet, updateWorksheet, updateWorksheetTimes, getTotalTime } = useWorksheets(
expanded ? ticket.woid : null
@@ -125,10 +122,7 @@ export default function TicketCard({ ticket, onUpdate }) {
</div>
<ProjectContextPanel ticket={ticket} />
<TicketRoadmapPanel ticket={ticket} />
<button className="ui-btn ui-btn-primary" style={{ marginBottom: 12 }} onClick={() => setShowProjectsModal(true)}>
<FaPen /> Projekte zuweisen / bearbeiten
</button>
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
<TicketProjects ticket={ticket} />
</div>
)}
@@ -164,7 +158,6 @@ export default function TicketCard({ ticket, onUpdate }) {
onSave={(ws, times) => updateWorksheetTimes(ws, times, user, employee)}
/>
)}
<TicketProjectsModal isOpen={showProjectsModal} onClose={() => setShowProjectsModal(false)} ticket={ticket} onUpdated={() => setProjectsRefreshKey((k) => k + 1)} />
</div>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { useState, useEffect, useCallback } from 'react'
import { FaPlus, FaLink } from 'react-icons/fa6'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { createProjectFromTemplate } from '../lib/projectAdminApi'
import AssignedProjectCard from './AssignedProjectCard'
function slugify(v) {
return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100)
}
// Zuordnen lassen sich echte Repos des gleichen Kunden oder ohne Kunden, die nicht
// archiviert, keine Vorlage und noch nicht an diesem Ticket sind
function assignableProjects(all, ticket, assignedIds) {
return (all || []).filter((p) =>
p.repoFullName &&
!assignedIds.has(p.$id) &&
p.status !== 'archived' &&
!p.isTemplate &&
(!p.customerId || p.customerId === ticket.customerId)
)
}
// Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle.
// Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt.
export default function TicketProjects({ ticket }) {
const { fetchAllProjects, fetchByTicketId, assignProjects, unassignProject } = useWebsiteProjects()
const [assigned, setAssigned] = useState([])
const [available, setAvailable] = useState([])
const [selected, setSelected] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [createForm, setCreateForm] = useState({ displayName: '', subdomain: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const load = useCallback(async () => {
if (!ticket?.$id) return
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticket.$id)])
setAssigned(mine)
setAvailable(assignableProjects(all, ticket, new Set(mine.map((p) => p.$id))))
}, [ticket, fetchAllProjects, fetchByTicketId])
useEffect(() => { load() }, [load])
const run = async (action) => {
setBusy(true)
setError('')
try {
await action()
await load()
} catch (err) {
setError(err.message || 'Aktion fehlgeschlagen')
} finally {
setBusy(false)
}
}
const assign = () => run(async () => {
const r = await assignProjects([selected], { customerId: ticket.customerId || '', ticketId: ticket.$id })
if (!r.success) throw new Error(r.error)
setSelected('')
})
const unassign = (projectId) => run(async () => {
const r = await unassignProject(projectId)
if (!r.success) throw new Error(r.error)
})
const create = (e) => {
e.preventDefault()
run(async () => {
const subdomain = slugify(createForm.subdomain)
await createProjectFromTemplate({
repoName: subdomain,
displayName: createForm.displayName,
subdomain,
customerId: ticket.customerId || '',
ticketId: ticket.$id,
})
setCreateForm({ displayName: '', subdomain: '' })
setShowCreate(false)
})
}
return (
<div className="ui-card pad ticket-projects">
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Projekte ({assigned.length})</h5>
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
{assigned.length === 0
? <p className="muted" style={{ marginTop: 0 }}>Noch kein Projekt an diesem Ticket.</p>
: assigned.map((p) => <AssignedProjectCard key={p.$id} project={p} showActions onUnassign={unassign} />)}
<div className="ticket-projects-assign">
<select className="form-control" aria-label="Projekt zuweisen" value={selected} onChange={(e) => setSelected(e.target.value)}>
<option value="">Projekt wählen …</option>
{available.map((p) => (
<option key={p.$id} value={p.$id}>{p.projectName} ({p.subdomain || p.repoFullName})</option>
))}
</select>
<button type="button" className="ui-btn" disabled={!selected || busy} onClick={assign}><FaLink /> Zuweisen</button>
<button type="button" className="ui-btn ui-btn-ghost" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neues Projekt</button>
</div>
{showCreate && (
<form className="ticket-projects-assign" onSubmit={create} style={{ marginTop: 8 }}>
<input className="form-control" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
<input className="form-control" placeholder="Subdomain" value={createForm.subdomain} onChange={(e) => setCreateForm((f) => ({ ...f, subdomain: e.target.value }))} required />
<button type="submit" className="ui-btn ui-btn-primary" disabled={busy}>{busy ? 'Wird angelegt …' : 'Anlegen & zuweisen'}</button>
</form>
)}
</div>
)
}
-125
View File
@@ -1,125 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { FaPlus, FaTimes } from 'react-icons/fa'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { createProjectFromTemplate } from '../lib/projectAdminApi'
import AssignedProjectCard from './AssignedProjectCard'
function slugify(v) {
return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100)
}
export default function TicketProjectsModal({ isOpen, onClose, ticket, onUpdated }) {
const { fetchAllProjects, fetchByTicketId, assignProjects, unassignProject } = useWebsiteProjects()
const [assigned, setAssigned] = useState([])
const [available, setAvailable] = useState([])
const [selectedIds, setSelectedIds] = useState([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState(false)
const [error, setError] = useState('')
const [createForm, setCreateForm] = useState({ displayName: '', subdomain: '', repoName: '' })
const loadProjects = useCallback(async () => {
if (!ticket?.$id) return
setLoading(true)
setError('')
try {
const [all, mine] = await Promise.all([fetchAllProjects(), fetchByTicketId(ticket.$id)])
setAssigned(mine)
const assignedIds = new Set(mine.map((p) => p.$id))
setAvailable((all || []).filter((p) => p.repoFullName && !assignedIds.has(p.$id)))
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [ticket, fetchAllProjects, fetchByTicketId])
useEffect(() => { if (isOpen) loadProjects() }, [isOpen, loadProjects])
if (!isOpen) return null
const handleAssign = async () => {
if (!selectedIds.length) return
setActionLoading(true)
const r = await assignProjects(selectedIds, { customerId: ticket.customerId || '', ticketId: ticket.$id })
setActionLoading(false)
if (r.success) { setSelectedIds([]); await loadProjects(); onUpdated?.() } else setError(r.error)
}
const handleCreate = async (e) => {
e.preventDefault()
setActionLoading(true)
setError('')
try {
await createProjectFromTemplate({
repoName: createForm.repoName || createForm.subdomain,
displayName: createForm.displayName,
subdomain: slugify(createForm.subdomain),
customerId: ticket.customerId || '',
ticketId: ticket.$id,
})
setCreateForm({ displayName: '', subdomain: '', repoName: '' })
await loadProjects()
onUpdated?.()
} catch (err) {
setError(err.message)
} finally {
setActionLoading(false)
}
}
const handleUnassign = async (projectId) => {
setActionLoading(true)
await unassignProject(projectId)
setActionLoading(false)
await loadProjects()
onUpdated?.()
}
return (
<div className="overlay ticket-projects-overlay">
<span className="overlay-close" onClick={onClose}>{'\u00d7'}</span>
<div className="overlay-content ticket-projects-modal">
<div className="ticket-projects-modal-header">
<h2>Projekte bearbeiten</h2>
<button type="button" className="btn btn-sm" onClick={onClose} aria-label="Schliessen"><FaTimes /></button>
</div>
{error && <div className="bg-red text-white p-2 mb-2">{error}</div>}
{loading ? <p className="text-grey">Projekte werden geladen...</p> : (
<div className="ticket-projects-modal-grid">
<section className="ticket-projects-modal-section">
<h6>Zugewiesen</h6>
{assigned.length === 0 ? <p className="text-grey">Keine Projekte zugewiesen.</p> : assigned.map((p) => (
<AssignedProjectCard key={p.$id} project={p} showActions onUnassign={handleUnassign} />
))}
</section>
<div className="ticket-projects-modal-side">
<section className="ticket-projects-modal-section">
<h6>Verfuegbar zuweisen</h6>
{available.length === 0 ? <p className="text-grey">Keine weiteren Projekte verfuegbar.</p> : (
<div className="ticket-projects-available-list">
{available.map((p) => (
<label key={p.$id} className="ticket-projects-available-item">
<input type="checkbox" checked={selectedIds.includes(p.$id)} onChange={() => setSelectedIds((s) => s.includes(p.$id) ? s.filter((x) => x !== p.$id) : [...s, p.$id])} />
<span>{p.projectName} ({p.subdomain || p.repoFullName})</span>
</label>
))}
</div>
)}
<button type="button" className="btn btn-teal mt-2" disabled={!selectedIds.length || actionLoading} onClick={handleAssign}>Zuweisen</button>
</section>
<section className="ticket-projects-modal-section">
<h6><FaPlus /> Neues Projekt</h6>
<form onSubmit={handleCreate} className="ticket-projects-create-form">
<input className="form-control" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
<input className="form-control" placeholder="Subdomain" value={createForm.subdomain} onChange={(e) => setCreateForm((f) => ({ ...f, subdomain: e.target.value, repoName: f.repoName || slugify(e.target.value) }))} required />
<button type="submit" className="btn btn-dark" disabled={actionLoading}>{actionLoading ? 'Wird angelegt...' : 'Anlegen & zuweisen'}</button>
</form>
</section>
</div>
</div>
)}
</div>
</div>
)
}
+9 -13
View File
@@ -8,7 +8,6 @@ import {
import {
createCustomerWithPortalAccess,
deleteCustomerWithPortalAccess,
listCustomersForAdmin,
updateCustomerWithPortalAccess,
} from '../lib/customerAdminApi'
@@ -19,18 +18,15 @@ export function useCustomers() {
const fetchCustomers = useCallback(async () => {
try {
try {
const response = await listCustomersForAdmin()
setCustomers(response.customers || [])
} catch {
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
// Ohne Limit liefert Appwrite nur 25 Dokumente.
[Query.orderAsc('name'), Query.limit(5000)]
)
setCustomers(response.documents || [])
}
// Direkt aus Appwrite: die Admin-API des Portals liefert gekuerzte Daten
// (ohne Stern und InvoiceNinja-Verknuepfung)
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
// Ohne Limit liefert Appwrite nur 25 Dokumente.
[Query.orderAsc('name'), Query.limit(5000)]
)
setCustomers(response.documents || [])
setError(null)
} catch (err) {
console.error('Error fetching customers:', err)
+2 -1
View File
@@ -55,11 +55,12 @@ export function useWebsiteProjects() {
return results.find((r) => !r.success) || { success: true }
}
// Nur vom Ticket loesen: customerId bleibt, sonst verliert der Kunde die Vorschau im Portal
const unassignProject = async (projectId) => {
try {
await databases.updateDocument(
DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, projectId,
{ customerId: '', ticketId: '', updatedAt: new Date().toISOString() }
{ ticketId: '', updatedAt: new Date().toISOString() }
)
return { success: true }
} catch (err) {
-4
View File
@@ -24,10 +24,6 @@ export async function createCustomerWithPortalAccess(payload) {
return adminFetch('/api/admin/customers', { method: 'POST', body: payload })
}
export async function listCustomersForAdmin() {
return adminFetch('/api/admin/customers', { method: 'GET' })
}
export async function updateCustomerWithPortalAccess(customerId, payload) {
return adminFetch(`/api/admin/customers/${customerId}`, {
method: 'PATCH',
-7
View File
@@ -43,10 +43,3 @@ export async function archiveWebsiteProject(id) {
export async function unarchiveWebsiteProject(id) {
return adminFetch(`/api/admin/website-projects/${id}/unarchive`, { method: 'POST' })
}
export async function fetchProjectReadme(repoFullName) {
if (!repoFullName) return { found: false }
return adminFetch(
`/api/admin/gitea/repo-readme?repoFullName=${encodeURIComponent(repoFullName)}`
)
}
+4 -160
View File
@@ -7,23 +7,11 @@ import {
FaXmark,
} from 'react-icons/fa6'
import { FaEdit } from 'react-icons/fa'
import CopyableCredential, {
getCustomerPortalPassword,
} from '../components/CopyableCredential'
import CopyableCredential from '../components/CopyableCredential'
import { useAuth } from '../context/AuthContext'
import { useCustomers } from '../hooks/useCustomers'
import { useEmployees } from '../hooks/useEmployees'
import './AdminPage.css'
const EMPTY_CUSTOMER = {
code: '',
name: '',
location: '',
email: '',
phone: '',
password: '',
}
const EMPTY_EMPLOYEE = {
displayName: '',
email: '',
@@ -39,13 +27,6 @@ function Notice({ notice }) {
export default function AdminPage() {
const { user, isAdmin } = useAuth()
const {
customers,
loading: customersLoading,
createCustomer,
updateCustomer,
deleteCustomer,
} = useCustomers()
const {
employees,
loading: employeesLoading,
@@ -55,10 +36,8 @@ export default function AdminPage() {
deleteEmployee,
} = useEmployees()
const [activeSection, setActiveSection] = useState('customers')
const [customerForm, setCustomerForm] = useState(EMPTY_CUSTOMER)
const [editingCustomer, setEditingCustomer] = useState(null)
const [customerBusy, setCustomerBusy] = useState(false)
// Kunden werden unter "Kunden" gepflegt (Anlegen und Loeschen fuer Admins dort)
const [activeSection, setActiveSection] = useState('employees')
const [employeeForm, setEmployeeForm] = useState(EMPTY_EMPLOYEE)
const [editingEmployee, setEditingEmployee] = useState(null)
const [employeeBusy, setEmployeeBusy] = useState(false)
@@ -82,60 +61,6 @@ export default function AdminPage() {
)
}
const resetCustomerForm = () => {
setCustomerForm(EMPTY_CUSTOMER)
setEditingCustomer(null)
}
const submitCustomer = async (event) => {
event.preventDefault()
if (!customerForm.name.trim() || !customerForm.email.trim()) {
showNotice('error', 'Name und E-Mail sind erforderlich.')
return
}
if (!editingCustomer && customerForm.password.length < 8) {
showNotice('error', 'Das Portal-Passwort muss mindestens 8 Zeichen haben.')
return
}
if (editingCustomer && customerForm.password && customerForm.password.length < 8) {
showNotice('error', 'Ein neues Passwort muss mindestens 8 Zeichen haben.')
return
}
setCustomerBusy(true)
const result = editingCustomer
? await updateCustomer(editingCustomer, customerForm)
: await createCustomer(customerForm)
setCustomerBusy(false)
if (!result.success) {
showNotice('error', result.error || 'Kunde konnte nicht gespeichert werden.')
return
}
showNotice('success', editingCustomer ? 'Kunde aktualisiert.' : 'Kunde angelegt.')
resetCustomerForm()
}
const editCustomer = (customer) => {
setEditingCustomer(customer.$id)
setCustomerForm({
code: customer.code || '',
name: customer.name || '',
location: customer.location || '',
email: customer.email || '',
phone: customer.phone || '',
password: '',
})
}
const removeCustomer = async (customer) => {
if (!window.confirm(`${customer.name || customer.email} wirklich löschen?`)) return
const result = await deleteCustomer(customer.$id)
showNotice(
result.success ? 'success' : 'error',
result.success ? 'Kunde und Portalzugang gelöscht.' : result.error
)
}
const resetEmployeeForm = () => {
setEmployeeForm(EMPTY_EMPLOYEE)
setEditingEmployee(null)
@@ -201,7 +126,7 @@ export default function AdminPage() {
<div>
<span className="admin-eyebrow">Systemverwaltung</span>
<h1>Admin</h1>
<p>Kundenkonten und Mitarbeiterzugänge verwalten.</p>
<p>Mitarbeiter und ihre Zugänge verwalten. Kunden pflegst du unter „Kunden“.</p>
</div>
<div className="admin-current-user">
<span>Angemeldet als</span>
@@ -213,7 +138,6 @@ export default function AdminPage() {
<nav className="admin-tabs" aria-label="Admin-Bereiche">
{[
['customers', `Kunden (${customers.length})`],
['employees', `Mitarbeiter (${employees.length})`],
].map(([id, label]) => (
<button
@@ -227,86 +151,6 @@ export default function AdminPage() {
))}
</nav>
{activeSection === 'customers' && (
<section className="admin-split-layout">
<form className="admin-form-card" onSubmit={submitCustomer}>
<div className="admin-section-head">
<div>
<h2>{editingCustomer ? 'Kunde bearbeiten' : 'Kunde anlegen'}</h2>
<span>Portalzugang für project.webklar.com</span>
</div>
{editingCustomer && (
<button type="button" className="admin-icon-button" onClick={resetCustomerForm} aria-label="Bearbeitung schließen">
<FaXmark />
</button>
)}
</div>
<label className="admin-field">
<span>Kundencode <em>manuell</em></span>
<input className="form-control" value={customerForm.code} onChange={(event) => setCustomerForm({ ...customerForm, code: event.target.value })} placeholder="z. B. C001" />
</label>
<label className="admin-field">
<span>Name</span>
<input className="form-control" required value={customerForm.name} onChange={(event) => setCustomerForm({ ...customerForm, name: event.target.value })} />
</label>
<label className="admin-field">
<span>Standort</span>
<input className="form-control" value={customerForm.location} onChange={(event) => setCustomerForm({ ...customerForm, location: event.target.value })} />
</label>
<label className="admin-field">
<span>E-Mail</span>
<input type="email" className="form-control" required value={customerForm.email} onChange={(event) => setCustomerForm({ ...customerForm, email: event.target.value })} />
</label>
<label className="admin-field">
<span>Telefon</span>
<input className="form-control" value={customerForm.phone} onChange={(event) => setCustomerForm({ ...customerForm, phone: event.target.value })} />
</label>
<label className="admin-field">
<span>{editingCustomer ? 'Neues Passwort (optional)' : 'Portal-Passwort'}</span>
<input type="password" className="form-control" autoComplete="new-password" required={!editingCustomer} value={customerForm.password} onChange={(event) => setCustomerForm({ ...customerForm, password: event.target.value })} placeholder="Mindestens 8 Zeichen" />
</label>
<button className="admin-primary-button" disabled={customerBusy}>
{customerBusy ? <FaSpinner className="spin" /> : editingCustomer ? <FaFloppyDisk /> : <FaPlus />}
{editingCustomer ? 'Änderungen speichern' : 'Kunde anlegen'}
</button>
</form>
<section className="admin-list-card">
<div className="admin-section-head">
<div><h2>Kundenkonten</h2><span>E-Mail sichtbar, Passwort geschützt</span></div>
</div>
{customersLoading ? (
<div className="admin-loading"><FaSpinner className="spinner" /> Kunden werden geladen …</div>
) : customers.length === 0 ? (
<div className="admin-empty-state"><h3>Noch keine Kunden</h3><p>Lege links den ersten Portalzugang an.</p></div>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead><tr><th>Code / Kunde</th><th>Zugang</th><th>Standort / Telefon</th><th>Status</th><th><span className="sr-only">Aktionen</span></th></tr></thead>
<tbody>
{customers.map((customer) => (
<tr key={customer.$id}>
<td><strong>{customer.code || 'Ohne Code'}</strong><span>{customer.name || '–'}</span></td>
<td>
<CopyableCredential value={customer.email} label="E-Mail" />
<CopyableCredential value={getCustomerPortalPassword(customer)} secret label="Passwort" />
</td>
<td><strong>{customer.location || '–'}</strong><span>{customer.phone || '–'}</span></td>
<td><span className={`admin-status ${customer.portalAccessEnabled ? 'active' : ''}`}>{customer.portalAccessEnabled ? 'Aktiv' : 'Inaktiv'}</span></td>
<td className="admin-row-actions">
<button type="button" className="admin-icon-button" onClick={() => editCustomer(customer)} aria-label={`${customer.name} bearbeiten`}><FaEdit /></button>
<button type="button" className="admin-icon-button danger" onClick={() => removeCustomer(customer)} aria-label={`${customer.name} löschen`}><FaTrash /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</section>
)}
{activeSection === 'employees' && (
<section>
{createdCredential && (
+20 -4
View File
@@ -1,13 +1,14 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, Link } from 'react-router-dom'
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaArrowUpRightFromSquare, FaBoxArchive, FaBoxOpen } from 'react-icons/fa6'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaArrowUpRightFromSquare, FaBoxArchive, FaBoxOpen, FaTrash } from 'react-icons/fa6'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
import InvoicePanel from '../components/InvoicePanel'
import AssignedProjectCard from '../components/AssignedProjectCard'
import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
import { updateCustomerWithPortalAccess } from '../lib/customerAdminApi'
import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '../lib/customerAdminApi'
import { useAuth } from '../context/AuthContext'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer, syncCustomerToNinja } from '../lib/leads'
@@ -26,6 +27,8 @@ export default function CustomerDetailPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [tab, setTab] = useState('overview')
const { isAdmin } = useAuth()
const navigate = useNavigate()
const load = useCallback(async () => {
setLoading(true)
@@ -42,7 +45,7 @@ export default function CustomerDetailPage() {
useEffect(() => { load() }, [load])
if (loading) return <div className="page"><div className="empty"><FaSpinner className="spinner" /></div></div>
if (error || !customer) return <div className="page"><EmptyState title="Kunde nicht gefunden" hint={error} action={<Button as={Link} to="/customers"><FaArrowLeft /> Zurueck</Button>} /></div>
if (error || !customer) return <div className="page"><EmptyState title="Kunde nicht gefunden" hint={error} action={<Button as={Link} to="/customers"><FaArrowLeft /> Zurück</Button>} /></div>
const stage = customerStage(customer)
const archived = isArchived(customer)
@@ -53,6 +56,17 @@ export default function CustomerDetailPage() {
await load()
}
// Loescht Kunde und Portal-Login ueber die Admin-API des Kundenportals (nur Admins)
const removeCustomer = async () => {
if (!window.confirm(`${customer.name || customer.email} und den Portal-Zugang wirklich löschen?`)) return
try {
await deleteCustomerWithPortalAccess(customer.$id)
navigate('/customers')
} catch (err) {
window.alert(`Löschen fehlgeschlagen: ${err.message}`)
}
}
return (
<div className="page">
<PageHeader
@@ -72,6 +86,7 @@ export default function CustomerDetailPage() {
<Button variant="ghost" onClick={toggleArchived}>
{archived ? <><FaBoxOpen /> Wiederherstellen</> : <><FaBoxArchive /> Archivieren</>}
</Button>
{isAdmin && <Button variant="danger" onClick={removeCustomer}><FaTrash /> Kunde löschen</Button>}
<Button variant="ghost" as={Link} to="/customers"><FaArrowLeft /> Alle Kunden</Button>
</>
}
@@ -154,6 +169,7 @@ function OverviewTab({ customer, stage, onSaved }) {
<Field label="E-Mail"><input className="form-control" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></Field>
<Field label="Telefon"><input className="form-control" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></Field>
</div>
<Field label="Standort"><input className="form-control" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></Field>
<Field label="Ansprechpartner"><input className="form-control" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} /></Field>
<Field label="Notizen"><textarea className="form-control" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} /></Field>
<label className="flex items-center gap-2" style={{ marginBottom: 12, cursor: 'pointer' }}>
+78 -12
View File
@@ -7,11 +7,12 @@ import { createLead, customerStage, isArchived } from '../lib/leads'
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
export default function CustomersPage() {
const { customers, loading, refresh } = useCustomers()
const { user } = useAuth()
const { customers, loading, refresh, createCustomer } = useCustomers()
const { user, isAdmin } = useAuth()
const [search, setSearch] = useState('')
const [stage, setStage] = useState('all') // all | lead | customer | archived
const [showCreate, setShowCreate] = useState(false)
// 105 von 110 Eintraegen sind Leads der Lead-Automatik, deshalb startet die Liste bei den festen Kunden
const [stage, setStage] = useState('customer') // customer | lead | all | archived
const [showCreate, setShowCreate] = useState(null) // null | 'lead' | 'customer'
const filtered = useMemo(() => {
return (customers || [])
@@ -37,16 +38,23 @@ export default function CustomersPage() {
<PageHeader
title="Kunden"
subtitle="Potenzielle Kunden (Leads) und feste Kunden - eine Pflege, Sync mit InvoiceNinja"
actions={<Button variant="primary" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neuer Lead</Button>}
actions={
<>
{isAdmin && (
<Button onClick={() => setShowCreate((s) => (s === 'customer' ? null : 'customer'))}><FaPlus /> Neuer Kunde</Button>
)}
<Button variant="primary" onClick={() => setShowCreate((s) => (s === 'lead' ? null : 'lead'))}><FaPlus /> Neuer Lead</Button>
</>
}
/>
{showCreate && (
{showCreate === 'lead' && (
<div style={{ marginBottom: 16 }}>
<CreateLeadForm
onCancel={() => setShowCreate(false)}
onCancel={() => setShowCreate(null)}
onDone={async (data) => {
const r = await createLead(data, user)
setShowCreate(false)
setShowCreate(null)
refresh()
return r
}}
@@ -54,21 +62,38 @@ export default function CustomersPage() {
</div>
)}
{showCreate === 'customer' && isAdmin && (
<div style={{ marginBottom: 16 }}>
<CreateCustomerForm
onCancel={() => setShowCreate(null)}
onCreate={async (data) => {
const r = await createCustomer(data)
if (r.success) {
setShowCreate(null)
setStage('customer')
refresh()
}
return r
}}
/>
</div>
)}
<Card pad={false}>
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
<div className="seg">
<button className={stage === 'all' ? 'active' : ''} onClick={() => setStage('all')}>Alle</button>
<button className={stage === 'lead' ? 'active' : ''} onClick={() => setStage('lead')}>Leads</button>
<button className={stage === 'customer' ? 'active' : ''} onClick={() => setStage('customer')}>Feste Kunden</button>
<button className={stage === 'lead' ? 'active' : ''} onClick={() => setStage('lead')}>Leads</button>
<button className={stage === 'all' ? 'active' : ''} onClick={() => setStage('all')}>Alle</button>
<button className={stage === 'archived' ? 'active' : ''} onClick={() => setStage('archived')}>Archiviert</button>
</div>
<input className="form-control search-input" placeholder="Kunde suchen..." value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 320 }} />
<input className="form-control search-input" placeholder="Kunde suchen …" value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 320 }} />
</div>
<div className="ui-card-body" style={{ padding: 0 }}>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : filtered.length === 0 ? (
<EmptyState title="Keine Eintraege" hint="Lege einen neuen Lead an." />
<EmptyState title="Keine Einträge" hint="Keine Kunden in dieser Ansicht." />
) : (
<div className="list">
{filtered.map((c) => {
@@ -141,3 +166,44 @@ function CreateLeadForm({ onCancel, onDone }) {
</Card>
)
}
// Fester Kunde mit Zugang zum Kundenportal (nur Admins; legt den Login im Portal an)
function CreateCustomerForm({ onCancel, onCreate }) {
const [form, setForm] = useState({ name: '', email: '', code: '', phone: '', location: '', password: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const set = (field) => (e) => setForm((f) => ({ ...f, [field]: e.target.value }))
const submit = async (e) => {
e.preventDefault()
if (form.password.length < 8) { setError('Das Portal-Passwort muss mindestens 8 Zeichen haben.'); return }
setBusy(true); setError(null)
const r = await onCreate({ ...form, name: form.name.trim(), email: form.email.trim() })
if (!r.success) {
setError(r.error || 'Kunde konnte nicht angelegt werden.')
setBusy(false)
}
}
return (
<Card title="Neuer Kunde mit Portal-Zugang">
<form onSubmit={submit}>
<div className="flex gap-3 wrap">
<Field label="Unternehmen / Name"><input className="form-control" name="name" value={form.name} onChange={set('name')} required /></Field>
<Field label="E-Mail (Portal-Login)"><input className="form-control" type="email" name="email" value={form.email} onChange={set('email')} required /></Field>
<Field label="Kundennummer"><input className="form-control" name="code" value={form.code} onChange={set('code')} /></Field>
</div>
<div className="flex gap-3 wrap">
<Field label="Telefon"><input className="form-control" name="phone" value={form.phone} onChange={set('phone')} /></Field>
<Field label="Standort"><input className="form-control" name="location" value={form.location} onChange={set('location')} /></Field>
<Field label="Portal-Passwort (mind. 8 Zeichen)"><input className="form-control" type="password" autoComplete="new-password" name="password" value={form.password} onChange={set('password')} required /></Field>
</div>
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
<div className="flex gap-2">
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Kunde anlegen'}</Button>
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
</div>
</form>
</Card>
)
}
+2 -28
View File
@@ -38,16 +38,6 @@ const pulseStyle = `
@keyframes leadPulse { 0% { opacity: .35 } 50% { opacity: 1 } 100% { opacity: .35 } }
`
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'
@@ -121,28 +111,24 @@ function CycleBar() {
export default function LeadsPage() {
const { leads, pipelines, loading, error } = 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])
}, [leads, search])
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 }
return { total: all.length, avg, orte: orte.size }
}, [leads])
return (
@@ -168,22 +154,12 @@ export default function LeadsPage() {
<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..."
@@ -342,7 +318,6 @@ function EmailDraft({ pipeline }) {
* sondern im Akquise-Ticket des Leads (WOID-Chip). */
function LeadCard({ lead, pipeline }) {
const [open, setOpen] = useState(false)
const meta = statusMeta(lead.crmStatus || 'neu')
const score = lead.leadScore || 0
const websiteUrl = /^https?:\/\//.test(lead.website || '') ? lead.website : null
@@ -372,7 +347,6 @@ function LeadCard({ lead, pipeline }) {
<PipelineBar lead={lead} />
<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})
+4 -102
View File
@@ -593,108 +593,10 @@ textarea.form-control {
color: #fff;
}
.ticket-projects-overlay { padding: 24px; }
.ticket-projects-modal { width: 100%; max-width: none; min-height: calc(100vh - 48px); box-sizing: border-box; }
.ticket-projects-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.ticket-projects-modal-header h2 { margin: 0; }
.ticket-projects-modal-grid { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr); gap: 24px; align-items: start; }
.ticket-projects-modal-section { background: rgba(45,55,72,0.65); border: 1px solid rgba(59,130,246,0.25); border-radius: 12px; padding: 16px 18px; margin-bottom: 16px; }
.ticket-projects-modal-section h6 { font-weight: bold; margin: 0 0 12px; }
.ticket-projects-modal-side { display: flex; flex-direction: column; }
.ticket-projects-available-list { max-height: min(50vh, 520px); overflow-y: auto; margin-bottom: 8px; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 6px 16px; }
.ticket-projects-available-item { display: flex; gap: 8px; align-items: flex-start; cursor: pointer; padding: 4px 0; }
.ticket-projects-create-form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; align-items: end; }
@media (max-width: 900px) {
.ticket-projects-modal-grid { grid-template-columns: 1fr; }
.ticket-projects-create-form { grid-template-columns: 1fr; }
.ticket-projects-available-list { grid-template-columns: 1fr; }
}
.ticket-projects-overlay {
padding: 24px;
}
.ticket-projects-modal {
width: 100%;
max-width: none;
min-height: calc(100vh - 48px);
box-sizing: border-box;
}
.ticket-projects-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.ticket-projects-modal-header h2 {
margin: 0;
}
.ticket-projects-modal-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr);
gap: 24px;
align-items: start;
}
.ticket-projects-modal-section {
background: rgba(45, 55, 72, 0.65);
border: 1px solid rgba(59, 130, 246, 0.25);
border-radius: 12px;
padding: 16px 18px;
margin-bottom: 16px;
}
.ticket-projects-modal-section h6 {
font-weight: bold;
margin: 0 0 12px;
}
.ticket-projects-modal-side {
display: flex;
flex-direction: column;
gap: 0;
}
.ticket-projects-available-list {
max-height: min(50vh, 520px);
overflow-y: auto;
margin-bottom: 8px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 6px 16px;
}
.ticket-projects-available-item {
display: flex;
gap: 8px;
align-items: flex-start;
cursor: pointer;
padding: 4px 0;
}
.ticket-projects-create-form {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 10px;
align-items: end;
}
@media (max-width: 900px) {
.ticket-projects-modal-grid {
grid-template-columns: 1fr;
}
.ticket-projects-create-form {
grid-template-columns: 1fr;
}
.ticket-projects-available-list {
grid-template-columns: 1fr;
}
}
.ticket-projects { margin-top: 16px; }
/* Zeile unter der Projektliste: Projekt waehlen + Zuweisen + Neues Projekt */
.ticket-projects-assign { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.ticket-projects-assign .form-control { flex: 1 1 220px; margin: 0; }
.modal-content {
background: rgba(45, 55, 72, 0.98) !important;