Finanzen nur noch anzeigen, Dashboard und Admin-Konfiguration entfernt (Paket D)
- Finanzen: Liste, Zahlen, PDF und Link zu InvoiceNinja bleiben. Das Formular "Rechnung erstellen" (auf der Seite und als "Rechnung aus Ticket erstellen" im Ticket) ist weg; Rechnungen entstehen direkt in InvoiceNinja. Das Verknüpfen von Kunden mit InvoiceNinja bleibt. - Filter "Bezahlt" repariert: InvoiceNinja liefert status_id als Text. - FinancePage.jsx und InvoicePanel.jsx waren Windows-1252 kodiert und zeigten live "�"; jetzt UTF-8, dazu Umlaute in den Texten. - Dashboard entfernt (doppelte Finanzzahlen, "Offen" zählte falsch); alte Links führen zu den Tickets. - Admin: Reiter "Konfiguration" (nur noch Prioritäten, die seit Paket B niemand liest) mit useAdminConfig entfernt; Admin startet bei Kunden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
34c4db0228
commit
1a8604f7e6
12 files changed
+40
-617
No files matched your search
@@ -1,7 +1,8 @@
|
|||||||
# WOMS – Ticketsystem von WEBklar
|
# WOMS – Ticketsystem von WEBklar
|
||||||
|
|
||||||
Internes Ticketsystem unter https://ticket.webklar.com: Tickets, Arbeitsblätter mit
|
Internes Ticketsystem unter https://ticket.webklar.com: Tickets, Arbeitsblätter mit
|
||||||
Zeiterfassung, Kunden und Leads, Website-Projekte, Planboard und Rechnungen.
|
Zeiterfassung, Kunden und Leads, Website-Projekte, Planboard und Rechnungen (nur
|
||||||
|
anzeigen; Rechnungen werden in InvoiceNinja geschrieben).
|
||||||
React/Vite im Browser, Daten in Appwrite (Projekt `6a1058610003c5a13a05`,
|
React/Vite im Browser, Daten in Appwrite (Projekt `6a1058610003c5a13a05`,
|
||||||
Datenbank `woms-database`, API unter `https://ticket.webklar.com/v1`).
|
Datenbank `woms-database`, API unter `https://ticket.webklar.com/v1`).
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ Datum speichert WOMS als Text `dd.mm.yyyy`, Uhrzeiten als `hhmm` (z. B. `0930`).
|
|||||||
| `websiteProjects` | Website-Projekte und Vorschauen | `customerId` (steuert, was der Kunde im Portal sieht), `ticketId` (Ziel der Git-Push-Arbeitsblätter), Subdomain, Repo |
|
| `websiteProjects` | Website-Projekte und Vorschauen | `customerId` (steuert, was der Kunde im Portal sieht), `ticketId` (Ziel der Git-Push-Arbeitsblätter), Subdomain, Repo |
|
||||||
| `leads`, `leadPipeline`, `leadSettings` | Lead-Automatik | Recherche-Ergebnisse, Pipeline-Schritte, Sucheinstellungen |
|
| `leads`, `leadPipeline`, `leadSettings` | Lead-Automatik | Recherche-Ergebnisse, Pipeline-Schritte, Sucheinstellungen |
|
||||||
| `roadmaps`, `roadmapNodes` | Projektpläne | Plan mit Schritten, verknüpft mit einem Ticket |
|
| `roadmaps`, `roadmapNodes` | Projektpläne | Plan mit Schritten, verknüpft mit einem Ticket |
|
||||||
| `config` | Einstellungen | Prioritäten (dazu alte Listen, die nichts mehr liest) |
|
| `config` | alte Einstellungen | wird von WOMS nicht mehr gelesen (Prioritäten, alte Listen) |
|
||||||
|
|
||||||
Zusammenspiel mit anderen Systemen:
|
Zusammenspiel mit anderen Systemen:
|
||||||
|
|
||||||
@@ -36,5 +37,6 @@ Zusammenspiel mit anderen Systemen:
|
|||||||
nachgetragen, danach zählt sie als Arbeitszeit. Termine aus der Rechnungsanalyse
|
nachgetragen, danach zählt sie als Arbeitszeit. Termine aus der Rechnungsanalyse
|
||||||
landen am Akquise-Ticket des Kunden und erscheinen im Planboard. Kunden-, Mitarbeiter-
|
landen am Akquise-Ticket des Kunden und erscheinen im Planboard. Kunden-, Mitarbeiter-
|
||||||
und Projektverwaltung laufen über die Admin-API des Portals.
|
und Projektverwaltung laufen über die Admin-API des Portals.
|
||||||
- **ticket-integrations** (`/api/integrations`): Proxy zu InvoiceNinja (Rechnungen)
|
- **ticket-integrations** (`/api/integrations`): Proxy zu InvoiceNinja (Rechnungen
|
||||||
und Paperless (Dokumente).
|
anzeigen, Kunden verknüpfen). Die Paperless-Routen dort nutzt WOMS nicht mehr;
|
||||||
|
Paperless selbst ist gestoppt.
|
||||||
@@ -4,7 +4,6 @@ import Sidebar from './components/Navbar'
|
|||||||
import Footer from './components/Footer'
|
import Footer from './components/Footer'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import TicketsPage from './pages/TicketsPage'
|
import TicketsPage from './pages/TicketsPage'
|
||||||
import DashboardPage from './pages/DashboardPage'
|
|
||||||
import PlanboardPage from './pages/PlanboardPage'
|
import PlanboardPage from './pages/PlanboardPage'
|
||||||
import ProjectsPage from './pages/ProjectsPage'
|
import ProjectsPage from './pages/ProjectsPage'
|
||||||
import RoadmapsPage from './pages/RoadmapsPage'
|
import RoadmapsPage from './pages/RoadmapsPage'
|
||||||
@@ -46,7 +45,6 @@ function AppRoutes() {
|
|||||||
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
|
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
|
||||||
<Route path="/leads" element={<ProtectedRoute><LeadsPage /></ProtectedRoute>} />
|
<Route path="/leads" element={<ProtectedRoute><LeadsPage /></ProtectedRoute>} />
|
||||||
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
|
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
|
||||||
<Route path="/dashboard" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
|
||||||
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
||||||
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
|
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
|
||||||
<Route path="/roadmaps" element={<ProtectedRoute><RoadmapsPage /></ProtectedRoute>} />
|
<Route path="/roadmaps" element={<ProtectedRoute><RoadmapsPage /></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function InvoicePanel({ ticket }) {
|
|||||||
}, [loadCustomer])
|
}, [loadCustomer])
|
||||||
|
|
||||||
const clientId = customer?.invoiceNinjaClientId || null
|
const clientId = customer?.invoiceNinjaClientId || null
|
||||||
const { invoices, loading, error, refresh, createInvoice } = useInvoices(clientId)
|
const { invoices, loading, error, refresh } = useInvoices(clientId)
|
||||||
|
|
||||||
if (customerLoading) {
|
if (customerLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -54,7 +54,7 @@ export default function InvoicePanel({ ticket }) {
|
|||||||
if (!ticket.customerId) {
|
if (!ticket.customerId) {
|
||||||
return (
|
return (
|
||||||
<div style={card}>
|
<div style={card}>
|
||||||
<p style={{ color: '#a0aec0', margin: 0 }}>Kein Kunde am Ticket verknuepft - keine Rechnungen verfuegbar.</p>
|
<p style={{ color: '#a0aec0', margin: 0 }}>Kein Kunde am Ticket verknüpft, keine Rechnungen verfügbar.</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -77,23 +77,16 @@ export default function InvoicePanel({ ticket }) {
|
|||||||
|
|
||||||
{customer && customerStage(customer) === 'lead' ? (
|
{customer && customerStage(customer) === 'lead' ? (
|
||||||
<p style={{ color: '#a0aec0', margin: 0 }}>
|
<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.
|
Dieser Kunde ist noch ein Lead. Erst zum festen Kunden machen (im Kunden-Bereich, vollständige Daten nötig), dann sind Rechnungen möglich.
|
||||||
</p>
|
</p>
|
||||||
) : !clientId && !isInvoiceReady(customer) ? (
|
) : !clientId && !isInvoiceReady(customer) ? (
|
||||||
<p style={{ color: '#a0aec0', margin: 0 }}>
|
<p style={{ color: '#a0aec0', margin: 0 }}>
|
||||||
Rechnungsdaten unvollstaendig. Bitte im Kunden-Bereich Unternehmen, E-Mail, Strasse, PLZ und Stadt ergaenzen.
|
Rechnungsdaten unvollständig. Bitte im Kunden-Bereich Unternehmen, E-Mail, Straße, PLZ und Stadt ergänzen.
|
||||||
</p>
|
</p>
|
||||||
) : !clientId ? (
|
) : !clientId ? (
|
||||||
<LinkClient customer={customer} onLinked={loadCustomer} />
|
<LinkClient customer={customer} onLinked={loadCustomer} />
|
||||||
) : (
|
) : (
|
||||||
<InvoiceList
|
<InvoiceList invoices={invoices} loading={loading} error={error} />
|
||||||
ticket={ticket}
|
|
||||||
clientId={clientId}
|
|
||||||
invoices={invoices}
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
createInvoice={createInvoice}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -163,13 +156,13 @@ function LinkClient({ customer, onLinked }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p style={{ color: '#a0aec0', fontSize: '13px', marginTop: 0 }}>
|
<p style={{ color: '#a0aec0', fontSize: '13px', marginTop: 0 }}>
|
||||||
Dieser Kunde ist noch nicht mit InvoiceNinja verknuepft.
|
Dieser Kunde ist noch nicht mit InvoiceNinja verknüpft.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
|
||||||
<input
|
<input
|
||||||
className="form-control"
|
className="form-control"
|
||||||
style={{ margin: 0 }}
|
style={{ margin: 0 }}
|
||||||
placeholder="InvoiceNinja-Kunde suchen..."
|
placeholder="InvoiceNinja-Kunde suchen …"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && doSearch()}
|
onKeyDown={(e) => e.key === 'Enter' && doSearch()}
|
||||||
@@ -186,11 +179,11 @@ 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' }}> � {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}
|
{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)}>
|
||||||
<FaLink /> Verknuepfen
|
<FaLink /> Verknüpfen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -200,15 +193,15 @@ function LinkClient({ customer, onLinked }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="btn" style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '10px 16px', borderRadius: '8px', cursor: 'pointer' }} disabled={saving} onClick={createAndLink}>
|
<button className="btn" style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '10px 16px', borderRadius: '8px', cursor: 'pointer' }} disabled={saving} onClick={createAndLink}>
|
||||||
{saving ? <FaSpinner className="spinner" /> : <><FaPlus /> Neuen InvoiceNinja-Kunden anlegen & verknuepfen</>}
|
{saving ? <FaSpinner className="spinner" /> : <><FaPlus /> Neuen InvoiceNinja-Kunden anlegen & verknüpfen</>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------- Rechnungsliste ---------
|
// ---------------------------------------------------- Rechnungsliste ---------
|
||||||
function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice }) {
|
// Nur anzeigen: neue Rechnungen entstehen direkt in InvoiceNinja
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
function InvoiceList({ invoices, loading, error }) {
|
||||||
const [openingId, setOpeningId] = useState(null)
|
const [openingId, setOpeningId] = useState(null)
|
||||||
|
|
||||||
const openPdf = async (id) => {
|
const openPdf = async (id) => {
|
||||||
@@ -216,7 +209,7 @@ function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice
|
|||||||
try {
|
try {
|
||||||
await integrationsApi.openInvoicePdf(id)
|
await integrationsApi.openInvoicePdf(id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('PDF konnte nicht geoeffnet werden: ' + err.message)
|
alert('PDF konnte nicht geöffnet werden: ' + err.message)
|
||||||
} finally {
|
} finally {
|
||||||
setOpeningId(null)
|
setOpeningId(null)
|
||||||
}
|
}
|
||||||
@@ -224,40 +217,23 @@ function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button className="btn btn-green" style={{ marginBottom: '12px' }} onClick={() => setShowCreate((s) => !s)}>
|
|
||||||
<FaPlus /> Rechnung aus Ticket erstellen
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showCreate && (
|
|
||||||
<CreateInvoiceForm
|
|
||||||
ticket={ticket}
|
|
||||||
clientId={clientId}
|
|
||||||
onCancel={() => setShowCreate(false)}
|
|
||||||
onSubmit={async (payload) => {
|
|
||||||
const r = await createInvoice(payload)
|
|
||||||
if (r.success) setShowCreate(false)
|
|
||||||
return r
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Rechnungen werden geladen...</p>
|
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Rechnungen werden geladen...</p>
|
||||||
) : invoices.length === 0 ? (
|
) : invoices.length === 0 ? (
|
||||||
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Noch keine Rechnungen fuer diesen Kunden.</p>
|
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Noch keine Rechnungen für diesen Kunden.</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className="table" style={{ width: '100%', fontSize: '13px' }}>
|
<table className="table" style={{ width: '100%', fontSize: '13px' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ color: '#a0aec0', textAlign: 'left' }}>
|
<tr style={{ color: '#a0aec0', textAlign: 'left' }}>
|
||||||
<th>Nr.</th><th>Datum</th><th>Faellig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th>
|
<th>Nr.</th><th>Datum</th><th>Fällig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{invoices.map((inv) => {
|
{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()
|
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}>
|
||||||
@@ -266,9 +242,9 @@ function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice
|
|||||||
<td style={{ color: overdue ? '#ef4444' : '#cbd5e0' }}>{inv.due_date || '-'}</td>
|
<td style={{ color: overdue ? '#ef4444' : '#cbd5e0' }}>{inv.due_date || '-'}</td>
|
||||||
<td style={{ color: 'var(--dark-text)' }}>{formatMoney(inv.amount)}</td>
|
<td style={{ color: 'var(--dark-text)' }}>{formatMoney(inv.amount)}</td>
|
||||||
<td style={{ color: Number(inv.balance) > 0 ? '#f59e0b' : '#10b981' }}>{formatMoney(inv.balance)}</td>
|
<td style={{ color: Number(inv.balance) > 0 ? '#f59e0b' : '#10b981' }}>{formatMoney(inv.balance)}</td>
|
||||||
<td><span style={{ color: st.color, fontWeight: 'bold' }}>{st.label}{overdue ? ' (ueberfaellig)' : ''}</span></td>
|
<td><span style={{ color: st.color, fontWeight: 'bold' }}>{st.label}{overdue ? ' (überfällig)' : ''}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<button className="btn" title="PDF oeffnen" disabled={openingId === inv.id}
|
<button className="btn" title="PDF öffnen" disabled={openingId === inv.id}
|
||||||
style={{ background: '#ef4444', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
|
style={{ background: '#ef4444', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
|
||||||
onClick={() => openPdf(inv.id)}>
|
onClick={() => openPdf(inv.id)}>
|
||||||
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
|
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
|
||||||
@@ -284,60 +260,3 @@ function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------- Rechnung anlegen -------
|
|
||||||
function CreateInvoiceForm({ ticket, clientId, onCancel, onSubmit }) {
|
|
||||||
const [description, setDescription] = useState(ticket.topic || ticket.title || '')
|
|
||||||
const [cost, setCost] = useState('')
|
|
||||||
const [quantity, setQuantity] = useState('1')
|
|
||||||
const [markSent, setMarkSent] = useState(false)
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
const submit = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setBusy(true)
|
|
||||||
setError(null)
|
|
||||||
const r = await onSubmit({
|
|
||||||
client_id: clientId,
|
|
||||||
po_number: ticket.woid || '',
|
|
||||||
public_notes: `Ticket ${ticket.woid || ''}: ${ticket.topic || ''}`.trim(),
|
|
||||||
line_items: [{ notes: description, cost: Number(cost || 0), quantity: Number(quantity || 1) }],
|
|
||||||
markSent,
|
|
||||||
})
|
|
||||||
if (!r.success) {
|
|
||||||
setError(r.error || 'Fehler beim Anlegen')
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={submit} style={{ ...card, marginBottom: '12px' }}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Leistung / Beschreibung</label>
|
|
||||||
<textarea className="form-control" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} required />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
|
||||||
<div className="form-group" style={{ flex: 1 }}>
|
|
||||||
<label className="form-label">Einzelpreis (EUR)</label>
|
|
||||||
<input type="number" step="0.01" className="form-control" value={cost} onChange={(e) => setCost(e.target.value)} required />
|
|
||||||
</div>
|
|
||||||
<div className="form-group" style={{ flex: 1 }}>
|
|
||||||
<label className="form-label">Menge</label>
|
|
||||||
<input type="number" step="1" className="form-control" value={quantity} onChange={(e) => setQuantity(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#cbd5e0', marginBottom: '12px' }}>
|
|
||||||
<input type="checkbox" checked={markSent} onChange={(e) => setMarkSent(e.target.checked)} />
|
|
||||||
Direkt als "versendet" markieren
|
|
||||||
</label>
|
|
||||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<button type="submit" className="btn btn-green" disabled={busy}>
|
|
||||||
{busy ? <FaSpinner className="spinner" /> : 'Rechnung erstellen'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn" style={{ background: '#374151', color: 'white' }} onClick={onCancel}>Abbrechen</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
IconFolders,
|
IconFolders,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconRoute,
|
IconRoute,
|
||||||
IconLayoutDashboard,
|
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconLayoutSidebarLeftCollapse,
|
IconLayoutSidebarLeftCollapse,
|
||||||
@@ -48,12 +47,6 @@ export default function Sidebar() {
|
|||||||
label: 'Finanzen',
|
label: 'Finanzen',
|
||||||
links: [{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> }],
|
links: [{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> }],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Auswertung',
|
|
||||||
links: [
|
|
||||||
{ label: 'Dashboard', href: '/dashboard', icon: <IconLayoutDashboard /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'System',
|
label: 'System',
|
||||||
links: [
|
links: [
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, ID } from '../lib/appwrite'
|
|
||||||
|
|
||||||
// Standardwerte, solange das Config-Dokument fehlt oder nicht lesbar ist
|
|
||||||
const DEFAULT_CONFIG = {
|
|
||||||
ticketTypes: [
|
|
||||||
'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'
|
|
||||||
],
|
|
||||||
systems: [
|
|
||||||
'Account View', 'Client', 'Cofano', 'Credentials', 'Diamant', 'Docuware',
|
|
||||||
'EDI', 'eMail', 'Employee', 'Invoice', 'LBase', 'Medical Office', 'Network',
|
|
||||||
'O365', 'PDF Viewer', 'Printer', 'Reports', 'Server', 'Time Tracking',
|
|
||||||
'TK', 'TOS', 'Vivendi NG', 'VGM', '(W)LAN', '(W)WAN', 'WOMS', 'n/a'
|
|
||||||
],
|
|
||||||
responseLevels: [
|
|
||||||
'USER', 'KEY USER', 'Helpdesk', 'Support', 'Admin', 'FS/FE', '24/7',
|
|
||||||
'TECH MGMT', 'Backoffice', 'BUSI MGMT', 'n/a'
|
|
||||||
],
|
|
||||||
serviceTypes: ['Remote', 'On Site', 'Off Site'],
|
|
||||||
priorities: [
|
|
||||||
{ value: 0, label: 'None' },
|
|
||||||
{ value: 1, label: 'Low' },
|
|
||||||
{ value: 2, label: 'Medium' },
|
|
||||||
{ value: 3, label: 'High' },
|
|
||||||
{ value: 4, label: 'Critical' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePriorities(values) {
|
|
||||||
if (!Array.isArray(values)) return DEFAULT_CONFIG.priorities
|
|
||||||
const parsed = values.map((item, index) => {
|
|
||||||
if (typeof item === 'object' && item !== null) return item
|
|
||||||
if (typeof item === 'string') {
|
|
||||||
try {
|
|
||||||
const value = JSON.parse(item)
|
|
||||||
if (value && typeof value === 'object') return value
|
|
||||||
} catch {
|
|
||||||
return { value: index, label: item }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}).filter(Boolean)
|
|
||||||
return parsed.length > 0 ? parsed : DEFAULT_CONFIG.priorities
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAdminConfig() {
|
|
||||||
const [config, setConfig] = useState(DEFAULT_CONFIG)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
const fetchConfig = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
// Versuche Config-Dokument zu laden (ID: 'config')
|
|
||||||
try {
|
|
||||||
const doc = await databases.getDocument(
|
|
||||||
DATABASE_ID,
|
|
||||||
COLLECTIONS.CONFIG || 'config',
|
|
||||||
'config'
|
|
||||||
)
|
|
||||||
setConfig({
|
|
||||||
ticketTypes: doc.ticketTypes || DEFAULT_CONFIG.ticketTypes,
|
|
||||||
systems: doc.systems || DEFAULT_CONFIG.systems,
|
|
||||||
responseLevels: doc.responseLevels || DEFAULT_CONFIG.responseLevels,
|
|
||||||
serviceTypes: doc.serviceTypes || DEFAULT_CONFIG.serviceTypes,
|
|
||||||
priorities: normalizePriorities(doc.priorities)
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
// Config existiert noch nicht (404) - das ist normal, verwende Defaults
|
|
||||||
if (e.code === 404 || e.message?.includes('not found')) {
|
|
||||||
setConfig(DEFAULT_CONFIG)
|
|
||||||
setError(null) // Kein Fehler, Collection existiert einfach noch nicht
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching config:', err)
|
|
||||||
// Nur echte Fehler als Error setzen, nicht 404
|
|
||||||
if (err.code !== 404 && !err.message?.includes('not found')) {
|
|
||||||
setError(err.message)
|
|
||||||
} else {
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
setConfig(DEFAULT_CONFIG) // Fallback zu Defaults
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchConfig()
|
|
||||||
}, [fetchConfig])
|
|
||||||
|
|
||||||
const updateConfig = async (newConfig) => {
|
|
||||||
try {
|
|
||||||
const configData = {
|
|
||||||
ticketTypes: newConfig.ticketTypes,
|
|
||||||
systems: newConfig.systems,
|
|
||||||
responseLevels: newConfig.responseLevels,
|
|
||||||
serviceTypes: newConfig.serviceTypes,
|
|
||||||
priorities: normalizePriorities(newConfig.priorities).map((priority) =>
|
|
||||||
JSON.stringify(priority)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Versuche zu aktualisieren
|
|
||||||
await databases.updateDocument(
|
|
||||||
DATABASE_ID,
|
|
||||||
COLLECTIONS.CONFIG || 'config',
|
|
||||||
'config',
|
|
||||||
configData
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
// Dokument existiert nicht (404) oder Collection existiert nicht
|
|
||||||
if (e.code === 404 || e.message?.includes('not found')) {
|
|
||||||
// Versuche zu erstellen
|
|
||||||
try {
|
|
||||||
await databases.createDocument(
|
|
||||||
DATABASE_ID,
|
|
||||||
COLLECTIONS.CONFIG || 'config',
|
|
||||||
'config',
|
|
||||||
configData
|
|
||||||
)
|
|
||||||
} catch (createErr) {
|
|
||||||
// Collection existiert nicht - zeige hilfreiche Fehlermeldung
|
|
||||||
if (createErr.code === 404 || createErr.message?.includes('Collection')) {
|
|
||||||
throw new Error('Die "config" Collection existiert noch nicht. Bitte erstelle sie zuerst in Appwrite.')
|
|
||||||
}
|
|
||||||
throw createErr
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setConfig(newConfig)
|
|
||||||
return { success: true }
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error updating config:', err)
|
|
||||||
return { success: false, error: err.message }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
config,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
updateConfig,
|
|
||||||
refresh: fetchConfig
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,18 +32,5 @@ export function useInvoices(clientId) {
|
|||||||
fetchInvoices()
|
fetchInvoices()
|
||||||
}, [fetchInvoices])
|
}, [fetchInvoices])
|
||||||
|
|
||||||
const createInvoice = useCallback(
|
return { invoices, loading, error, refresh: fetchInvoices }
|
||||||
async (payload) => {
|
|
||||||
try {
|
|
||||||
const data = await integrationsApi.createInvoice(payload)
|
|
||||||
await fetchInvoices()
|
|
||||||
return { success: true, invoice: data.invoice }
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err.message }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[fetchInvoices]
|
|
||||||
)
|
|
||||||
|
|
||||||
return { invoices, loading, error, refresh: fetchInvoices, createInvoice }
|
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,6 @@ export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'woms-da
|
|||||||
|
|
||||||
export const COLLECTIONS = {
|
export const COLLECTIONS = {
|
||||||
WORKORDERS: 'workorders',
|
WORKORDERS: 'workorders',
|
||||||
CONFIG: 'config',
|
|
||||||
CUSTOMERS: 'customers',
|
CUSTOMERS: 'customers',
|
||||||
EMPLOYEES: 'employees',
|
EMPLOYEES: 'employees',
|
||||||
WORKSHEETS: 'worksheets',
|
WORKSHEETS: 'worksheets',
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export const integrationsApi = {
|
|||||||
// ---- InvoiceNinja: invoices ----
|
// ---- InvoiceNinja: invoices ----
|
||||||
listInvoices: (clientId, perPage) => call(`/invoices${qs({ clientId, per_page: perPage })}`),
|
listInvoices: (clientId, perPage) => call(`/invoices${qs({ clientId, per_page: perPage })}`),
|
||||||
invoicesSummary: () => call('/invoices/summary'),
|
invoicesSummary: () => call('/invoices/summary'),
|
||||||
createInvoice: (payload) => call('/invoices', { method: 'POST', body: payload }),
|
|
||||||
async openInvoicePdf(id) {
|
async openInvoicePdf(id) {
|
||||||
const blob = await callBlob(`/invoices/${encodeURIComponent(id)}/pdf`)
|
const blob = await callBlob(`/invoices/${encodeURIComponent(id)}/pdf`)
|
||||||
await openBlobInNewTab(blob)
|
await openBlobInNewTab(blob)
|
||||||
|
|||||||
@@ -101,13 +101,6 @@
|
|||||||
.admin-notice.success { border-left-color: var(--ok); }
|
.admin-notice.success { border-left-color: var(--ok); }
|
||||||
.admin-notice.error { border-left-color: var(--danger); }
|
.admin-notice.error { border-left-color: var(--danger); }
|
||||||
|
|
||||||
.admin-config-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-config-card,
|
|
||||||
.admin-form-card,
|
.admin-form-card,
|
||||||
.admin-list-card {
|
.admin-list-card {
|
||||||
border: 1px solid var(--admin-line);
|
border: 1px solid var(--admin-line);
|
||||||
@@ -115,8 +108,6 @@
|
|||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-config-card.priorities { grid-column: 1 / -1; }
|
|
||||||
|
|
||||||
.admin-section-head {
|
.admin-section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 62px;
|
min-height: 62px;
|
||||||
@@ -134,21 +125,6 @@
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-config-list { padding: 10px; }
|
|
||||||
|
|
||||||
.admin-config-row,
|
|
||||||
.admin-priority-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-priority-row { grid-template-columns: 90px minmax(0, 1fr) auto; }
|
|
||||||
.admin-config-row:last-child,
|
|
||||||
.admin-priority-row:last-child { border-bottom: 0; }
|
|
||||||
|
|
||||||
.admin-icon-button {
|
.admin-icon-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-width: 34px;
|
min-width: 34px;
|
||||||
@@ -179,19 +155,6 @@
|
|||||||
background: rgba(239, 68, 68, 0.08);
|
background: rgba(239, 68, 68, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-savebar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 14px;
|
|
||||||
padding: 13px 16px;
|
|
||||||
border: 1px solid var(--admin-line);
|
|
||||||
background: var(--surface-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-savebar span { color: var(--text-muted); font-size: 13px; }
|
|
||||||
|
|
||||||
.admin-primary-button {
|
.admin-primary-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
@@ -449,8 +412,5 @@
|
|||||||
.admin-current-user { width: 100%; }
|
.admin-current-user { width: 100%; }
|
||||||
.admin-tabs { overflow-x: auto; }
|
.admin-tabs { overflow-x: auto; }
|
||||||
.admin-tabs button { flex: 0 0 auto; padding: 11px 12px; }
|
.admin-tabs button { flex: 0 0 auto; padding: 11px 12px; }
|
||||||
.admin-config-grid { grid-template-columns: 1fr; }
|
|
||||||
.admin-config-card.priorities { grid-column: auto; }
|
|
||||||
.admin-savebar { align-items: stretch; flex-direction: column; }
|
|
||||||
.admin-credential-result { grid-template-columns: 1fr; }
|
.admin-credential-result { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
+3
-121
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
FaFloppyDisk,
|
FaFloppyDisk,
|
||||||
FaPlus,
|
FaPlus,
|
||||||
@@ -11,22 +11,10 @@ import CopyableCredential, {
|
|||||||
getCustomerPortalPassword,
|
getCustomerPortalPassword,
|
||||||
} from '../components/CopyableCredential'
|
} from '../components/CopyableCredential'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useAdminConfig } from '../hooks/useAdminConfig'
|
|
||||||
import { useCustomers } from '../hooks/useCustomers'
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
import { useEmployees } from '../hooks/useEmployees'
|
import { useEmployees } from '../hooks/useEmployees'
|
||||||
import './AdminPage.css'
|
import './AdminPage.css'
|
||||||
|
|
||||||
// Ticketarten, Systeme, Reaktions- und Leistungsarten gibt das Ticketformular
|
|
||||||
// fest vor; sie sind hier nicht mehr editierbar, bleiben beim Speichern aber
|
|
||||||
// im Config-Dokument erhalten. Editierbar sind nur noch die Prioritaeten.
|
|
||||||
const EMPTY_CONFIG = {
|
|
||||||
ticketTypes: [],
|
|
||||||
systems: [],
|
|
||||||
responseLevels: [],
|
|
||||||
serviceTypes: [],
|
|
||||||
priorities: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_CUSTOMER = {
|
const EMPTY_CUSTOMER = {
|
||||||
code: '',
|
code: '',
|
||||||
name: '',
|
name: '',
|
||||||
@@ -51,7 +39,6 @@ function Notice({ notice }) {
|
|||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const { user, isAdmin } = useAuth()
|
const { user, isAdmin } = useAuth()
|
||||||
const { config, loading, error, updateConfig } = useAdminConfig()
|
|
||||||
const {
|
const {
|
||||||
customers,
|
customers,
|
||||||
loading: customersLoading,
|
loading: customersLoading,
|
||||||
@@ -68,9 +55,7 @@ export default function AdminPage() {
|
|||||||
deleteEmployee,
|
deleteEmployee,
|
||||||
} = useEmployees()
|
} = useEmployees()
|
||||||
|
|
||||||
const [activeSection, setActiveSection] = useState('config')
|
const [activeSection, setActiveSection] = useState('customers')
|
||||||
const [localConfig, setLocalConfig] = useState(EMPTY_CONFIG)
|
|
||||||
const [savingConfig, setSavingConfig] = useState(false)
|
|
||||||
const [customerForm, setCustomerForm] = useState(EMPTY_CUSTOMER)
|
const [customerForm, setCustomerForm] = useState(EMPTY_CUSTOMER)
|
||||||
const [editingCustomer, setEditingCustomer] = useState(null)
|
const [editingCustomer, setEditingCustomer] = useState(null)
|
||||||
const [customerBusy, setCustomerBusy] = useState(false)
|
const [customerBusy, setCustomerBusy] = useState(false)
|
||||||
@@ -80,12 +65,6 @@ export default function AdminPage() {
|
|||||||
const [createdCredential, setCreatedCredential] = useState(null)
|
const [createdCredential, setCreatedCredential] = useState(null)
|
||||||
const [notice, setNotice] = useState(null)
|
const [notice, setNotice] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (config && Object.keys(config).length > 0) {
|
|
||||||
setLocalConfig({ ...EMPTY_CONFIG, ...config })
|
|
||||||
}
|
|
||||||
}, [config])
|
|
||||||
|
|
||||||
const showNotice = (type, text) => {
|
const showNotice = (type, text) => {
|
||||||
setNotice({ type, text })
|
setNotice({ type, text })
|
||||||
window.setTimeout(() => setNotice(null), 4500)
|
window.setTimeout(() => setNotice(null), 4500)
|
||||||
@@ -103,45 +82,6 @@ export default function AdminPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addConfigItem = (field) => {
|
|
||||||
setLocalConfig((current) => ({
|
|
||||||
...current,
|
|
||||||
[field]: [
|
|
||||||
...(current[field] || []),
|
|
||||||
field === 'priorities'
|
|
||||||
? { value: current.priorities?.length || 0, label: 'Neue Priorität' }
|
|
||||||
: '',
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeConfigItem = (field, index) => {
|
|
||||||
setLocalConfig((current) => ({
|
|
||||||
...current,
|
|
||||||
[field]: (current[field] || []).filter((_, itemIndex) => itemIndex !== index),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateConfigItem = (field, index, value) => {
|
|
||||||
setLocalConfig((current) => {
|
|
||||||
const next = [...(current[field] || [])]
|
|
||||||
next[index] = value
|
|
||||||
return { ...current, [field]: next }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveConfiguration = async () => {
|
|
||||||
setSavingConfig(true)
|
|
||||||
const result = await updateConfig(localConfig)
|
|
||||||
setSavingConfig(false)
|
|
||||||
showNotice(
|
|
||||||
result.success ? 'success' : 'error',
|
|
||||||
result.success
|
|
||||||
? 'Konfiguration gespeichert.'
|
|
||||||
: `Speichern fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetCustomerForm = () => {
|
const resetCustomerForm = () => {
|
||||||
setCustomerForm(EMPTY_CUSTOMER)
|
setCustomerForm(EMPTY_CUSTOMER)
|
||||||
setEditingCustomer(null)
|
setEditingCustomer(null)
|
||||||
@@ -261,7 +201,7 @@ export default function AdminPage() {
|
|||||||
<div>
|
<div>
|
||||||
<span className="admin-eyebrow">Systemverwaltung</span>
|
<span className="admin-eyebrow">Systemverwaltung</span>
|
||||||
<h1>Admin</h1>
|
<h1>Admin</h1>
|
||||||
<p>Konfiguration, Kundenkonten und Mitarbeiterzugänge zentral verwalten.</p>
|
<p>Kundenkonten und Mitarbeiterzugänge verwalten.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-current-user">
|
<div className="admin-current-user">
|
||||||
<span>Angemeldet als</span>
|
<span>Angemeldet als</span>
|
||||||
@@ -270,11 +210,9 @@ export default function AdminPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Notice notice={notice} />
|
<Notice notice={notice} />
|
||||||
{error && <Notice notice={{ type: 'error', text: error }} />}
|
|
||||||
|
|
||||||
<nav className="admin-tabs" aria-label="Admin-Bereiche">
|
<nav className="admin-tabs" aria-label="Admin-Bereiche">
|
||||||
{[
|
{[
|
||||||
['config', 'Konfiguration'],
|
|
||||||
['customers', `Kunden (${customers.length})`],
|
['customers', `Kunden (${customers.length})`],
|
||||||
['employees', `Mitarbeiter (${employees.length})`],
|
['employees', `Mitarbeiter (${employees.length})`],
|
||||||
].map(([id, label]) => (
|
].map(([id, label]) => (
|
||||||
@@ -289,62 +227,6 @@ export default function AdminPage() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{activeSection === 'config' && (
|
|
||||||
<section>
|
|
||||||
{loading && !config ? (
|
|
||||||
<div className="admin-loading"><FaSpinner className="spinner" /> Konfiguration wird geladen …</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="admin-config-grid">
|
|
||||||
<section className="admin-config-card priorities">
|
|
||||||
<div className="admin-section-head">
|
|
||||||
<div><h3>Prioritäten</h3><span>{localConfig.priorities?.length || 0} Einträge</span></div>
|
|
||||||
<button type="button" className="admin-icon-button" onClick={() => addConfigItem('priorities')}>
|
|
||||||
<FaPlus /><span>Hinzufügen</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="admin-config-list">
|
|
||||||
{(localConfig.priorities || []).map((priority, index) => (
|
|
||||||
<div className="admin-priority-row" key={`priority-${index}`}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-control"
|
|
||||||
value={priority.value}
|
|
||||||
aria-label="Prioritätswert"
|
|
||||||
onChange={(event) => updateConfigItem('priorities', index, {
|
|
||||||
...priority,
|
|
||||||
value: Number(event.target.value),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
value={priority.label}
|
|
||||||
aria-label="Prioritätsname"
|
|
||||||
onChange={(event) => updateConfigItem('priorities', index, {
|
|
||||||
...priority,
|
|
||||||
label: event.target.value,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<button type="button" className="admin-icon-button danger" onClick={() => removeConfigItem('priorities', index)}>
|
|
||||||
<FaTrash />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<div className="admin-savebar">
|
|
||||||
<span>Änderungen gelten für neue und bestehende Ticketformulare.</span>
|
|
||||||
<button type="button" className="admin-primary-button" disabled={savingConfig} onClick={saveConfiguration}>
|
|
||||||
{savingConfig ? <FaSpinner className="spin" /> : <FaFloppyDisk />}
|
|
||||||
Konfiguration speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeSection === 'customers' && (
|
{activeSection === 'customers' && (
|
||||||
<section className="admin-split-layout">
|
<section className="admin-split-layout">
|
||||||
<form className="admin-form-card" onSubmit={submitCustomer}>
|
<form className="admin-form-card" onSubmit={submitCustomer}>
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
|
||||||
import { FaTicket, FaClipboardList, FaClock, FaCircleCheck, FaPlus, FaFileInvoiceDollar } from 'react-icons/fa6'
|
|
||||||
import { formatMoney } from '../lib/integrationsApi'
|
|
||||||
import { useFinanceSummary } from '../hooks/useFinanceSummary'
|
|
||||||
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
|
|
||||||
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const { summary, loading: finLoading } = useFinanceSummary()
|
|
||||||
const [stats, setStats] = useState({ total: 0, open: 0, awaiting: 0, closed: 0 })
|
|
||||||
const [recent, setRecent] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
const [total, open, closed, awaiting, recentRes] = await Promise.all([
|
|
||||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.limit(1)]),
|
|
||||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Open'), Query.limit(1)]),
|
|
||||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Closed'), Query.limit(1)]),
|
|
||||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Awaiting'), Query.limit(1)]),
|
|
||||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.orderDesc('$createdAt'), Query.limit(6)]),
|
|
||||||
])
|
|
||||||
if (!active) return
|
|
||||||
setStats({ total: total.total, open: open.total, awaiting: awaiting.total, closed: closed.total })
|
|
||||||
setRecent(recentRes.documents)
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
if (active) setLoading(false)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return () => { active = false }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<PageHeader
|
|
||||||
title="Dashboard"
|
|
||||||
subtitle="Überblick über Tickets und Finanzen"
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<Button variant="ghost" as={Link} to="/finance"><FaFileInvoiceDollar /> Finanzen</Button>
|
|
||||||
<Button variant="primary" as={Link} to="/tickets"><FaPlus /> Neues Ticket</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="kpi-grid">
|
|
||||||
<Kpi label={<><FaTicket /> Tickets gesamt</>} value={loading ? '...' : stats.total} accent="var(--info)" />
|
|
||||||
<Kpi label={<><FaClipboardList /> Offen</>} value={loading ? '...' : stats.open} accent="var(--ok)" />
|
|
||||||
<Kpi label={<><FaClock /> Wartend</>} value={loading ? '...' : stats.awaiting} accent="var(--warn)" />
|
|
||||||
<Kpi label={<><FaCircleCheck /> Geschlossen</>} value={loading ? '...' : stats.closed} accent="var(--text-faint)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="kpi-grid">
|
|
||||||
<Kpi label="Offener Betrag" value={finLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
|
|
||||||
<Kpi label="Ueberfaellig" value={finLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} ueberfaellig`} accent="var(--danger)" />
|
|
||||||
<Kpi label="Bezahlt (Monat)" value={finLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 16 }}>
|
|
||||||
<Card title="Letzte Tickets" pad={false}>
|
|
||||||
{loading ? (
|
|
||||||
<div className="empty">...</div>
|
|
||||||
) : recent.length === 0 ? (
|
|
||||||
<EmptyState title="Keine Tickets" />
|
|
||||||
) : (
|
|
||||||
<div className="list">
|
|
||||||
{recent.map((t) => (
|
|
||||||
<Link key={t.$id} to="/tickets" className="list-row">
|
|
||||||
<span className="mono" style={{ color: 'var(--accent)', minWidth: 56 }}>{t.woid || t.$id.slice(-5)}</span>
|
|
||||||
<div className="grow">
|
|
||||||
<div className="text-ellipsis" style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
|
|
||||||
<div className="muted">{t.customerName || '-'}</div>
|
|
||||||
</div>
|
|
||||||
<UrgentPill priority={t.priority} />
|
|
||||||
<StatusPill status={t.status} />
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Schnellaktionen">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button as={Link} to="/tickets"><FaPlus /> Neues Ticket erstellen</Button>
|
|
||||||
<Button as={Link} to="/finance"><FaFileInvoiceDollar /> Rechnung erstellen</Button>
|
|
||||||
<Button as={Link} to="/customers" variant="ghost">Kunden verwalten</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+12
-74
@@ -1,20 +1,17 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate, FaPlus } from 'react-icons/fa6'
|
import { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate } from 'react-icons/fa6'
|
||||||
import { integrationsApi, formatMoney, INVOICE_STATUS, INVOICE_NINJA_PUBLIC_URL } from '../lib/integrationsApi'
|
import { integrationsApi, formatMoney, INVOICE_STATUS, INVOICE_NINJA_PUBLIC_URL } from '../lib/integrationsApi'
|
||||||
import { useFinanceSummary } from '../hooks/useFinanceSummary'
|
import { useFinanceSummary } from '../hooks/useFinanceSummary'
|
||||||
import { useCustomers } from '../hooks/useCustomers'
|
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
|
||||||
import { PageHeader, Card, Button, Kpi, EmptyState, Field } from '../components/ui'
|
|
||||||
|
|
||||||
export default function FinancePage() {
|
export default function FinancePage() {
|
||||||
const { summary, loading: sumLoading, refresh: refreshSummary } = useFinanceSummary()
|
const { summary, loading: sumLoading, refresh: refreshSummary } = useFinanceSummary()
|
||||||
const { customers } = useCustomers()
|
|
||||||
const [invoices, setInvoices] = useState([])
|
const [invoices, setInvoices] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [filter, setFilter] = useState('all') // all | open | overdue | paid
|
const [filter, setFilter] = useState('all') // all | open | overdue | paid
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [openingId, setOpeningId] = useState(null)
|
const [openingId, setOpeningId] = useState(null)
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -37,7 +34,8 @@ export default function FinancePage() {
|
|||||||
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
|
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
|
||||||
if (filter === 'open' && !(Number(inv.balance) > 0)) return false
|
if (filter === 'open' && !(Number(inv.balance) > 0)) return false
|
||||||
if (filter === 'overdue' && !overdue) return false
|
if (filter === 'overdue' && !overdue) return false
|
||||||
if (filter === 'paid' && inv.status_id !== 4) return false
|
// InvoiceNinja liefert status_id als Text ("4" = bezahlt)
|
||||||
|
if (filter === 'paid' && String(inv.status_id) !== '4') return false
|
||||||
if (search) {
|
if (search) {
|
||||||
const s = search.toLowerCase()
|
const s = search.toLowerCase()
|
||||||
if (!`${inv.number} ${inv.client_name}`.toLowerCase().includes(s)) return false
|
if (!`${inv.number} ${inv.client_name}`.toLowerCase().includes(s)) return false
|
||||||
@@ -57,39 +55,32 @@ export default function FinancePage() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Finanzen"
|
title="Finanzen"
|
||||||
subtitle="Rechnungen aus InvoiceNinja - Uebersicht, Status und schnelle Aktionen"
|
subtitle="Rechnungen aus InvoiceNinja: Übersicht und Status. Neue Rechnungen direkt in InvoiceNinja schreiben."
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" as="a" href={INVOICE_NINJA_PUBLIC_URL} target="_blank" rel="noopener noreferrer">
|
<Button variant="ghost" as="a" href={INVOICE_NINJA_PUBLIC_URL} target="_blank" rel="noopener noreferrer">
|
||||||
<FaArrowUpRightFromSquare /> InvoiceNinja
|
<FaArrowUpRightFromSquare /> InvoiceNinja
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={() => { load(); refreshSummary() }}><FaRotate /> Aktualisieren</Button>
|
<Button variant="ghost" onClick={() => { load(); refreshSummary() }}><FaRotate /> Aktualisieren</Button>
|
||||||
<Button variant="primary" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Rechnung erstellen</Button>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="kpi-grid">
|
<div className="kpi-grid">
|
||||||
<Kpi label="Offener Betrag" value={sumLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
|
<Kpi label="Offener Betrag" value={sumLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
|
||||||
<Kpi label="Ueberfaellig" value={sumLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} ueberfaellig`} accent="var(--danger)" />
|
<Kpi label="Überfällig" value={sumLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} überfällig`} accent="var(--danger)" />
|
||||||
<Kpi label="Bezahlt (Monat)" value={sumLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
|
<Kpi label="Bezahlt (Monat)" value={sumLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
|
||||||
<Kpi label="Rechnungen gesamt" value={sumLoading ? '...' : (summary?.total ?? 0)} accent="var(--info)" />
|
<Kpi label="Rechnungen gesamt" value={sumLoading ? '...' : (summary?.total ?? 0)} accent="var(--info)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCreate && (
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<CreateInvoiceForm customers={customers} onDone={() => { setShowCreate(false); load(); refreshSummary() }} onCancel={() => setShowCreate(false)} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card pad={false}>
|
<Card pad={false}>
|
||||||
<div className="ui-card-head">
|
<div className="ui-card-head">
|
||||||
<div className="seg">
|
<div className="seg">
|
||||||
{[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Ueberfaellig'], ['paid', 'Bezahlt']].map(([id, label]) => (
|
{[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Überfällig'], ['paid', 'Bezahlt']].map(([id, label]) => (
|
||||||
<button key={id} className={filter === id ? 'active' : ''} onClick={() => setFilter(id)}>{label}</button>
|
<button key={id} className={filter === id ? 'active' : ''} onClick={() => setFilter(id)}>{label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<input className="form-control search-input" placeholder="Nummer oder Kunde suchen..." value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 280 }} />
|
<input className="form-control search-input" placeholder="Nummer oder Kunde suchen …" value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 280 }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="ui-card-body" style={{ padding: 0 }}>
|
<div className="ui-card-body" style={{ padding: 0 }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -97,16 +88,16 @@ export default function FinancePage() {
|
|||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="empty text-red">{error}</div>
|
<div className="empty text-red">{error}</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<EmptyState title="Keine Rechnungen" hint="Keine Rechnungen fuer diesen Filter." />
|
<EmptyState title="Keine Rechnungen" hint="Keine Rechnungen für diesen Filter." />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className="ui-table">
|
<table className="ui-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Nr.</th><th>Kunde</th><th>Datum</th><th>Faellig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr>
|
<tr><th>Nr.</th><th>Kunde</th><th>Datum</th><th>Fällig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map((inv) => {
|
{filtered.map((inv) => {
|
||||||
const st = INVOICE_STATUS[inv.status_id] || { label: '—', color: 'var(--text-muted)' }
|
const st = INVOICE_STATUS[inv.status_id] || { label: '–', color: 'var(--text-muted)' }
|
||||||
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
|
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
|
||||||
return (
|
return (
|
||||||
<tr key={inv.id}>
|
<tr key={inv.id}>
|
||||||
@@ -116,7 +107,7 @@ export default function FinancePage() {
|
|||||||
<td style={{ color: overdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date || '-'}</td>
|
<td style={{ color: overdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date || '-'}</td>
|
||||||
<td>{formatMoney(inv.amount)}</td>
|
<td>{formatMoney(inv.amount)}</td>
|
||||||
<td style={{ color: Number(inv.balance) > 0 ? 'var(--warn)' : 'var(--ok)' }}>{formatMoney(inv.balance)}</td>
|
<td style={{ color: Number(inv.balance) > 0 ? 'var(--warn)' : 'var(--ok)' }}>{formatMoney(inv.balance)}</td>
|
||||||
<td><span style={{ color: st.color, fontWeight: 700 }}>{st.label}{overdue ? ' (ueberf.)' : ''}</span></td>
|
<td><span style={{ color: st.color, fontWeight: 700 }}>{st.label}{overdue ? ' (überfällig)' : ''}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<Button size="sm" variant="ghost" disabled={openingId === inv.id} onClick={() => openPdf(inv.id)} title="PDF">
|
<Button size="sm" variant="ghost" disabled={openingId === inv.id} onClick={() => openPdf(inv.id)} title="PDF">
|
||||||
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
|
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
|
||||||
@@ -134,56 +125,3 @@ export default function FinancePage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateInvoiceForm({ customers, onDone, onCancel }) {
|
|
||||||
const linkable = (customers || []).filter((c) => c.invoiceNinjaClientId)
|
|
||||||
const [clientId, setClientId] = useState('')
|
|
||||||
const [description, setDescription] = useState('')
|
|
||||||
const [cost, setCost] = useState('')
|
|
||||||
const [quantity, setQuantity] = useState('1')
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
const submit = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!clientId) { setError('Bitte Kunde waehlen'); return }
|
|
||||||
setBusy(true); setError(null)
|
|
||||||
try {
|
|
||||||
await integrationsApi.createInvoice({
|
|
||||||
client_id: clientId,
|
|
||||||
public_notes: description,
|
|
||||||
line_items: [{ notes: description, cost: Number(cost || 0), quantity: Number(quantity || 1) }],
|
|
||||||
})
|
|
||||||
onDone()
|
|
||||||
} catch (err) { setError(err.message); setBusy(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card title="Neue Rechnung">
|
|
||||||
{linkable.length === 0 ? (
|
|
||||||
<p className="muted">Kein Kunde ist mit InvoiceNinja verknuepft. Bitte zuerst im Kunden-Bereich verknuepfen.</p>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={submit}>
|
|
||||||
<Field label="Kunde">
|
|
||||||
<select className="form-control" value={clientId} onChange={(e) => setClientId(e.target.value)} required>
|
|
||||||
<option value="">Kunde waehlen...</option>
|
|
||||||
{linkable.map((c) => <option key={c.$id} value={c.invoiceNinjaClientId}>{c.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field label="Leistung / Beschreibung">
|
|
||||||
<textarea className="form-control" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} required />
|
|
||||||
</Field>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Field label="Einzelpreis (EUR)"><input className="form-control" type="number" step="0.01" value={cost} onChange={(e) => setCost(e.target.value)} required /></Field>
|
|
||||||
<Field label="Menge"><input className="form-control" type="number" step="1" value={quantity} onChange={(e) => setQuantity(e.target.value)} /></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" /> : 'Rechnung erstellen'}</Button>
|
|
||||||
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user