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:
@@ -5,7 +5,7 @@ import { useEmployees } from '../hooks/useEmployees'
|
|||||||
|
|
||||||
// Fallback-Werte falls Config nicht geladen werden kann
|
// Fallback-Werte falls Config nicht geladen werden kann
|
||||||
const DEFAULT_TICKET_TYPES = [
|
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',
|
'Maintenance', 'Project', 'Webpage', 'Controlling', 'Development', 'Documentation',
|
||||||
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
|
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
|
||||||
'Rollout', 'Emergency Call', 'Other Services'
|
'Rollout', 'Emergency Call', 'Other Services'
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ const RESPONSE_LEVELS = [
|
|||||||
'n/a'
|
'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 }) {
|
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const today = new Date().toLocaleDateString('de-DE', {
|
const today = new Date().toLocaleDateString('de-DE', {
|
||||||
@@ -55,12 +58,18 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [autoCalculate, setAutoCalculate] = useState(true)
|
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
|
// Reset form wenn Modal geöffnet wird
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && workorder) {
|
if (isOpen && workorder) {
|
||||||
setFormData({
|
setFormData({
|
||||||
serviceType: 'Remote',
|
serviceType: 'Remote',
|
||||||
newStatus: workorder.status || 'Open',
|
newStatus: defaultStatus,
|
||||||
newResponseLevel: workorder.responseLevel || '',
|
newResponseLevel: workorder.responseLevel || '',
|
||||||
totalTime: 0,
|
totalTime: 0,
|
||||||
startDate: today,
|
startDate: today,
|
||||||
@@ -189,17 +198,22 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">New Status</label>
|
<label className="form-label">{isAcquisition ? 'Akquise-Ergebnis' : 'New Status'}</label>
|
||||||
<select
|
<select
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={formData.newStatus}
|
value={formData.newStatus}
|
||||||
onChange={(e) => handleChange('newStatus', e.target.value)}
|
onChange={(e) => handleChange('newStatus', e.target.value)}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
{STATUS_OPTIONS.map(status => (
|
{statusOptions.map(status => (
|
||||||
<option key={status} value={status}>{status}</option>
|
<option key={status} value={status}>{status}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{isAcquisition && (
|
||||||
|
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
|
||||||
|
"Zugesagt" macht den Lead automatisch zum festen Kunden.
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FaFilePdf, FaPlus, FaLink, FaSpinner, FaRotate } from 'react-icons/fa6'
|
|||||||
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
|
||||||
import { integrationsApi, formatMoney, INVOICE_STATUS } from '../lib/integrationsApi'
|
import { integrationsApi, formatMoney, INVOICE_STATUS } from '../lib/integrationsApi'
|
||||||
import { useInvoices } from '../hooks/useInvoices'
|
import { useInvoices } from '../hooks/useInvoices'
|
||||||
|
import { customerStage, isInvoiceReady } from '../lib/leads'
|
||||||
|
|
||||||
const card = {
|
const card = {
|
||||||
background: 'rgba(45, 55, 72, 0.5)',
|
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>}
|
{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} />
|
<LinkClient customer={customer} onLinked={loadCustomer} />
|
||||||
) : (
|
) : (
|
||||||
<InvoiceList
|
<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 key={c.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ color: 'var(--dark-text)' }}>{c.name}</strong>
|
<strong style={{ color: 'var(--dark-text)' }}>{c.name}</strong>
|
||||||
{c.number ? <span style={{ color: '#a0aec0' }}> <EFBFBD> {c.number}</span> : null}
|
{c.number ? <span style={{ color: '#a0aec0' }}> <EFBFBD> {c.number}</span> : null}
|
||||||
{c.email ? <div style={{ color: '#a0aec0', fontSize: '12px' }}>{c.email}</div> : null}
|
{c.email ? <div style={{ color: '#a0aec0', fontSize: '12px' }}>{c.email}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-green" style={{ margin: 0 }} disabled={saving} onClick={() => saveMapping(c.id)}>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{invoices.map((inv) => {
|
{invoices.map((inv) => {
|
||||||
const st = INVOICE_STATUS[inv.status_id] || { label: '<27>', color: '#a0aec0' }
|
const st = INVOICE_STATUS[inv.status_id] || { label: '<27>', color: '#a0aec0' }
|
||||||
const overdue = Number(inv.balance) > 0 && inv.due_date && new Date(inv.due_date) < new Date()
|
const overdue = Number(inv.balance) > 0 && inv.due_date && new Date(inv.due_date) < new Date()
|
||||||
return (
|
return (
|
||||||
<tr key={inv.id}>
|
<tr key={inv.id}>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import Tabs from './ui/Tabs'
|
|||||||
import Badge from './ui/Badge'
|
import Badge from './ui/Badge'
|
||||||
import { StatusPill, PriorityPill } from './ui/StatusPill'
|
import { StatusPill, PriorityPill } from './ui/StatusPill'
|
||||||
import { useWorksheets } from '../hooks/useWorksheets'
|
import { useWorksheets } from '../hooks/useWorksheets'
|
||||||
|
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'details', label: 'Details' },
|
{ id: 'details', label: 'Details' },
|
||||||
@@ -52,6 +53,19 @@ export default function TicketCard({ ticket, onUpdate }) {
|
|||||||
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel,
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const integrationsApi = {
|
|||||||
// ---- InvoiceNinja: clients ----
|
// ---- InvoiceNinja: clients ----
|
||||||
listClients: (search = '') => call(`/clients${qs({ search })}`),
|
listClients: (search = '') => call(`/clients${qs({ search })}`),
|
||||||
createClient: (payload) => call('/clients', { method: 'POST', body: payload }),
|
createClient: (payload) => call('/clients', { method: 'POST', body: payload }),
|
||||||
|
updateClient: (id, payload) => call(`/clients/${encodeURIComponent(id)}`, { method: 'PUT', body: payload }),
|
||||||
|
|
||||||
// ---- InvoiceNinja: invoices ----
|
// ---- InvoiceNinja: invoices ----
|
||||||
listInvoices: (clientId, perPage) => call(`/invoices${qs({ clientId, per_page: perPage })}`),
|
listInvoices: (clientId, perPage) => call(`/invoices${qs({ clientId, per_page: perPage })}`),
|
||||||
|
|||||||
119
src/lib/leads.js
Normal file
119
src/lib/leads.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from './appwrite'
|
||||||
|
import { integrationsApi } from './integrationsApi'
|
||||||
|
|
||||||
|
/** Pflichtfelder, damit eine Rechnung erstellt werden kann (wie InvoiceNinja). */
|
||||||
|
export function isInvoiceReady(c) {
|
||||||
|
return Boolean(c && c.name && c.email && c.street && c.city && c.postalCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customerStage(c) {
|
||||||
|
const s = (c?.customerStatus || '').toLowerCase()
|
||||||
|
if (s === 'lead') return 'lead'
|
||||||
|
if (s === 'lost') return 'lost'
|
||||||
|
return 'customer' // Bestandsdaten ohne Status gelten als fester Kunde
|
||||||
|
}
|
||||||
|
|
||||||
|
function ninjaPayload(c) {
|
||||||
|
return {
|
||||||
|
name: c.companyName || c.name,
|
||||||
|
email: c.email || '',
|
||||||
|
phone: c.phone || '',
|
||||||
|
street: c.street || '',
|
||||||
|
city: c.city || '',
|
||||||
|
postalCode: c.postalCode || '',
|
||||||
|
country: c.country || '',
|
||||||
|
vatNumber: c.vatNumber || '',
|
||||||
|
contactName: c.contactName || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erstellt/aktualisiert den InvoiceNinja-Client und speichert die ID am Kunden. */
|
||||||
|
export async function syncCustomerToNinja(customer) {
|
||||||
|
if (customer.invoiceNinjaClientId) {
|
||||||
|
await integrationsApi.updateClient(customer.invoiceNinjaClientId, ninjaPayload(customer))
|
||||||
|
return customer.invoiceNinjaClientId
|
||||||
|
}
|
||||||
|
const r = await integrationsApi.createClient(ninjaPayload(customer))
|
||||||
|
const id = r.client?.id
|
||||||
|
if (id) {
|
||||||
|
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
|
||||||
|
invoiceNinjaClientId: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextWoid() {
|
||||||
|
try {
|
||||||
|
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
|
||||||
|
Query.orderDesc('$createdAt'),
|
||||||
|
Query.limit(200),
|
||||||
|
])
|
||||||
|
const nums = res.documents.map((d) => parseInt(d.woid)).filter((n) => !isNaN(n) && n > 0)
|
||||||
|
return String((nums.length ? Math.max(...nums) : 9999) + 1)
|
||||||
|
} catch {
|
||||||
|
return String(10000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-Akquise-Ticket fuer einen potenziellen Kunden. */
|
||||||
|
export async function createAcquisitionTicket(customer, user) {
|
||||||
|
const woid = await nextWoid()
|
||||||
|
const data = {
|
||||||
|
topic: `Akquise: ${customer.name}`,
|
||||||
|
status: 'Open',
|
||||||
|
priority: 2,
|
||||||
|
woid,
|
||||||
|
type: 'Akquise',
|
||||||
|
systemType: 'n/a',
|
||||||
|
serviceType: 'Remote',
|
||||||
|
customerId: customer.$id,
|
||||||
|
customerName: customer.name,
|
||||||
|
customerLocation: customer.location || '',
|
||||||
|
requestedBy: customer.email || '',
|
||||||
|
assignedTo: user?.$id || '',
|
||||||
|
details:
|
||||||
|
`Automatisch erstelltes Akquise-Ticket fuer den potenziellen Kunden "${customer.name}".\n` +
|
||||||
|
`Kontakt: ${customer.email || '-'}\n\n` +
|
||||||
|
`Worksheets dokumentieren den Kontaktverlauf (sichtbar in den WSIDs).\n` +
|
||||||
|
`Ergebnis im Worksheet auf "Zugesagt" -> Lead wird automatisch fester Kunde. "Abgesagt" -> Ticket geschlossen.`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ID.unique(), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt einen Lead an (nur Unternehmen + E-Mail) und erstellt das Akquise-Ticket. */
|
||||||
|
export async function createLead({ company, email }, user) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const customer = await databases.createDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ID.unique(), {
|
||||||
|
name: company,
|
||||||
|
companyName: company,
|
||||||
|
email: email || '',
|
||||||
|
customerStatus: 'lead',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
let ticket = null
|
||||||
|
try {
|
||||||
|
ticket = await createAcquisitionTicket(customer, user)
|
||||||
|
} catch {
|
||||||
|
/* Ticket ist optional - Lead bleibt bestehen */
|
||||||
|
}
|
||||||
|
return { customer, ticket }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Macht aus einem Lead einen festen Kunden (mit vollstaendigen Daten) und legt den InvoiceNinja-Client an. */
|
||||||
|
export async function upgradeToCustomer(customer, fullData) {
|
||||||
|
const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
|
||||||
|
...fullData,
|
||||||
|
customerStatus: 'customer',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
let clientId = updated.invoiceNinjaClientId
|
||||||
|
try {
|
||||||
|
clientId = await syncCustomerToNinja(updated)
|
||||||
|
} catch (e) {
|
||||||
|
return { customer: updated, clientId: null, syncError: e.message }
|
||||||
|
}
|
||||||
|
return { customer: { ...updated, invoiceNinjaClientId: clientId }, clientId }
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk } from 'react-icons/fa6'
|
import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp } from 'react-icons/fa6'
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||||
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
|
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
|
||||||
import { StatusPill, PriorityPill } from '../components/ui/StatusPill'
|
import { StatusPill, PriorityPill } from '../components/ui/StatusPill'
|
||||||
@@ -8,6 +8,7 @@ import InvoicePanel from '../components/InvoicePanel'
|
|||||||
import DocumentsPanel from '../components/DocumentsPanel'
|
import DocumentsPanel from '../components/DocumentsPanel'
|
||||||
import AssignedProjectCard from '../components/AssignedProjectCard'
|
import AssignedProjectCard from '../components/AssignedProjectCard'
|
||||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
|
import { customerStage, isInvoiceReady, upgradeToCustomer, syncCustomerToNinja } from '../lib/leads'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'overview', label: 'Uebersicht' },
|
{ id: 'overview', label: 'Uebersicht' },
|
||||||
@@ -41,6 +42,7 @@ export default function CustomerDetailPage() {
|
|||||||
if (loading) return <div className="page"><div className="empty"><FaSpinner className="spinner" /></div></div>
|
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 /> Zurueck</Button>} /></div>
|
||||||
|
|
||||||
|
const stage = customerStage(customer)
|
||||||
const syntheticTicket = { customerId: customer.$id, customerName: customer.name, woid: '' }
|
const syntheticTicket = { customerId: customer.$id, customerName: customer.name, woid: '' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,30 +52,48 @@ export default function CustomerDetailPage() {
|
|||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
{customer.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} />}
|
{customer.importantCustomer && <FaStar style={{ color: 'var(--warn)' }} />}
|
||||||
{customer.name}
|
{customer.name}
|
||||||
|
{stage === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
|
||||||
|
{stage === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
|
||||||
|
{stage === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' <20> ')}
|
subtitle={[customer.location, customer.email, customer.phone].filter(Boolean).join(' <20> ')}
|
||||||
actions={<Button variant="ghost" as={Link} to="/customers"><FaArrowLeft /> Alle Kunden</Button>}
|
actions={<Button variant="ghost" as={Link} to="/customers"><FaArrowLeft /> Alle Kunden</Button>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||||
|
|
||||||
{tab === 'overview' && <OverviewTab customer={customer} onSaved={load} />}
|
{tab === 'overview' && <OverviewTab customer={customer} stage={stage} onSaved={load} />}
|
||||||
{tab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
|
{tab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
|
||||||
{tab === 'invoices' && <InvoicePanel ticket={syntheticTicket} />}
|
{tab === 'invoices' && (
|
||||||
|
stage === 'lead' ? (
|
||||||
|
<Card><EmptyState title="Noch ein Lead" hint="Erst zum festen Kunden machen (vollstaendige Daten noetig), dann sind Rechnungen moeglich." action={<Button variant="primary" onClick={() => setTab('overview')}>Daten erfassen</Button>} /></Card>
|
||||||
|
) : !isInvoiceReady(customer) ? (
|
||||||
|
<Card><EmptyState title="Daten unvollstaendig" hint="Fuer Rechnungen werden Unternehmen, E-Mail, Strasse, Stadt und PLZ benoetigt." action={<Button variant="primary" onClick={() => setTab('overview')}>Daten vervollstaendigen</Button>} /></Card>
|
||||||
|
) : (
|
||||||
|
<InvoicePanel ticket={syntheticTicket} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
{tab === 'documents' && <DocumentsPanel ticket={syntheticTicket} initialScope="customer" />}
|
{tab === 'documents' && <DocumentsPanel ticket={syntheticTicket} initialScope="customer" />}
|
||||||
{tab === 'projects' && <CustomerProjects customerId={customer.$id} />}
|
{tab === 'projects' && <CustomerProjects customerId={customer.$id} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function OverviewTab({ customer, onSaved }) {
|
function OverviewTab({ customer, stage, onSaved }) {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: customer.name || '',
|
name: customer.name || '',
|
||||||
|
companyName: customer.companyName || customer.name || '',
|
||||||
code: customer.code || '',
|
code: customer.code || '',
|
||||||
location: customer.location || '',
|
|
||||||
email: customer.email || '',
|
email: customer.email || '',
|
||||||
phone: customer.phone || '',
|
phone: customer.phone || '',
|
||||||
|
contactName: customer.contactName || '',
|
||||||
|
street: customer.street || '',
|
||||||
|
postalCode: customer.postalCode || '',
|
||||||
|
city: customer.city || '',
|
||||||
|
country: customer.country || 'Deutschland',
|
||||||
|
vatNumber: customer.vatNumber || '',
|
||||||
|
location: customer.location || '',
|
||||||
notes: customer.notes || '',
|
notes: customer.notes || '',
|
||||||
importantCustomer: !!customer.importantCustomer,
|
importantCustomer: !!customer.importantCustomer,
|
||||||
paperlessCorrespondent: customer.paperlessCorrespondent || '',
|
paperlessCorrespondent: customer.paperlessCorrespondent || '',
|
||||||
@@ -81,56 +101,76 @@ function OverviewTab({ customer, onSaved }) {
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [msg, setMsg] = useState(null)
|
const [msg, setMsg] = useState(null)
|
||||||
|
|
||||||
|
const ready = isInvoiceReady({ ...customer, ...form })
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setBusy(true); setMsg(null)
|
setBusy(true); setMsg(null)
|
||||||
try {
|
try {
|
||||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
|
const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
|
||||||
setMsg('Gespeichert')
|
let note = 'Gespeichert'
|
||||||
|
// Wenn fester Kunde: Aenderungen nach InvoiceNinja syncen (sofern Daten reichen oder bereits verknuepft)
|
||||||
|
if (stage === 'customer' && (updated.invoiceNinjaClientId || isInvoiceReady(updated))) {
|
||||||
|
try { await syncCustomerToNinja(updated) } catch (e) { note = 'Gespeichert, InvoiceNinja-Sync fehlgeschlagen: ' + e.message }
|
||||||
|
}
|
||||||
|
setMsg(note)
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMsg('Fehler: ' + err.message)
|
setMsg('Fehler: ' + err.message)
|
||||||
} finally { setBusy(false) }
|
} finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const upgrade = async () => {
|
||||||
|
if (!ready) { setMsg('Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.'); return }
|
||||||
|
setBusy(true); setMsg(null)
|
||||||
|
const r = await upgradeToCustomer(customer, form)
|
||||||
|
if (r.clientId) setMsg('Zum festen Kunden gemacht und mit InvoiceNinja verknuepft.')
|
||||||
|
else setMsg('Status gesetzt, aber InvoiceNinja-Verknuepfung fehlgeschlagen: ' + (r.syncError || 'unbekannt'))
|
||||||
|
setBusy(false)
|
||||||
|
onSaved()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 16 }}>
|
||||||
<Card title="Stammdaten">
|
<Card title="Stammdaten">
|
||||||
<div className="flex gap-3 wrap">
|
<div className="flex gap-3 wrap">
|
||||||
<Field label="Name"><input className="form-control" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></Field>
|
<Field label="Unternehmen / Name"><input className="form-control" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value, companyName: e.target.value })} /></Field>
|
||||||
<Field label="Code"><input className="form-control" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></Field>
|
<Field label="Code"><input className="form-control" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Ort"><input className="form-control" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></Field>
|
|
||||||
<div className="flex gap-3 wrap">
|
<div className="flex gap-3 wrap">
|
||||||
<Field label="E-Mail"><input className="form-control" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></Field>
|
<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>
|
<Field label="Telefon"><input className="form-control" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /></Field>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
<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' }}>
|
<label className="flex items-center gap-2" style={{ marginBottom: 12, cursor: 'pointer' }}>
|
||||||
<input type="checkbox" checked={form.importantCustomer} onChange={(e) => setForm({ ...form, importantCustomer: e.target.checked })} />
|
<input type="checkbox" checked={form.importantCustomer} onChange={(e) => setForm({ ...form, importantCustomer: e.target.checked })} />
|
||||||
Wichtiger Kunde
|
Wichtiger Kunde
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center wrap">
|
||||||
<Button variant="primary" onClick={save} disabled={busy}>{busy ? <FaSpinner className="spinner" /> : <><FaFloppyDisk /> Speichern</>}</Button>
|
<Button variant="primary" onClick={save} disabled={busy}>{busy ? <FaSpinner className="spinner" /> : <><FaFloppyDisk /> Speichern</>}</Button>
|
||||||
|
{stage === 'lead' && <Button onClick={upgrade} disabled={busy}><FaArrowUp /> Zum festen Kunden machen</Button>}
|
||||||
{msg && <span className="muted">{msg}</span>}
|
{msg && <span className="muted">{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Verknuepfungen">
|
<Card title="Rechnungsdaten (fuer InvoiceNinja)">
|
||||||
<div className="flex flex-col gap-3">
|
{!ready && <div className="badge badge-warn" style={{ marginBottom: 12 }}>Unvollstaendig - fuer Rechnungen erforderlich</div>}
|
||||||
<div className="flex items-center justify-between">
|
<Field label="Strasse + Nr."><input className="form-control" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></Field>
|
||||||
|
<div className="flex gap-3 wrap">
|
||||||
|
<Field label="PLZ"><input className="form-control" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} /></Field>
|
||||||
|
<Field label="Stadt"><input className="form-control" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} /></Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 wrap">
|
||||||
|
<Field label="Land"><input className="form-control" value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} /></Field>
|
||||||
|
<Field label="USt-IdNr."><input className="form-control" value={form.vatNumber} onChange={(e) => setForm({ ...form, vatNumber: e.target.value })} /></Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between" style={{ marginTop: 8 }}>
|
||||||
<span className="muted">InvoiceNinja</span>
|
<span className="muted">InvoiceNinja</span>
|
||||||
{customer.invoiceNinjaClientId
|
{customer.invoiceNinjaClientId ? <Badge tone="ok" dot>verknuepft</Badge> : <Badge tone="muted">nicht verknuepft</Badge>}
|
||||||
? <Badge tone="ok" dot>verknuepft</Badge>
|
|
||||||
: <Badge tone="muted">nicht verknuepft</Badge>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="faint" style={{ fontSize: 12 }}>
|
<Field label="Paperless-Korrespondent" hint="Leer = Kundenname wird verwendet">
|
||||||
Verknuepfung erfolgt im Tab "Rechnungen" (Kunde suchen oder neu anlegen).
|
|
||||||
</div>
|
|
||||||
<Field label="Paperless-Korrespondent">
|
|
||||||
<input className="form-control" value={form.paperlessCorrespondent} onChange={(e) => setForm({ ...form, paperlessCorrespondent: e.target.value })} placeholder={customer.name} />
|
<input className="form-control" value={form.paperlessCorrespondent} onChange={(e) => setForm({ ...form, paperlessCorrespondent: e.target.value })} placeholder={customer.name} />
|
||||||
</Field>
|
</Field>
|
||||||
<div className="faint" style={{ fontSize: 12 }}>Leer = Kundenname wird als Korrespondent verwendet.</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -171,7 +211,7 @@ function CustomerTickets({ customerId }) {
|
|||||||
<span className="mono" style={{ color: 'var(--accent)', minWidth: 60 }}>{t.woid || t.$id.slice(-5)}</span>
|
<span className="mono" style={{ color: 'var(--accent)', minWidth: 60 }}>{t.woid || t.$id.slice(-5)}</span>
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<div style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
|
<div style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
|
||||||
<div className="muted">{t.type} <EFBFBD> {t.systemType || 'n/a'}</div>
|
<div className="muted">{t.type} <EFBFBD> {t.systemType || 'n/a'}</div>
|
||||||
</div>
|
</div>
|
||||||
<PriorityPill priority={t.priority} />
|
<PriorityPill priority={t.priority} />
|
||||||
<StatusPill status={t.status} />
|
<StatusPill status={t.status} />
|
||||||
|
|||||||
@@ -2,39 +2,48 @@ import { useState, useMemo } from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { FaSpinner, FaPlus, FaStar, FaFileInvoiceDollar, FaFileLines } from 'react-icons/fa6'
|
import { FaSpinner, FaPlus, FaStar, FaFileInvoiceDollar, FaFileLines } from 'react-icons/fa6'
|
||||||
import { useCustomers } from '../hooks/useCustomers'
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { createLead, customerStage } from '../lib/leads'
|
||||||
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
|
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
|
||||||
|
|
||||||
export default function CustomersPage() {
|
export default function CustomersPage() {
|
||||||
const { customers, loading, createCustomer, refresh } = useCustomers()
|
const { customers, loading, refresh } = useCustomers()
|
||||||
|
const { user } = useAuth()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [onlyImportant, setOnlyImportant] = useState(false)
|
const [stage, setStage] = useState('all') // all | lead | customer
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return (customers || [])
|
return (customers || [])
|
||||||
.filter((c) => (onlyImportant ? c.importantCustomer : true))
|
.filter((c) => {
|
||||||
|
const st = customerStage(c)
|
||||||
|
if (st === 'lost') return stage === 'all'
|
||||||
|
if (stage === 'all') return true
|
||||||
|
return st === stage
|
||||||
|
})
|
||||||
.filter((c) => {
|
.filter((c) => {
|
||||||
if (!search) return true
|
if (!search) return true
|
||||||
const s = search.toLowerCase()
|
const s = search.toLowerCase()
|
||||||
return `${c.name} ${c.code || ''} ${c.location || ''} ${c.email || ''}`.toLowerCase().includes(s)
|
return `${c.name} ${c.code || ''} ${c.location || ''} ${c.email || ''}`.toLowerCase().includes(s)
|
||||||
})
|
})
|
||||||
}, [customers, search, onlyImportant])
|
}, [customers, search, stage])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Kunden"
|
title="Kunden"
|
||||||
subtitle="Stammdaten, Verknuepfungen und 360-Grad-Sicht je Kunde"
|
subtitle="Potenzielle Kunden (Leads) und feste Kunden - eine Pflege, Sync mit InvoiceNinja"
|
||||||
actions={<Button variant="primary" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neuer Kunde</Button>}
|
actions={<Button variant="primary" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neuer Lead</Button>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<CreateCustomerForm
|
<CreateLeadForm
|
||||||
onCancel={() => setShowCreate(false)}
|
onCancel={() => setShowCreate(false)}
|
||||||
onDone={async (data) => {
|
onDone={async (data) => {
|
||||||
const r = await createCustomer(data)
|
const r = await createLead(data, user)
|
||||||
if (r.success) { setShowCreate(false); refresh() }
|
setShowCreate(false)
|
||||||
|
refresh()
|
||||||
return r
|
return r
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -44,8 +53,9 @@ export default function CustomersPage() {
|
|||||||
<Card pad={false}>
|
<Card pad={false}>
|
||||||
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
|
<div className="ui-card-head" style={{ gap: 12, flexWrap: 'wrap' }}>
|
||||||
<div className="seg">
|
<div className="seg">
|
||||||
<button className={!onlyImportant ? 'active' : ''} onClick={() => setOnlyImportant(false)}>Alle</button>
|
<button className={stage === 'all' ? 'active' : ''} onClick={() => setStage('all')}>Alle</button>
|
||||||
<button className={onlyImportant ? 'active' : ''} onClick={() => setOnlyImportant(true)}>Wichtige</button>
|
<button className={stage === 'lead' ? 'active' : ''} onClick={() => setStage('lead')}>Leads</button>
|
||||||
|
<button className={stage === 'customer' ? 'active' : ''} onClick={() => setStage('customer')}>Feste Kunden</button>
|
||||||
</div>
|
</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>
|
||||||
@@ -53,10 +63,12 @@ export default function CustomersPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="empty"><FaSpinner className="spinner" /></div>
|
<div className="empty"><FaSpinner className="spinner" /></div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<EmptyState title="Keine Kunden" hint="Lege deinen ersten Kunden an." />
|
<EmptyState title="Keine Eintraege" hint="Lege einen neuen Lead an." />
|
||||||
) : (
|
) : (
|
||||||
<div className="list">
|
<div className="list">
|
||||||
{filtered.map((c) => (
|
{filtered.map((c) => {
|
||||||
|
const st = customerStage(c)
|
||||||
|
return (
|
||||||
<Link key={c.$id} to={`/customers/${c.$id}`} className="list-row">
|
<Link key={c.$id} to={`/customers/${c.$id}`} className="list-row">
|
||||||
<div className="side-logo" style={{ width: 34, height: 34, fontSize: 13 }}>
|
<div className="side-logo" style={{ width: 34, height: 34, fontSize: 13 }}>
|
||||||
{(c.name || '?').charAt(0).toUpperCase()}
|
{(c.name || '?').charAt(0).toUpperCase()}
|
||||||
@@ -70,11 +82,15 @@ export default function CustomersPage() {
|
|||||||
<div className="muted">{[c.location, c.email].filter(Boolean).join(' <20> ') || '-'}</div>
|
<div className="muted">{[c.location, c.email].filter(Boolean).join(' <20> ') || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 wrap" style={{ justifyContent: 'flex-end' }}>
|
<div className="flex gap-2 wrap" style={{ justifyContent: 'flex-end' }}>
|
||||||
{c.invoiceNinjaClientId && <Badge tone="ok"><FaFileInvoiceDollar /> Rechnungen</Badge>}
|
{st === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
|
||||||
{c.paperlessCorrespondent && <Badge tone="info"><FaFileLines /> Dokumente</Badge>}
|
{st === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
|
||||||
|
{st === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
|
||||||
|
{c.invoiceNinjaClientId && <Badge tone="info"><FaFileInvoiceDollar /> Ninja</Badge>}
|
||||||
|
{c.paperlessCorrespondent && <Badge tone="info"><FaFileLines /> Docs</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -83,34 +99,37 @@ export default function CustomersPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateCustomerForm({ onCancel, onDone }) {
|
function CreateLeadForm({ onCancel, onDone }) {
|
||||||
const [form, setForm] = useState({ name: '', code: '', location: '', email: '', phone: '' })
|
const [company, setCompany] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.name) { setError('Name erforderlich'); return }
|
if (!company.trim()) { setError('Unternehmen erforderlich'); return }
|
||||||
setBusy(true); setError(null)
|
setBusy(true); setError(null)
|
||||||
const r = await onDone(form)
|
try {
|
||||||
if (!r.success) { setError(r.error || 'Fehler'); setBusy(false) }
|
await onDone({ company: company.trim(), email: email.trim() })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Fehler')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Neuer Kunde">
|
<Card title="Neuer Lead (potenzieller Kunde)">
|
||||||
|
<p className="muted" style={{ marginTop: 0, fontSize: 13 }}>
|
||||||
|
Fuer einen Lead reichen Unternehmen und E-Mail. Es wird automatisch ein Akquise-Ticket erstellt.
|
||||||
|
</p>
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<div className="flex gap-3 wrap">
|
<div className="flex gap-3 wrap">
|
||||||
<Field label="Name"><input className="form-control" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /></Field>
|
<Field label="Unternehmen"><input className="form-control" value={company} onChange={(e) => setCompany(e.target.value)} required /></Field>
|
||||||
<Field label="Code"><input className="form-control" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></Field>
|
<Field label="E-Mail"><input className="form-control" type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></Field>
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 wrap">
|
|
||||||
<Field label="Ort"><input className="form-control" value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} /></Field>
|
|
||||||
<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>
|
</div>
|
||||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Anlegen'}</Button>
|
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Lead anlegen + Akquise-Ticket'}</Button>
|
||||||
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
|
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user