feat: CRM-Stufen (Lead/Kunde) + InvoiceNinja-Sync (eine Pflege)

- Zwei Kundenstufen: Lead (potenziell) und fester Kunde; Lead braucht nur
  Unternehmen + E-Mail und erzeugt automatisch ein Akquise-Ticket
- Akquise-Worksheet-Ergebnis (In Kontakt/Zugesagt/Abgesagt) sichtbar in WSIDs;
  "Zugesagt" macht den Lead automatisch zum festen Kunden, "Abgesagt" -> verloren
- Kunde wird nur in Appwrite gepflegt und automatisch als InvoiceNinja-Client
  angelegt/aktualisiert (beim Upgrade bzw. bei Datenpflege)
- Rechnungen nur fuer feste Kunden mit vollstaendigen Daten (Strasse/PLZ/Stadt)
- Kundenliste mit Stufen-Filter, neue Rechnungs-/Adressfelder

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Webklar DeployandCursor committed 2026-06-17 11:18:28 +00:00
1 parent 7d764ce6bc
commit a1cb90372f
8 files changed
+296 -80

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ import { useEmployees } from '../hooks/useEmployees'
// Fallback-Werte falls Config nicht geladen werden kann
const DEFAULT_TICKET_TYPES = [
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
'Akquise', 'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
'Maintenance', 'Project', 'Webpage', 'Controlling', 'Development', 'Documentation',
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
'Rollout', 'Emergency Call', 'Other Services'
+17 -3
View File
@@ -30,6 +30,9 @@ const RESPONSE_LEVELS = [
'n/a'
]
// Akquise-Tickets: Ergebnis des Kundenkontakts (statt technischer Status)
const ACQUISITION_STATUS = ['In Kontakt', 'Zugesagt', 'Abgesagt']
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) {
const { user } = useAuth()
const today = new Date().toLocaleDateString('de-DE', {
@@ -55,12 +58,18 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
const [error, setError] = useState('')
const [autoCalculate, setAutoCalculate] = useState(true)
const isAcquisition = workorder?.type === 'Akquise'
const statusOptions = isAcquisition ? ACQUISITION_STATUS : STATUS_OPTIONS
const defaultStatus = isAcquisition
? (ACQUISITION_STATUS.includes(workorder?.status) ? workorder.status : 'In Kontakt')
: (workorder?.status || 'Open')
// Reset form wenn Modal geöffnet wird
useEffect(() => {
if (isOpen && workorder) {
setFormData({
serviceType: 'Remote',
newStatus: workorder.status || 'Open',
newStatus: defaultStatus,
newResponseLevel: workorder.responseLevel || '',
totalTime: 0,
startDate: today,
@@ -189,17 +198,22 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
</div>
<div className="form-group">
<label className="form-label">New Status</label>
<label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'New Status'}</label>
<select
className="form-control"
value={formData.newStatus}
onChange={(e) => handleChange('newStatus', e.target.value)}
required
>
{STATUS_OPTIONS.map(status => (
{statusOptions.map(status => (
<option key={status} value={status}>{status}</option>
))}
</select>
{isAcquisition && (
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
"Zugesagt" macht den Lead automatisch zum festen Kunden.
</small>
)}
</div>
<div className="form-group">
+12 -3
View File
@@ -3,6 +3,7 @@ import { FaFilePdf, FaPlus, FaLink, FaSpinner, FaRotate } from 'react-icons/fa6'
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
import { integrationsApi, formatMoney, INVOICE_STATUS } from '../lib/integrationsApi'
import { useInvoices } from '../hooks/useInvoices'
import { customerStage, isInvoiceReady } from '../lib/leads'
const card = {
background: 'rgba(45, 55, 72, 0.5)',
@@ -74,7 +75,15 @@ export default function InvoicePanel({ ticket }) {
{customerError && <div className="text-red" style={{ marginBottom: 8 }}>{customerError}</div>}
{!clientId ? (
{customer && customerStage(customer) === 'lead' ? (
<p style={{ color: '#a0aec0', margin: 0 }}>
Dieser Kunde ist noch ein Lead. Erst zum festen Kunden machen (im Kunden-Bereich, vollstaendige Daten noetig) - dann sind Rechnungen moeglich.
</p>
) : !clientId && !isInvoiceReady(customer) ? (
<p style={{ color: '#a0aec0', margin: 0 }}>
Rechnungsdaten unvollstaendig. Bitte im Kunden-Bereich Unternehmen, E-Mail, Strasse, PLZ und Stadt ergaenzen.
</p>
) : !clientId ? (
<LinkClient customer={customer} onLinked={loadCustomer} />
) : (
<InvoiceList
@@ -177,7 +186,7 @@ function LinkClient({ customer, onLinked }) {
<div key={c.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<div>
<strong style={{ color: 'var(--dark-text)' }}>{c.name}</strong>
{c.number ? <span style={{ color: '#a0aec0' }}> · {c.number}</span> : null}
{c.number ? <span style={{ color: '#a0aec0' }}> � {c.number}</span> : null}
{c.email ? <div style={{ color: '#a0aec0', fontSize: '12px' }}>{c.email}</div> : null}
</div>
<button className="btn btn-green" style={{ margin: 0 }} disabled={saving} onClick={() => saveMapping(c.id)}>
@@ -248,7 +257,7 @@ function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice
</thead>
<tbody>
{invoices.map((inv) => {
const st = INVOICE_STATUS[inv.status_id] || { label: '—', color: '#a0aec0' }
const st = INVOICE_STATUS[inv.status_id] || { label: '�', color: '#a0aec0' }
const overdue = Number(inv.balance) > 0 && inv.due_date && new Date(inv.due_date) < new Date()
return (
<tr key={inv.id}>
+14
View File
@@ -18,6 +18,7 @@ import Tabs from './ui/Tabs'
import Badge from './ui/Badge'
import { StatusPill, PriorityPill } from './ui/StatusPill'
import { useWorksheets } from '../hooks/useWorksheets'
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
const TABS = [
{ id: 'details', label: 'Details' },
@@ -52,6 +53,19 @@ export default function TicketCard({ ticket, onUpdate }) {
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel,
})
}
// Akquise-Automatik: Ergebnis im Worksheet steuert den Kundenstatus
if (result.success && ticket.type === 'Akquise' && ticket.customerId) {
const outcome = worksheetData.newStatus
const next = outcome === 'Zugesagt' ? 'customer' : outcome === 'Abgesagt' ? 'lost' : null
if (next) {
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ticket.customerId, {
customerStatus: next,
updatedAt: new Date().toISOString(),
})
} catch { /* nicht kritisch */ }
}
}
return result
}