Buchungskalender: origin/test zusammenführen

This commit is contained in:
Claude committed 2026-10-07 15:48:07 +00:00
commit dc4df16cab
48 files changed
+6387 -367

No files matched your search

+17
View File
@@ -1,6 +1,8 @@
import { EmptyState } from './ui'
import { needsTimeEntry, isGitWorksheet } from '../lib/ticketForm'
import { parseSupporters } from '../lib/support'
import { useAbgerechnet } from '../hooks/useAbgerechnet'
import { abgerechnetText, ticketIdsOf } from '../lib/projektkontoView'
import {
showAboToggle,
statusAfterLabel,
@@ -23,6 +25,8 @@ export default function WorksheetList({
onOwnSupport,
currentUserId,
}) {
// Hooks laufen immer, auch bei den frühen Rückgaben unten; die Abzeichen sehen alle Mitarbeiter und zeigen nie Beträge
const abgerechnet = useAbgerechnet(ticketIdsOf(worksheets))
if (loading) return <div className="loading">Wird geladen …</div>
if (!worksheets || worksheets.length === 0) {
@@ -35,6 +39,7 @@ export default function WorksheetList({
const when = worksheetWhen(ws)
const kind = worksheetKindLabel(ws)
const badge = worksheetBadge(ws)
const billed = abgerechnetText(abgerechnet, ws.$id)
const lines = supportLines(ws)
const status = statusAfterLabel(ws)
const time = timeLabel(ws)
@@ -58,6 +63,18 @@ export default function WorksheetList({
{kind && <span className="muted">{kind}</span>}
<span className="faint xs num">WSID {ws.wsid}</span>
{badge && <span className={`badge ${badge.tone}`}>{badge.text}</span>}
{billed && (
<span
className="badge blue"
title={
billed === 'abgerechnet (Entwurf)'
? 'Dieses Arbeitsblatt steht in einem Rechnungsentwurf.'
: 'Dieses Arbeitsblatt ist auf einer Rechnung abgerechnet.'
}
>
{billed}
</span>
)}
{showAbo ? (
<button
type="button"
+10
View File
@@ -5,6 +5,8 @@ import { Button, DateTimeInput, Dialog, DurationSelect, Field } from './ui'
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
import { parseSupporters, validateSupporters } from '../lib/support'
import SupportersEditor from './SupportersEditor'
import { useAbgerechnet } from '../hooks/useAbgerechnet'
import { abgerechnetWarnung, ticketIdsOf } from '../lib/projektkontoView'
// Gespeichert ist dd.mm.yyyy / hhmm, die Kalender-Felder brauchen yyyy-mm-dd / hh:mm
const formFromWorksheet = (worksheet) => ({
@@ -23,6 +25,8 @@ const NO_EMPLOYEES_STATE = { employees: [], loading: false, error: null }
*/
export default function WorksheetTimesModal({ worksheet, currentUserId, onClose, onSave, employeesState = NO_EMPLOYEES_STATE }) {
const isGit = isGitWorksheet(worksheet)
const abgerechnet = useAbgerechnet(ticketIdsOf([worksheet]))
const warnung = abgerechnetWarnung(abgerechnet, worksheet.$id)
const [formData, setFormData] = useState(() => formFromWorksheet(worksheet))
// null = die Arbeitszeit folgt Beginn und Ende (aufgerundet auf 15 Minuten). Passte sie
// schon vorher nicht dazu, wurde sie von Hand gewaehlt (z.B. ohne Pause) und bleibt stehen.
@@ -117,6 +121,12 @@ export default function WorksheetTimesModal({ worksheet, currentUserId, onClose,
{isGit && <><br />Das Ende ist der Zeitpunkt des Git-Pushs. Trag ein, wann die Arbeit begonnen hat.</>}
</p>
{warnung && (
<p className="msg warn" role="alert">
{warnung}
</p>
)}
<Field label="Beginn" htmlFor="worksheet-times-start">
<DateTimeInput
id="worksheet-times-start"
@@ -0,0 +1,171 @@
import { useRef, useState } from 'react'
import { Button, Card, EmptyState } from '../ui'
import AddressWarning from '../finance/AddressWarning'
import InvoiceTable from '../finance/InvoiceTable'
import ProjektkontoListe from './ProjektkontoListe'
import RechnungDialog from './RechnungDialog'
import AuftragListe from './AuftragListe'
import AuftragDialog from './AuftragDialog'
import { useFinanceResource } from '../../hooks/useFinanceResource'
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
import { getProjektkonten, listAuftraege } from '../../lib/financeApi'
import { customerInvoicesGate } from '../../lib/financeView'
import { noticeProps } from '../../lib/financeUi'
import {
KONTEN_FEHLER,
ladeAuftraegeAnsicht,
ansichtsZustand,
kundeMitKundenart,
erstelltMeldung,
auftragMeldung,
} from '../../lib/auftraegeAnsicht'
/**
* Reiter „Aufträge und Rechnungen“ am Kunden (ersetzt CustomerInvoices). Kann für den Kunden keine Rechnung geschrieben
* werden (noch Lead, abgesagt, keine E-Mail), steht der Grund da, mit „Daten erfassen“; es werden keine Abrufe gestartet.
* Sonst von oben nach unten: Adresswarnung, Rückmeldung, Hinweis zum Neuladen, Projekte (Projektkonten), Aufträge (nur
* mit eingeschaltetem Schalter AUFTRAEGE_ENABLED) und die Rechnungsliste. Die Rechnungsliste und die Adresswarnung hängen
* nie vom Laden der Projekte oder Aufträge ab: fällt eines davon aus, bleibt „Rechnung schreiben“ bedienbar.
* Die Dialoge stehen als Geschwister hinter den Abschnitten (nie in InvoiceTable oder .fin-body).
*/
export default function AuftraegeUndRechnungen({ customer, onShowOverview }) {
const gate = customerInvoicesGate(customer)
if (!gate.ok) {
return (
<EmptyState
title={gate.title}
hint={gate.hint}
action={onShowOverview ? <Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button> : null}
/>
)
}
return <AuftraegeInhalt customer={customer} onShowOverview={onShowOverview} />
}
function AuftraegeInhalt({ customer, onShowOverview }) {
const res = useFinanceResource(() => ladeAuftraegeAnsicht(customer.$id, { getProjektkonten, listAuftraege }), KONTEN_FEHLER)
const ansicht = ansichtsZustand(res)
const reload = res.reload
const [meldung, setMeldung] = useState(null) // { tone, text } oder null
const [rechnungsDialog, setRechnungsDialog] = useState(null) // { projektId } oder null
const [auftragsDialog, setAuftragsDialog] = useState(null) // { projektId, auftrag } oder null
const [listeToken, setListeToken] = useState(0)
const meldungRef = useRef(null)
useScrollIntoView(meldungRef, meldung)
// Die Dialoge bekommen die frische Kundenart des Portals (die Kundenseite hält ggf. noch den alten Datensatz)
const kunde = kundeMitKundenart(customer, ansicht.kunde)
function openRechnung(projektId) {
setMeldung(null)
setRechnungsDialog({ projektId })
}
function openAuftrag(projektId, auftrag = null) {
setMeldung(null)
setAuftragsDialog({ projektId, auftrag })
}
function handleCreated(rechnung, info) {
setRechnungsDialog(null)
setMeldung(erstelltMeldung(rechnung, info))
setListeToken((n) => n + 1)
reload()
}
function handleSaved(auftrag) {
setAuftragsDialog(null)
setMeldung(auftragMeldung(auftrag))
reload()
}
return (
<>
<AddressWarning customer={customer} onShowOverview={onShowOverview} variant="page" />
{meldung && (
<p ref={meldungRef} {...noticeProps(meldung.tone, 'fin-notice')}>
{meldung.text}
</p>
)}
{ansicht.projekteStatus === 'bereit' && ansicht.nachladeFehler && (
<p {...noticeProps('warn', 'fin-notice')}>
Die Übersicht konnte nicht neu geladen werden: {ansicht.nachladeFehler}{' '}
<button type="button" className="link" onClick={reload}>
Erneut versuchen
</button>
</p>
)}
{ansicht.projekteStatus === 'laden' && (
<Card title="Projekte">
<div className="loading" role="status">Projekte werden geladen …</div>
</Card>
)}
{ansicht.projekteStatus === 'fehler' && (
<Card title="Projekte">
<div className="fin-load-error">
<p className="msg warn" role="alert">{ansicht.fehler}</p>
<Button onClick={reload}>Erneut versuchen</Button>
</div>
</Card>
)}
{ansicht.projekteStatus === 'bereit' && (
<ProjektkontoListe
konten={ansicht.konten}
ohneProjekt={ansicht.ohneProjekt}
auftraegeAktiv={ansicht.auftraegeAktiv}
stripeOk={ansicht.stripeOk}
onRechnung={openRechnung}
onAuftrag={(projektId) => openAuftrag(projektId)}
/>
)}
{ansicht.auftraegeStatus === 'bereit' && (
<AuftragListe
auftraege={ansicht.auftraege}
konten={ansicht.konten}
onChanged={reload}
onBearbeiten={(auftrag) => openAuftrag(auftrag.projektId || null, auftrag)}
onShowOverview={onShowOverview}
/>
)}
{ansicht.auftraegeStatus === 'fehler' && (
<Card title="Aufträge">
<div className="fin-load-error">
<p className="msg warn" role="alert">{ansicht.auftraegeFehler}</p>
<Button onClick={reload}>Erneut versuchen</Button>
</div>
</Card>
)}
<InvoiceTable customer={customer} onShowOverview={onShowOverview} refreshToken={listeToken} onChanged={reload} />
{rechnungsDialog && (
<RechnungDialog
open
onClose={() => setRechnungsDialog(null)}
onCreated={handleCreated}
kunde={kunde}
projektId={rechnungsDialog.projektId}
auftraegeAktiv={ansicht.auftraegeAktiv}
onShowOverview={onShowOverview}
/>
)}
{auftragsDialog && (
<AuftragDialog
open
onClose={() => setAuftragsDialog(null)}
onSaved={handleSaved}
kunde={kunde}
projektId={auftragsDialog.projektId}
auftrag={auftragsDialog.auftrag}
konten={ansicht.konten}
onShowOverview={onShowOverview}
/>
)}
</>
)
}
+275
View File
@@ -0,0 +1,275 @@
import { useEffect, useRef, useState } from 'react'
import { Button, Dialog } from '../ui'
import { createAuftrag, getAuftragPdf, getSettings, patchAuftrag, sendAuftrag } from '../../lib/financeApi'
import { alsPdfBlob } from '../../lib/auftragPdf'
import {
SCHRITTE,
auftragBestaetigung,
auftragFehler,
auftragHinweise,
auftragZuFormular,
formularZuNutzlast,
heuteBerlin,
kundenartWirksam,
neueSperre,
neuesFormular,
pruefeSchritt,
sendeGate,
zahlungsplanOptionen,
} from '../../lib/auftragView'
import { SchrittAuftrag, SchrittProjekt, SchrittZahlung } from './AuftragSchritte'
import AuftragVorschau from './AuftragVorschau'
import './auftrag.css'
/**
* Dialog „Auftragsbestätigung“ in vier Schritten (Projekt, Auftrag, Zahlung und Termine, Vorschau). Beim Wechsel nach
* Schritt 4 wird der Entwurf angelegt bzw. aktualisiert und das PDF geholt (die Vorschau gibt es nur zu einem gespeicherten
* Entwurf; archiviert wird sie nicht). „Senden“ fragt vorher nach und ist gesperrt, solange Kundendaten fehlen; Hinweise
* (Steuernummer, „Vor Ort“ bei Privatperson) blockieren nie. Nach Speichern oder Senden ruft der Dialog onSaved(auftrag) und
* danach immer onClose(): der Container schließt, lädt neu und meldet. Nur Entwürfe lassen sich bearbeiten.
*/
export default function AuftragDialog({ open, onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview }) {
if (!open) return null
if (auftrag && auftrag.status !== 'entwurf') {
return (
<Dialog title="Auftragsbestätigung" onClose={onClose} footer={<Button type="button" onClick={onClose}>Schließen</Button>}>
<p className="msg warn" role="alert">
Nur Entwürfe lassen sich bearbeiten. Eine gesendete Auftragsbestätigung lässt sich nur über „Neue Fassung“ in der Liste ändern.
</p>
</Dialog>
)
}
return (
<AuftragForm
key={auftrag?.id || projektId || 'neu'}
onClose={onClose}
onSaved={onSaved}
kunde={kunde}
projektId={projektId}
auftrag={auftrag}
konten={konten}
onShowOverview={onShowOverview}
/>
)
}
// Ids der Felder (Task 6): wenige Felder haben eine abweichende id
function feldId(feld, art) {
if (feld === 'art') return 'ab-art-festpreis'
if (feld === 'zahlungsart') return `ab-zahlungsart-${zahlungsplanOptionen(art)[0].value}`
return `ab-${feld}`
}
function AuftragForm({ onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview }) {
const [schritt, setSchritt] = useState(1)
const [form, setForm] = useState(() => (auftrag ? auftragZuFormular(auftrag) : neuesFormular({ projektId: projektId || '', konten, heute: heuteBerlin() })))
const [fehler, setFehler] = useState({})
const [meldung, setMeldung] = useState(null) // { text, zurUebersicht }
const [entwurf, setEntwurf] = useState(auftrag || null)
const [signatur, setSignatur] = useState('')
const [pdfUrl, setPdfUrl] = useState('')
const [phase, setPhase] = useState('') // '' | 'speichern' | 'pdf' | 'senden'
const [einstellungen, setEinstellungen] = useState(undefined) // undefined = lädt, null = nicht ladbar
const [fokus, setFokus] = useState(null) // { id }
const sperre = useRef(neueSperre()).current
const aliveRef = useRef(true)
const schmutzigRef = useRef(false) // es wurde gespeichert und noch nicht gemeldet
const urlRef = useRef('')
useEffect(() => {
aliveRef.current = true
getSettings()
.then((d) => {
if (aliveRef.current) setEinstellungen(d?.einstellungen ?? null)
})
.catch(() => {
if (aliveRef.current) setEinstellungen(null)
})
return () => {
aliveRef.current = false
if (urlRef.current) URL.revokeObjectURL(urlRef.current)
}
}, [])
useEffect(() => {
if (fokus) document.getElementById(fokus.id)?.focus()
}, [fokus])
const hinweise = auftragHinweise({ kunde, form, einstellungen })
const gate = sendeGate(kunde, form.kundenart)
const privat = kundenartWirksam(kunde, form.kundenart) === 'privat'
const beschaeftigt = phase !== ''
function zeigeFehler(schrittNr, r, art) {
setSchritt(schrittNr)
setFehler(r.fehler)
setFokus({ id: feldId(r.erstes, art) })
}
function weiter() {
const r = pruefeSchritt(schritt, form, { kunde })
if (!r.ok) {
setFehler(r.fehler)
setFokus({ id: feldId(r.erstes, form.art) })
return
}
setFehler({})
if (schritt === 3) zurVorschau()
else setSchritt(schritt + 1)
}
function zurueck() {
setMeldung(null)
setSchritt(Math.max(1, schritt - 1))
}
function zurVorschau() {
const r = formularZuNutzlast(form, { kunde })
if (!r.ok) {
zeigeFehler(r.schritt, r, form.art)
return
}
setSchritt(4)
vorschauLaden(r.payload)
}
async function vorschauLaden(payload) {
const neu = JSON.stringify(payload)
if (neu === signatur && pdfUrl) return
if (sperre.belegt) return
await sperre.run(async () => {
if (urlRef.current) URL.revokeObjectURL(urlRef.current)
urlRef.current = ''
setPdfUrl('')
setPhase('speichern')
setMeldung(null)
try {
const data = entwurf ? await patchAuftrag(entwurf.id, payload) : await createAuftrag({ kundenId: kunde.$id, ...payload })
if (!aliveRef.current) return
setEntwurf(data.auftrag)
schmutzigRef.current = true
setSignatur(neu)
setPhase('pdf')
const blob = await getAuftragPdf(data.auftrag.id)
const url = URL.createObjectURL(alsPdfBlob(blob))
if (!aliveRef.current) {
URL.revokeObjectURL(url)
return
}
urlRef.current = url
setPdfUrl(url)
} catch (err) {
if (aliveRef.current) setMeldung(auftragFehler(err)) // ein schon gespeicherter Entwurf bleibt
} finally {
if (aliveRef.current) setPhase('')
}
})
}
function speichernUndSchliessen() {
schmutzigRef.current = false
if (entwurf) onSaved(entwurf)
onClose()
}
function schliessen() {
if (beschaeftigt) return
if (schmutzigRef.current && entwurf) onSaved(entwurf)
schmutzigRef.current = false
onClose()
}
async function senden() {
if (sperre.belegt || !entwurf) return
const g = sendeGate(kunde, form.kundenart)
if (!g.erlaubt) {
setMeldung({ text: g.grund, zurUebersicht: g.zurUebersicht })
return
}
if (!window.confirm(auftragBestaetigung('senden', entwurf, { email: kunde?.email }))) return
await sperre.run(async () => {
setPhase('senden')
setMeldung(null)
try {
const data = await sendAuftrag(entwurf.id)
schmutzigRef.current = false
onSaved(data.auftrag)
onClose()
} catch (err) {
if (aliveRef.current) setMeldung(auftragFehler(err))
} finally {
if (aliveRef.current) setPhase('')
}
})
}
const fuss = (
<div className="auftrag-foot">
{schritt > 1 && (
<Button type="button" onClick={zurueck} disabled={beschaeftigt}>Zurück</Button>
)}
<span className="grow" />
{schritt < 4 ? (
<>
<Button type="button" onClick={schliessen} disabled={beschaeftigt}>Abbrechen</Button>
<Button type="submit" variant="primary" disabled={beschaeftigt}>{schritt === 3 ? 'Zur Vorschau' : 'Weiter'}</Button>
</>
) : (
<>
<Button type="button" onClick={speichernUndSchliessen} disabled={beschaeftigt || !entwurf}>Als Entwurf speichern</Button>
<Button
type="button"
variant="primary"
onClick={senden}
disabled={beschaeftigt || !entwurf || !pdfUrl || !gate.erlaubt}
title={!gate.erlaubt ? gate.grund : undefined}
>
{phase === 'senden' ? 'Sende …' : 'Senden'}
</Button>
</>
)}
</div>
)
return (
<Dialog
as="form"
wide
title={auftrag ? 'Auftragsbestätigung bearbeiten' : 'Auftragsbestätigung'}
busy={beschaeftigt}
onClose={schliessen}
onSubmit={(e) => {
e.preventDefault()
if (schritt < 4) weiter()
}}
footer={fuss}
>
<ol className="auftrag-schritte" aria-label="Schritte">
{SCHRITTE.map((s) => (
<li key={s.id} aria-current={s.id === schritt ? 'step' : undefined}>
<span className="auftrag-schritt-nr">{s.id}</span>
<span className="auftrag-schritt-text">{s.label}</span>
</li>
))}
</ol>
{schritt === 1 && <SchrittProjekt form={form} onChange={setForm} fehler={fehler} kunde={kunde} konten={konten} neueFassung={Boolean(auftrag?.ersetztId)} />}
{schritt === 2 && <SchrittAuftrag form={form} onChange={setForm} fehler={fehler} kunde={kunde} konten={konten} />}
{schritt === 3 && <SchrittZahlung form={form} onChange={setForm} fehler={fehler} kunde={kunde} konten={konten} />}
{schritt === 4 && (
<AuftragVorschau
phase={phase}
pdfUrl={pdfUrl}
meldung={meldung}
gate={gate}
privat={privat}
hinweise={hinweise}
onRetry={zurVorschau}
onShowOverview={() => {
schliessen()
onShowOverview()
}}
/>
)}
</Dialog>
)
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Badge, Button, Card, EmptyState } from '../ui'
import { noticeProps } from '../../lib/financeUi'
import {
deleteAuftrag,
erinnereAuftrag,
getAuftragPdf,
neueFassungAuftrag,
ordneAuftragProjektZu,
sendAuftrag,
storniereAuftrag,
} from '../../lib/financeApi'
import { oeffnePdfImTab } from '../../lib/auftragPdf'
import {
auftragBestaetigung,
auftragErfolg,
auftragFehler,
auftragZeilen,
neueSperre,
zuordnenOptionen,
} from '../../lib/auftragView'
import './auftrag.css'
const LAUFEND = { senden: 'Sende …', erinnern: 'Sende …', stornieren: 'Storniere …', loeschen: 'Lösche …', neueFassung: 'Lege an …', pdf: 'Öffne …' }
const AUFRUFE = {
senden: sendAuftrag,
erinnern: erinnereAuftrag,
stornieren: storniereAuftrag,
loeschen: deleteAuftrag,
neueFassung: neueFassungAuftrag,
}
/**
* Karte „Aufträge“ im Kundenreiter: Auftragsbestätigungen (AB) mit Status, Zusatzzeilen und den Aktionen je Status.
* Die Regeln stecken in src/lib/auftragView.js. Nach jeder Aktion ruft die Liste onChanged() (auch nach einem Fehler:
* der Stand kann ein anderer sein). „Bearbeiten“ und „Neue Fassung“ geben den Entwurf über onBearbeiten an den Container,
* „Zur Übersicht“ in Meldungen läuft über onShowOverview. Eine Sperre fängt den Doppelklick ab (eine Anfrage, eine Rückfrage).
* Schmal (Wrapper .auftrag-body bis 860 px) wird jede Zeile ein Raster.
*/
export default function AuftragListe({ auftraege, konten, onChanged, onBearbeiten, onShowOverview }) {
const zeilen = useMemo(() => auftragZeilen(auftraege, { konten }), [auftraege, konten])
const optionen = useMemo(() => zuordnenOptionen(konten), [konten])
const [busy, setBusy] = useState(null) // `${id}:${aktion}` der laufenden Anfrage
const [message, setMessage] = useState(null) // { tone, text, zurUebersicht? }
const [zuordnen, setZuordnen] = useState(null) // { id, projektId } der Zeile mit offener Auswahl
const sperre = useRef(neueSperre()).current
const aliveRef = useRef(true)
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
const zeigeFehler = (err) => {
const f = auftragFehler(err)
if (aliveRef.current) setMessage({ tone: 'warn', text: f.text, zurUebersicht: f.zurUebersicht })
}
async function run(zeile, aktion) {
if (aktion.key === 'bearbeiten') {
onBearbeiten(zeile.auftrag)
return
}
if (aktion.key === 'projekt') {
setZuordnen({ id: zeile.id, projektId: '' })
setMessage(null)
return
}
if (sperre.belegt) return
const frage = auftragBestaetigung(aktion.key, zeile.auftrag)
if (frage && !window.confirm(frage)) return
await sperre.run(async () => {
setBusy(`${zeile.id}:${aktion.key}`)
setMessage(null)
try {
if (aktion.key === 'pdf') {
// kein await davor: der neue Tab muss im Klick aufgehen
await oeffnePdfImTab(() => getAuftragPdf(zeile.id))
return
}
const data = await AUFRUFE[aktion.key](zeile.id)
if (aliveRef.current) setMessage(auftragErfolg(aktion.key, data?.auftrag || zeile.auftrag, { mailFehler: data?.mailFehler }))
onChanged()
if (aktion.key === 'neueFassung' && data?.auftrag) onBearbeiten(data.auftrag)
} catch (err) {
if (aktion.key === 'pdf') {
if (aliveRef.current) setMessage({ tone: 'warn', text: auftragFehler(err).text })
return
}
zeigeFehler(err)
onChanged() // der Stand kann ein anderer sein
} finally {
if (aliveRef.current) setBusy(null)
}
})
}
async function ordneZu(zeile) {
const gewaehlt = optionen.find((o) => o.value === zuordnen?.projektId)
if (!gewaehlt || sperre.belegt) return
const frage = auftragBestaetigung('projekt', zeile.auftrag, { projektName: gewaehlt.label })
if (frage && !window.confirm(frage)) return
await sperre.run(async () => {
setBusy(`${zeile.id}:projekt`)
setMessage(null)
try {
const data = await ordneAuftragProjektZu(zeile.id, gewaehlt.value)
if (aliveRef.current) {
setMessage(auftragErfolg('projekt', data?.auftrag || zeile.auftrag, { projektName: gewaehlt.label }))
setZuordnen(null)
}
onChanged()
} catch (err) {
zeigeFehler(err)
if (aliveRef.current) setZuordnen(null)
onChanged()
} finally {
if (aliveRef.current) setBusy(null)
}
})
}
return (
<Card title="Aufträge" className="auftrag-liste">
<div className="auftrag-body">
{message && (
<p {...noticeProps(message.tone, 'auftrag-notice')}>
{message.text}
{message.zurUebersicht && (
<>
{' '}
<button type="button" className="link" onClick={onShowOverview}>
Zur Übersicht
</button>
</>
)}
</p>
)}
{zeilen.length === 0 ? (
<EmptyState title="Noch keine Aufträge." hint="Eine Auftragsbestätigung entsteht über „Auftragsbestätigung“ in der Projektliste." />
) : (
<div className="auftrag-table-wrap">
<table className="table auftrag-table" role="table">
<caption className="auftrag-sr">Auftragsbestätigungen, neueste zuerst</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Nummer</th>
<th role="columnheader" scope="col">Projekt / Titel</th>
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col" className="auftrag-amount">Summe</th>
<th role="columnheader" scope="col">Status</th>
<th role="columnheader" scope="col">Aktionen</th>
</tr>
</thead>
<tbody role="rowgroup">
{zeilen.map((zeile) => {
const offen = zuordnen?.id === zeile.id
const wahl = offen && optionen.some((o) => o.value === zuordnen.projektId) ? zuordnen.projektId : ''
const ordnet = busy === `${zeile.id}:projekt`
return (
<tr key={zeile.key} role="row">
<td role="cell" className="auftrag-nummer">
{zeile.istEntwurf ? <span className="muted">{zeile.nummerText}</span> : zeile.nummerText}
</td>
<td role="cell" className="auftrag-titel">
<strong>{zeile.titel}</strong>
{zeile.projektText && <div className="cell-sub">{zeile.projektText}</div>}
</td>
<td role="cell" className="auftrag-datum" data-label="Datum">{zeile.datumText}</td>
<td role="cell" className="auftrag-amount" data-label="Summe">
{zeile.summeText}
{zeile.summeSub && <div className="cell-sub">{zeile.summeSub}</div>}
</td>
<td role="cell" className="auftrag-status">
<Badge tone={zeile.statusTone}>{zeile.statusLabel}</Badge>
{zeile.notizen.map((note) => (
<div key={note.key} className={note.tone === 'warn' ? 'msg warn auftrag-note' : 'muted auftrag-note'}>
{note.text}
</div>
))}
</td>
<td role="cell" className="auftrag-aktionen">
<div className="auftrag-actions">
{zeile.aktionen.map((aktion) => {
const label = `${aktion.label}: ${zeile.nummerText}, ${zeile.titel}`
const text = busy === `${zeile.id}:${aktion.key}` && LAUFEND[aktion.key] ? LAUFEND[aktion.key] : aktion.label
if (aktion.danger) {
return (
<Button key={aktion.key} type="button" variant="danger" onClick={() => run(zeile, aktion)} disabled={busy !== null} aria-label={label}>
{text}
</Button>
)
}
return (
<button
key={aktion.key}
type="button"
className="link"
onClick={() => run(zeile, aktion)}
disabled={busy !== null}
aria-label={label}
aria-expanded={aktion.key === 'projekt' ? offen : undefined}
>
{text}
</button>
)
})}
</div>
{offen && (
<div className="auftrag-zuordnen" role="group" aria-label={`Projekt zuordnen: ${zeile.nummerText}, ${zeile.titel}`}>
<select
className="select"
aria-label="Projekt wählen"
value={wahl}
onChange={(e) => setZuordnen({ id: zeile.id, projektId: e.target.value })}
>
<option value="">Projekt wählen …</option>
{optionen.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<Button type="button" onClick={() => ordneZu(zeile)} disabled={wahl === '' || busy !== null}>
{ordnet ? 'Ordne zu …' : 'Zuordnen'}
</Button>
<button type="button" className="link" disabled={busy !== null} onClick={() => setZuordnen(null)}>
Abbrechen
</button>
</div>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</Card>
)
}
@@ -0,0 +1,321 @@
import { Badge } from '../ui'
import FormField from '../finance/FormField'
import { useWorkorders } from '../../hooks/useWorkorders'
import { euro } from '../../lib/financeFormat'
import { customerName, lineTotalCents } from '../../lib/financeView'
import { KUNDENART_OPTIONEN, kundenartLabel } from '../../lib/kundenFelder'
import {
AUFTRAG_LIMITS,
AUFTRAG_WEGE,
formularSummeCents,
kundenartWirksam,
neuePosition,
parseProzent,
parseTage,
schalteZeitquelle,
vorOrtHinweis,
waehleProjekt,
wechselArt,
zahlungsplanOptionen,
zahlungsplanVorschau,
zeitquelleOptionen,
} from '../../lib/auftragView'
import './auftrag.css'
/** Meldung eines Feldes ohne FormField (Art, Positionen, Zahlungsplan, Zeitquelle). */
function FeldFehler({ text }) {
return text ? <div className="msg err" role="alert">{text}</div> : null
}
/** Schritt 1: Projekt, Titel und (falls am Kunden noch leer) die Kundenart. */
export function SchrittProjekt({ form, onChange, fehler, kunde, konten, neueFassung = false }) {
const name = customerName(kunde) || 'Kunde'
return (
<div className="auftrag-schritt">
<p className="muted">
Kunde: <strong>{name}</strong>
{kunde?.email ? ` · ${kunde.email}` : ''}
</p>
{neueFassung && <p className="msg muted">Neue Fassung: Beim Senden ersetzt sie die bisherige Auftragsbestätigung.</p>}
<FormField id="ab-projekt" label="Projekt">
<select className="select" value={form.projektId} onChange={(e) => onChange(waehleProjekt(form, e.target.value, konten))}>
<option value="">Kein Projekt (freier Titel)</option>
{(Array.isArray(konten) ? konten : []).map((k) => (
<option key={k.projektId} value={k.projektId}>{k.name}</option>
))}
</select>
</FormField>
<FormField
id="ab-titel"
label="Titel"
hint="Steht als Titel auf der Auftragsbestätigung, z. B. „Website für Praxis Müller“."
error={fehler.titel}
>
<input
className="input"
maxLength={AUFTRAG_LIMITS.maxTitel}
value={form.titel}
onChange={(e) => onChange({ ...form, titel: e.target.value, titelGeaendert: true })}
/>
</FormField>
{!kunde?.kundenart ? (
<FormField
id="ab-kundenart"
label="Kundenart"
hint="Wird am Kunden gespeichert. Privatpersonen bekommen die Widerrufsbelehrung in der Auftragsbestätigung."
error={fehler.kundenart}
>
<select className="select" value={form.kundenart} onChange={(e) => onChange({ ...form, kundenart: e.target.value })}>
<option value="">Bitte wählen</option>
{KUNDENART_OPTIONEN.slice(1).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</FormField>
) : (
<p className="muted">Kundenart: {kundenartLabel(kunde.kundenart)}. Änderbar in der Übersicht des Kunden.</p>
)}
</div>
)
}
/** Schritt 2: Absprache (Datum, Weg), Art des Auftrags und Positionen bzw. Stundensatz. */
export function SchrittAuftrag({ form, onChange, fehler, kunde }) {
const vorOrt = vorOrtHinweis(form.auftragWeg, kundenartWirksam(kunde, form.kundenart))
const positionen = form.positionen
const setPosition = (uid, feld, wert) =>
onChange({ ...form, positionen: positionen.map((p) => (p.uid === uid ? { ...p, [feld]: wert } : p)) })
const entferne = (uid) => onChange({ ...form, positionen: positionen.filter((p) => p.uid !== uid) })
const summe = formularSummeCents(form)
return (
<div className="auftrag-schritt">
<div className="field-row">
<FormField id="ab-auftragVom" label="Datum der Absprache" error={fehler.auftragVom}>
<input className="input" type="date" value={form.auftragVom} onChange={(e) => onChange({ ...form, auftragVom: e.target.value })} />
</FormField>
<FormField id="ab-auftragWeg" label="Weg der Absprache" error={fehler.auftragWeg}>
<select className="select" value={form.auftragWeg} onChange={(e) => onChange({ ...form, auftragWeg: e.target.value })}>
<option value="">Bitte wählen</option>
{AUFTRAG_WEGE.map((w) => (
<option key={w.value} value={w.value}>{w.label}</option>
))}
</select>
</FormField>
</div>
{vorOrt && <p className="msg warn auftrag-hinweis">{vorOrt}</p>}
<fieldset className="auftrag-art">
<legend>Art des Auftrags</legend>
<label className="check">
<input type="radio" name="ab-art" id="ab-art-festpreis" checked={form.art === 'festpreis'} onChange={() => onChange(wechselArt(form, 'festpreis'))} />
<span className="auftrag-check-text">Festpreis</span>
<span className="hint">Feste Positionen mit Preisen.</span>
</label>
<label className="check">
<input type="radio" name="ab-art" id="ab-art-aufwand" checked={form.art === 'aufwand'} onChange={() => onChange(wechselArt(form, 'aufwand'))} />
<span className="auftrag-check-text">Nach Aufwand</span>
<span className="hint">Stundensatz, abgerechnet nach erfasster Arbeitszeit.</span>
</label>
<FeldFehler text={fehler.art} />
</fieldset>
{form.art === 'festpreis' && (
<fieldset id="ab-positionen" tabIndex={-1} className="auftrag-items">
<legend>Positionen</legend>
{positionen.map((p, i) => {
const n = i + 1
const zeile = lineTotalCents(p)
return (
<div key={p.uid} className="auftrag-item">
<div className="auftrag-item-desc">
<span className="auftrag-item-label">Beschreibung</span>
<input
className="input"
maxLength={AUFTRAG_LIMITS.maxBeschreibung}
aria-label={`Position ${n}: Beschreibung`}
placeholder="z. B. Startseite"
value={p.description}
onChange={(e) => setPosition(p.uid, 'description', e.target.value)}
/>
</div>
<div className="auftrag-item-qty">
<span className="auftrag-item-label">Menge</span>
<input
className="input"
inputMode="numeric"
aria-label={`Position ${n}: Menge`}
value={p.quantity}
onChange={(e) => setPosition(p.uid, 'quantity', e.target.value)}
/>
</div>
<div className="auftrag-item-price">
<span className="auftrag-item-label">Einzelpreis in €</span>
<input
className="input"
inputMode="decimal"
aria-label={`Position ${n}: Einzelpreis in Euro`}
placeholder="0,00"
value={p.price}
onChange={(e) => setPosition(p.uid, 'price', e.target.value)}
/>
</div>
<div className="auftrag-item-sum">
<span className="auftrag-item-label">Summe</span>
<span className="auftrag-item-wert">{zeile === null ? '–' : euro(zeile)}</span>
</div>
<div className="auftrag-item-remove">
<button
type="button"
className="link"
aria-label={`Position ${n} entfernen`}
disabled={positionen.length <= 1}
onClick={() => entferne(p.uid)}
>
Entfernen
</button>
</div>
</div>
)
})}
<div>
<button
type="button"
className="btn"
disabled={positionen.length >= AUFTRAG_LIMITS.maxPositionen}
onClick={() => onChange({ ...form, positionen: [...positionen, neuePosition()] })}
>
+ Position
</button>
</div>
<div className="auftrag-total" aria-live="polite">
<span>Summe</span>
<span>{euro(summe)}</span>
</div>
<p className="hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
<FeldFehler text={fehler.positionen} />
</fieldset>
)}
{form.art === 'aufwand' && (
<div className="field-row">
<FormField id="ab-stundensatz" label="Stundensatz in €" hint="Steht mit der Aufwandsregel in der Auftragsbestätigung." error={fehler.stundensatz}>
<input className="input" inputMode="decimal" value={form.stundensatz} onChange={(e) => onChange({ ...form, stundensatz: e.target.value })} />
</FormField>
<FormField
id="ab-schaetzung"
label="Geschätzter Umfang in Stunden (optional)"
hint="In Schritten von 15 Minuten, z. B. 10 oder 12,5. Unverbindlich."
error={fehler.schaetzung}
>
<input
className="input"
inputMode="decimal"
placeholder="z. B. 10"
value={form.schaetzung}
onChange={(e) => onChange({ ...form, schaetzung: e.target.value })}
/>
</FormField>
</div>
)}
</div>
)
}
/** Zeitquelle: Tickets des Kunden, deren Arbeitsblätter zum Auftrag zählen (höchstens 10). */
function ZeitquelleAuswahl({ kundeId, form, onChange, projektTicketIds, fehler }) {
const { workorders, loading, error } = useWorkorders({ customerId: kundeId, limit: 100 })
const optionen = zeitquelleOptionen(workorders, form.zeitquelle, projektTicketIds)
const voll = form.zeitquelle.length >= AUFTRAG_LIMITS.maxZeitquellen
return (
<fieldset id="ab-zeitquelle" tabIndex={-1} className="auftrag-zeitquelle">
<legend>Zeiterfassung aus diesen Tickets</legend>
<p className="hint">Arbeitsblätter dieser Tickets zählen zum Auftrag (Projektkonto, Aufwandsrechnung). Höchstens 10.</p>
{loading && optionen.length === 0 && <div className="loading" role="status">Tickets werden geladen …</div>}
{error && <p className="msg warn" role="alert">Die Tickets konnten nicht geladen werden: {error}. Die bisherige Auswahl bleibt.</p>}
{!loading && !error && optionen.length === 0 && <p className="muted">Dieser Kunde hat noch keine Tickets.</p>}
{optionen.length > 0 && (
<div className="auftrag-zeitquelle-liste">
{optionen.map((o) => (
<label key={o.id} className="check">
<input
type="checkbox"
checked={o.gewaehlt}
disabled={!o.gewaehlt && voll}
onChange={(e) => onChange({ ...form, zeitquelle: schalteZeitquelle(form.zeitquelle, o.id, e.target.checked), zeitquelleGeaendert: true })}
/>
<span className="auftrag-check-text">{o.label}</span>
{o.projekt && <Badge tone="muted">Ticket des Projekts</Badge>}
</label>
))}
</div>
)}
<FeldFehler text={fehler.zeitquelle} />
</fieldset>
)
}
/** Schritt 3: Zahlungsplan, Zahlungsziel, Termine und Zeitquelle. */
export function SchrittZahlung({ form, onChange, fehler, kunde, konten }) {
const konto = (Array.isArray(konten) ? konten : []).find((k) => k.projektId && k.projektId === form.projektId)
const projektTicketIds = konto && Array.isArray(konto.tickets) ? konto.tickets.map((t) => t.id) : []
const vorschau = zahlungsplanVorschau({
art: form.zahlungsart,
prozent: parseProzent(form.prozent),
text: form.zahlungstext,
summeCents: formularSummeCents(form),
zielTage: parseTage(form.zahlungsziel),
})
return (
<div className="auftrag-schritt">
<fieldset className="auftrag-art">
<legend>Zahlungsplan</legend>
{zahlungsplanOptionen(form.art).map((o) => (
<label key={o.value} className="check">
<input
type="radio"
name="ab-zahlungsart"
id={`ab-zahlungsart-${o.value}`}
checked={form.zahlungsart === o.value}
onChange={() => onChange({ ...form, zahlungsart: o.value })}
/>
<span className="auftrag-check-text">{o.label}</span>
</label>
))}
<FeldFehler text={fehler.zahlungsart} />
</fieldset>
{form.zahlungsart === 'anzahlung' && (
<FormField id="ab-prozent" label="Anzahlung in %" error={fehler.prozent}>
<input className="input auftrag-narrow" inputMode="numeric" value={form.prozent} onChange={(e) => onChange({ ...form, prozent: e.target.value })} />
</FormField>
)}
{form.zahlungsart === 'frei' && (
<div>
<FormField id="ab-zahlungstext" label="Zahlungsplan als Text" error={fehler.zahlungstext}>
<textarea
className="textarea"
rows={3}
maxLength={AUFTRAG_LIMITS.maxZahlungsplanText}
value={form.zahlungstext}
onChange={(e) => onChange({ ...form, zahlungstext: e.target.value })}
/>
</FormField>
<div className="hint">{form.zahlungstext.length} / {AUFTRAG_LIMITS.maxZahlungsplanText} Zeichen</div>
</div>
)}
{vorschau && <p className="msg muted auftrag-plan-vorschau">Sinngemäß: {vorschau}</p>}
<FormField id="ab-zahlungsziel" label="Zahlungsziel in Tagen" hint="0 bis 60 Tage nach Rechnung." error={fehler.zahlungsziel}>
<input className="input auftrag-narrow" inputMode="numeric" value={form.zahlungsziel} onChange={(e) => onChange({ ...form, zahlungsziel: e.target.value })} />
</FormField>
<div className="field-row">
<FormField id="ab-startAm" label="Start (optional)" error={fehler.startAm}>
<input className="input" type="date" value={form.startAm} onChange={(e) => onChange({ ...form, startAm: e.target.value })} />
</FormField>
<FormField id="ab-fertigBis" label="Voraussichtliche Fertigstellung (optional)" error={fehler.fertigBis}>
<input className="input" type="date" value={form.fertigBis} onChange={(e) => onChange({ ...form, fertigBis: e.target.value })} />
</FormField>
</div>
<ZeitquelleAuswahl kundeId={kunde?.$id} form={form} onChange={onChange} projektTicketIds={projektTicketIds} fehler={fehler} />
</div>
)
}
@@ -0,0 +1,80 @@
import { useRef } from 'react'
import { Button } from '../ui'
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
import './auftrag.css'
/**
* Schritt 4 des Dialogs als reine Darstellung (kein eigener Zustand): Lade- und Fehlerzustand, Hinweise und die PDF-Vorschau.
* Alles, was vor dem Senden zu lesen ist, steht über dem hohen PDF-Rahmen. Hinweise sind reine Texte und sperren nichts;
* nur `gate` (Kundendaten) hält das Senden auf, der Entwurf lässt sich trotzdem speichern.
*
* phase: '' | 'speichern' | 'pdf' | 'senden'; meldung: { text, zurUebersicht? } | null; gate: { erlaubt, grund, zurUebersicht };
* hinweise: [{ key, text }]; privat: Kundenart Privatperson.
*/
export default function AuftragVorschau({ phase, pdfUrl, meldung, gate, privat, hinweise = [], onRetry, onShowOverview }) {
const errorRef = useRef(null)
useScrollIntoView(errorRef, meldung)
const laedt = phase === 'speichern' || phase === 'pdf'
return (
<div className="auftrag-schritt">
{phase === 'speichern' && <div className="loading" role="status">Entwurf wird gespeichert …</div>}
{phase === 'pdf' && <div className="loading" role="status">Vorschau wird erstellt …</div>}
{meldung && (
<div className="msg err" role="alert" ref={errorRef}>
{meldung.text}
{meldung.zurUebersicht && (
<>
{' '}
<button type="button" className="link" onClick={onShowOverview}>
Zur Übersicht
</button>
</>
)}
</div>
)}
{meldung && !pdfUrl && !laedt && phase !== 'senden' && (
<div>
<Button type="button" onClick={onRetry}>Erneut versuchen</Button>
</div>
)}
{pdfUrl && <p className="msg ok">Entwurf gespeichert. Er bleibt in der Liste, bis er gesendet oder gelöscht wird.</p>}
{gate && !gate.erlaubt && (
<div className="msg warn" role="alert">
<span>{gate.grund}</span>
{gate.zurUebersicht && (
<>
{' '}
<button type="button" className="link" onClick={onShowOverview}>
Zur Übersicht
</button>
</>
)}
</div>
)}
{hinweise.map((h) => (
<p key={h.key} className="msg warn auftrag-hinweis">{h.text}</p>
))}
{privat && (
<p className="hint">
Privatperson: Die Auftragsbestätigung enthält die Widerrufsbelehrung. Mit der Arbeit darf erst nach Ablauf der Widerrufsfrist begonnen
werden, es sei denn, der Kunde verlangt es ausdrücklich (Häkchen auf der Bestätigungsseite).
</p>
)}
{pdfUrl && (
<>
<iframe className="auftrag-pdf" title="Vorschau der Auftragsbestätigung" src={pdfUrl} />
<a className="link" href={pdfUrl} target="_blank" rel="noopener noreferrer">
PDF in neuem Tab öffnen
</a>
</>
)}
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { Field } from '../ui'
import { dateDe } from '../../lib/financeFormat'
import { stundenDe } from '../../lib/projektkontoView'
import { ausgewaehlteIds, blaetterSumme } from '../../lib/rechnungDialogView'
/**
* Auswahl der offenen Arbeitsblätter für eine Rechnung nach Aufwand, dazu „Weiteres Ticket des Kunden hinzunehmen“.
* Rein darstellend. Props: blaetter (alle angebotenen Blätter), abgewaehlt (Kennungen), onToggle(id), satzInfo,
* ticketOptionen ([{ value, ticketId, woid, label }]), ticketsLaden, ticketsFehler, weitereTickets ([{ ticketId, woid, titel }]),
* weitereZustand ({ [ticketId]: { status, message } }), onAddTicket(option), onRemoveTicket(ticketId), disabled.
*/
export default function AufwandAuswahl({
blaetter,
abgewaehlt,
onToggle,
satzInfo,
ticketOptionen,
ticketsLaden,
ticketsFehler,
weitereTickets,
weitereZustand,
onAddTicket,
onRemoveTicket,
disabled = false,
}) {
const liste = Array.isArray(blaetter) ? blaetter : []
const summe = blaetterSumme(liste, ausgewaehlteIds(liste, abgewaehlt))
const mehrereTickets = new Set(liste.map((b) => b.ticketId)).size > 1
const optionen = Array.isArray(ticketOptionen) ? ticketOptionen : []
const weitere = Array.isArray(weitereTickets) ? weitereTickets : []
const zustand = weitereZustand || {}
return (
<fieldset className="rd-group">
<legend>Arbeitsblätter</legend>
<div className="rd-stack">
{satzInfo && <p className="rd-hint">{satzInfo}</p>}
{liste.length === 0 ? (
<p className="msg muted">Keine offenen Arbeitsblätter.</p>
) : (
<div className="rd-blaetter">
{liste.map((b) => (
<label className="rd-blatt" key={b.id}>
<input type="checkbox" checked={!(abgewaehlt || []).includes(b.id)} onChange={() => onToggle(b.id)} disabled={disabled} />
<span className="rd-blatt-text">
<span className="num">{dateDe(b.datum)}</span> {b.text || 'Arbeitszeit'}
{mehrereTickets && <span className="faint xs"> #{b.woid}</span>}
</span>
<span className="rd-blatt-zeit">{stundenDe(b.minuten)}</span>
</label>
))}
</div>
)}
<p className="rd-hint" aria-live="polite">
{summe.anzahl} von {liste.length} ausgewählt · {stundenDe(summe.minuten)}
</p>
<Field label="Weiteres Ticket des Kunden hinzunehmen" htmlFor="rd-ticket">
<select
id="rd-ticket"
className="select"
value=""
disabled={ticketsLaden || disabled}
onChange={(e) => {
const option = optionen.find((o) => o.value === e.target.value)
if (option) onAddTicket(option)
}}
>
<option value="">{ticketsLaden ? 'Tickets werden geladen …' : 'Ticket wählen …'}</option>
{optionen.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{ticketsFehler && (
<p className="msg warn" role="alert">
{ticketsFehler}
</p>
)}
</Field>
{weitere.map((t) => {
const z = zustand[t.ticketId]
return (
<div className="rd-weiter" key={t.ticketId}>
<span>
#{t.woid} · {t.titel}
</span>
{z?.status === 'laedt' && <span className="msg muted">Arbeitsblätter werden geladen …</span>}
{z?.status === 'fehler' && (
<span className="msg warn" role="alert">
{z.message}
</span>
)}
<button type="button" className="link" aria-label={`Ticket #${t.woid} entfernen`} onClick={() => onRemoveTicket(t.ticketId)}>
Entfernen
</button>
</div>
)
})}
</div>
</fieldset>
)
}
@@ -0,0 +1,36 @@
/**
* Zusammenfassung des Projektkontos im Rechnungsdialog: Vereinbart, Berechnet (darunter bezahlt), Noch offen, Zeit erfasst.
* Rein darstellend. Props: zeile (aus kontoZeile) und hinweis (aus stripeHinweis, leer = kein Hinweis).
*/
export default function KontoZusammenfassung({ zeile, hinweis }) {
return (
<>
<div className="facts rd-facts">
<div className="fact">
<div className="k">Vereinbart</div>
<div className="v">{zeile.vereinbart}</div>
</div>
<div className="fact">
<div className="k">Berechnet</div>
<div className="v">{zeile.berechnet}</div>
{zeile.bezahlt && <div className="xs muted">{zeile.bezahlt}</div>}
</div>
<div className="fact">
<div className="k">Noch offen</div>
<div className="v">{zeile.offen}</div>
</div>
<div className="fact">
<div className="k">Zeit erfasst</div>
<div className="v">{zeile.zeit.stunden}</div>
<div className="xs muted">{zeile.zeit.wert}</div>
{zeile.zeit.abo && <div className="xs muted">{zeile.zeit.abo}</div>}
</div>
</div>
{hinweis && (
<p className="msg warn" role="status">
{hinweis}
</p>
)}
</>
)
}
@@ -0,0 +1,81 @@
import './projektkonto.css'
import { Card } from '../ui'
import { kontoZeile, ohneProjektZeile, stripeHinweis, KEINE_PROJEKTE_HINWEIS } from '../../lib/projektkontoView'
/**
* Abschnitt „Projekte“ im Kundenreiter „Aufträge und Rechnungen“: je Projekt eine Zeile mit Vereinbart, Berechnet
* (darunter bezahlt), Noch offen und erfasster Zeit, am Ende die feste Zeile „Ohne Projekt“.
* Props: konten (Projektkonten, darf fehlen), ohneProjekt ({ berechnetCents, bezahltCents, anzahl }), auftraegeAktiv,
* stripeOk, onRechnung(projektId) und onAuftrag(projektId). Die Kennung ist die des Projekts, `null` die Zeile „Ohne Projekt“.
* Die Überschrift „Projekte“ rendert die Komponente selbst; die Anzeigelogik steht in lib/projektkontoView.js.
*/
export default function ProjektkontoListe({ konten, ohneProjekt, auftraegeAktiv, stripeOk, onRechnung, onAuftrag }) {
const liste = Array.isArray(konten) ? konten : []
const zeilen = [...liste.map((k) => kontoZeile(k, { stripeOk })), ohneProjektZeile(ohneProjekt, { stripeOk })]
const hinweis = stripeHinweis(stripeOk)
return (
<Card title="Projekte">
<div className="pk">
{hinweis && (
<p className="msg warn pk-hinweis" role="status">
{hinweis}
</p>
)}
{liste.length === 0 && <p className="msg muted pk-hinweis">{KEINE_PROJEKTE_HINWEIS}</p>}
<table className="table pk-table" role="table">
<caption className="pk-sr">Projektkonten</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Projekt</th>
<th role="columnheader" scope="col">Vereinbart</th>
<th role="columnheader" scope="col">Berechnet</th>
<th role="columnheader" scope="col">Noch offen</th>
<th role="columnheader" scope="col">Zeit erfasst</th>
<th role="columnheader" scope="col"><span className="pk-sr">Aktionen</span></th>
</tr>
</thead>
<tbody role="rowgroup">
{zeilen.map((z) => {
const id = z.projektId || null
const fuer = id === null ? 'ohne Projekt' : `für ${z.name}`
return (
<tr role="row" key={z.key}>
<td role="cell" className="pk-name">
<div className="cell-title">{z.name}</div>
{z.meta && <div className="cell-sub">{z.meta}</div>}
</td>
<td role="cell" className="pk-vereinbart" data-label="Vereinbart">{z.vereinbart}</td>
<td role="cell" className="pk-berechnet" data-label="Berechnet">
<div className="num">{z.berechnet}</div>
{z.bezahlt && <div className="cell-sub">{z.bezahlt}</div>}
</td>
<td role="cell" className="pk-offen" data-label="Noch offen">
<span className="num">{z.offen}</span>
</td>
<td role="cell" className="pk-zeit" data-label="Zeit erfasst">
<div className="num">{z.zeit.stunden}</div>
{z.zeit.wert && <div className="cell-sub">{z.zeit.wert}</div>}
{z.zeit.abo && <div className="cell-sub">{z.zeit.abo}</div>}
</td>
<td role="cell" className="pk-actions-cell">
<div className="pk-actions">
{auftraegeAktiv && (
<button type="button" className="link" aria-label={`Auftragsbestätigung ${fuer}`} onClick={() => onAuftrag(id)}>
Auftragsbestätigung
</button>
)}
<button type="button" className="link" aria-label={`Rechnung ${fuer}`} onClick={() => onRechnung(id)}>
Rechnung
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
)
}
+631
View File
@@ -0,0 +1,631 @@
import './rechnung-dialog.css'
import { useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { AutocompleteInput, Button, Dialog, Field } from '../ui'
import AddressWarning from '../finance/AddressWarning'
import KontoZusammenfassung from './KontoZusammenfassung'
import RechnungPositionen from './RechnungPositionen'
import RechnungVorschau from './RechnungVorschau'
import AufwandAuswahl from './AufwandAuswahl'
import { useCustomers } from '../../hooks/useCustomers'
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
import {
useKundenTickets,
useEinstellungen,
useOhneProjektAbs,
useProjektkontoDetail,
useProjektkonten,
useRechnungVorschau,
useWeitereBlaetter,
} from '../../hooks/useRechnungDaten'
import { createInvoice } from '../../lib/financeApi'
import { canInvoice } from '../../lib/financeFormat'
import {
ADDRESS_INCOMPLETE_TEXT,
INVOICE_LIMITS,
customerName,
customerPickerOptions,
customerSuggestions,
invoiceSendGate,
invoiceTotalCents,
resolveCustomerText,
} from '../../lib/financeView'
import { kontoZeile, satzText, stripeHinweis, KEINE_PROJEKTE_HINWEIS } from '../../lib/projektkontoView'
import {
abLabel,
anzahlungStandard,
artOptionen,
abSummenText,
aufwandAuftrag,
gueltigeFestpreisAbs,
projektOptionen,
standardArt,
standardAuftrag,
startProjekt,
steuernummerHinweis,
ticketOptionen,
wirksamesProjekt,
zahlungszielVorschlag,
} from '../../lib/rechnungDialogView'
import {
BEZEICHNUNG_MAX,
VORSCHAU_ARTEN,
createSubmitter,
darfSpeichern,
rechnungAnfrage,
vorschauAnfrage,
vorschauSchluessel,
vorschauStatus,
} from '../../lib/rechnungAnfrage'
import { anfangszustand, reduce } from '../../lib/rechnungDialogState'
/**
* Dialog „Rechnung schreiben“. Ablauf von oben nach unten: Kunde (fest oder per Suche), Projekt,
* Projektkonto (Vereinbart, Berechnet, Noch offen, Zeit), Art der Rechnung (Anzahlung, Schlussrechnung, nach Aufwand, Festbetrag,
* ohne Projekt nur freie Positionen), Inhalt je Art mit Vorschau vom Server, Leistungszeitraum, Zahlungsziel und Notiz.
* „Als Entwurf speichern“ und „Senden“ bleiben gesperrt, solange die Vorschau nicht zur aktuellen Eingabe gehört.
*
* Props: open, onClose, onCreated(rechnung, info), kunde (Kundendokument; null = Kundenwahl im Dialog), projektId (undefined =
* keine Vorauswahl, null = „Ohne Projekt“, Text = dieses Projekt), auftraegeAktiv (nur der Startwert, danach gilt
* getProjektkonten), onShowOverview (optional, „Zur Übersicht“ in der Adresswarnung).
* onCreated bekommt wie im bisherigen Dialog ({ senden }) bzw. bei festgeschriebener, nicht versendeter Rechnung
* (502 mit invoiceId) (null, { senden: true, notice, tone: 'warn', finalizedFilter }); danach ruft der Dialog onClose().
* Enter sendet nie (das Formular hat keinen Absenden-Knopf); „Senden“ fragt vorher nach.
*/
export default function RechnungDialog({ open, onClose, onCreated, kunde = null, projektId, auftraegeAktiv, onShowOverview = null }) {
if (!open) return null
const gemeinsam = { onClose, onCreated, projektId, auftraegeAktiv, onShowOverview }
// Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht
return kunde ? <RechnungFormular {...gemeinsam} festerKunde={kunde} quelle={null} /> : <KundenWahl {...gemeinsam} />
}
function KundenWahl(props) {
const { customers, loading, error } = useCustomers()
return <RechnungFormular {...props} festerKunde={null} quelle={{ customers, loading, error }} />
}
const ZEITRAUM_PLATZHALTER = 'z. B. 01.10.–20.10.2026'
function RechnungFormular({ onClose, onCreated, projektId, auftraegeAktiv, onShowOverview, festerKunde, quelle }) {
const [state, dispatch] = useReducer(reduce, startProjekt(projektId), (p) => anfangszustand({ projektId: p }))
const [customerText, setCustomerText] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(null) // null | 'draft' | 'send'
const submitter = useMemo(() => createSubmitter({ create: createInvoice, confirm: (t) => window.confirm(t) }), [])
const errorRef = useRef(null)
const aliveRef = useRef(true)
const kundeIdRef = useRef('')
const neuGeladenRef = useRef(null)
useScrollIntoView(errorRef, error)
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
const act = (aktion) => {
if (error) setError('')
dispatch(aktion)
}
// --- Abgeleitete Werte ------------------------------------------------------------------------------------
const options = useMemo(() => customerPickerOptions(quelle?.customers), [quelle?.customers])
const resolved = festerKunde
? { state: canInvoice(festerKunde).ok ? 'ok' : 'blocked', option: null }
: resolveCustomerText(options, customerText)
const kundeDoc = festerKunde || resolved.option?.customer || null
const kunde = kundeDoc && canInvoice(kundeDoc).ok ? kundeDoc : null
const blockiert = Boolean(kundeDoc) && !kunde
const einstellungen = useEinstellungen()
const konten = useProjektkonten(kunde?.$id)
const projekt = wirksamesProjekt(state.projektId, konten.data?.konten)
const aktiv = konten.data ? Boolean(konten.data.auftraegeAktiv) : Boolean(auftraegeAktiv)
const detail = useProjektkontoDetail(projekt || '')
// „Ohne Projekt“: gültige ABs ohne Projekt (der Server nimmt Anzahlung und Schlussrechnung dazu ohne Projekt an)
const ohne = useOhneProjektAbs(kunde?.$id, projekt === '' && aktiv)
const quelleDaten = projekt === '' ? ohne.data : detail.data
const auftraege = quelleDaten?.auftraege || []
const rechnungen = quelleDaten?.rechnungen || []
const datenNeuLaden = projekt === '' ? ohne.reload : detail.reload
let optionen = []
if (projekt === '') optionen = artOptionen({ projektId: '', auftraege, rechnungen, auftraegeAktiv: aktiv })
else if (projekt && detail.data) optionen = artOptionen({ projektId: projekt, auftraege, rechnungen, auftraegeAktiv: aktiv })
const stripeOk = detail.data?.stripeOk
const art = optionen.some((o) => o.key === state.art && o.enabled)
? state.art
: standardArt(optionen, { auftraege, rechnungen, offeneMinuten: detail.data?.konto?.zeit?.offeneMinuten || 0, stripeOk })
const festpreisAbs = gueltigeFestpreisAbs(auftraege)
const auftragId =
(art === 'anzahlung' || art === 'schluss') && festpreisAbs.some((a) => a.id === state.auftragId)
? state.auftragId
: standardAuftrag(art, auftraege, rechnungen)
const ab = auftraege.find((a) => a.id === auftragId)
const weitereTickets = state.weitereTickets
const weitere = useWeitereBlaetter(weitereTickets)
const blaetter = [...(detail.data?.offeneArbeitsblaetter || []), ...weitereTickets.flatMap((t) => weitere[t.ticketId]?.blaetter || [])]
const kundenTickets = useKundenTickets(kunde?.$id, art === 'aufwand')
const e = {
kunde,
art,
projektId: projekt ?? '',
auftragId,
items: state.items,
zusatz: state.zusatz,
blaetter,
abgewaehlt: state.abgewaehlt,
anzahlung: { ...anzahlungStandard(ab), ...state.anzahlung },
zeilentexte: state.zeilentexte,
zeitraum: state.zeitraum,
zeitraumGeaendert: state.zeitraumGeaendert,
days: state.days,
memo: state.memo,
}
const vorschauReq = vorschauAnfrage(e)
const schluessel = vorschauReq.ok ? vorschauSchluessel(vorschauReq.body) : ''
const vorschau = useRechnungVorschau({ anfrage: vorschauReq, schluessel, aktiv: VORSCHAU_ARTEN.includes(art) })
const status = vorschauStatus({ art, anfrage: vorschauReq, vorschau, schluessel })
const anfrage = rechnungAnfrage(e)
let totalCents = null
if (art === 'frei' || art === 'festbetrag') totalCents = invoiceTotalCents(state.items)
else if (status.state === 'bereit') totalCents = vorschau.daten.summeCents
const gate = invoiceSendGate(kunde, totalCents)
const darf = darfSpeichern(status) && busy === null && Boolean(kunde) && projekt !== null && art !== ''
// Das Zahlungsziel der gewählten AB (bzw. 14); der Reducer lässt eine Eingabe des Nutzers unberührt
const tageVorschlag = zahlungszielVorschlag({ art, ab, auftraege })
useEffect(() => {
dispatch({ type: 'tage-vorschlag', wert: tageVorschlag })
}, [tageVorschlag])
// --- Effekte auf die Vorschau -----------------------------------------------------------------------------
const vorschauZeitraum = status.state === 'bereit' ? vorschau.daten.leistungszeitraum || '' : null
useEffect(() => {
// Der Vorschlag überschreibt nie eine Eingabe des Nutzers (der Reducer prüft das)
if (art === 'aufwand' && vorschauZeitraum !== null) dispatch({ type: 'zeitraum-vorschlag', text: vorschauZeitraum })
}, [art, vorschauZeitraum])
const detailReloadRef = useRef(datenNeuLaden)
detailReloadRef.current = datenNeuLaden
useEffect(() => {
// 409 der Vorschau: ein Arbeitsblatt ist inzwischen abgerechnet, das Projektkonto neu laden (einmal je Fehlerobjekt)
if (vorschau?.status === 'fehler' && vorschau.httpStatus === 409 && neuGeladenRef.current !== vorschau) {
neuGeladenRef.current = vorschau
detailReloadRef.current()
}
}, [vorschau])
// --- Aktionen ---------------------------------------------------------------------------------------------
async function abschicken(senden) {
setBusy(senden ? 'send' : 'draft')
setError('')
const r = await submitter({ anfrage, senden, kunde, totalCents })
if (aliveRef.current) setBusy(null)
if (r.status === 'fehler') {
if (aliveRef.current) setError(r.message)
if (r.httpStatus === 409) datenNeuLaden()
} else if (r.status === 'festgeschrieben') {
if (onCreated) onCreated(null, { senden: true, notice: r.notice, tone: 'warn', finalizedFilter: r.finalizedFilter })
onClose()
} else if (r.status === 'ok') {
if (onCreated) onCreated(r.rechnung, { senden: r.senden })
onClose()
}
}
const close = () => {
if (busy !== null) return
onClose()
}
function kundeGeaendert(text) {
setCustomerText(text)
if (error) setError('')
const neu = resolveCustomerText(options, text).option?.customer?.$id || ''
if (neu !== kundeIdRef.current) {
kundeIdRef.current = neu
dispatch({ type: 'projekt', projektId: null })
}
}
const gesperrt = busy !== null
const footer = (
<div className="rd-foot">
<Button type="button" onClick={close} disabled={gesperrt}>
Abbrechen
</Button>
<Button type="button" disabled={!darf} onClick={() => abschicken(false)}>
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
</Button>
<Button
type="button"
variant="primary"
disabled={!darf || !gate.sendAllowed}
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
onClick={() => abschicken(true)}
>
{busy === 'send' ? 'Sende …' : 'Senden'}
</Button>
</div>
)
// --- Abschnitte -------------------------------------------------------------------------------------------
const mitPositionen = (liste, legende, labelPrefix, idPrefix, minZeilen, addLabel, zeigeSumme) => (
<RechnungPositionen
legende={legende}
labelPrefix={labelPrefix}
idPrefix={idPrefix}
zeilen={state[liste]}
minZeilen={minZeilen}
onChange={(uid, feld, wert) => act({ type: 'position', liste, uid, feld, wert })}
onAdd={() => act({ type: 'position+', liste })}
onRemove={(uid) => act({ type: 'position-', liste, uid })}
addLabel={addLabel}
zeigeSumme={zeigeSumme}
/>
)
const abBlock = () => {
if (!ab) return null
return (
<div className="rd-stack">
{festpreisAbs.length > 1 ? (
<Field label="Auftragsbestätigung" htmlFor="rd-ab">
<select id="rd-ab" className="select" value={ab.id} onChange={(ev) => act({ type: 'auftrag', auftragId: ev.target.value })}>
{festpreisAbs.map((a) => (
<option key={a.id} value={a.id}>
{abLabel(a)}
</option>
))}
</select>
</Field>
) : (
<p className="rd-hint">{abLabel(ab)}</p>
)}
<p className="rd-hint">{abSummenText({ ab, rechnungen, stripeOk })}</p>
</div>
)
}
const anzahlungFelder = () => {
const a = e.anzahlung
const prozent = a.modus !== 'betrag'
return (
<div className="rd-stack">
<div className="seg rd-seg" role="group" aria-label="Anzahlung in">
<button type="button" aria-pressed={prozent} onClick={() => act({ type: 'anzahlung', patch: { modus: 'prozent' } })}>
Prozent
</button>
<button type="button" aria-pressed={!prozent} onClick={() => act({ type: 'anzahlung', patch: { modus: 'betrag' } })}>
Betrag
</button>
</div>
<div className="field-row">
<Field label={prozent ? 'Anzahlung in %' : 'Betrag in €'} htmlFor="rd-anzahlung-wert">
<input
id="rd-anzahlung-wert"
className="input"
inputMode="decimal"
value={a.wert}
onChange={(ev) => act({ type: 'anzahlung', patch: { wert: ev.target.value } })}
/>
</Field>
<Field label="Bezeichnung" htmlFor="rd-anzahlung-bez">
<input
id="rd-anzahlung-bez"
className="input"
maxLength={BEZEICHNUNG_MAX}
value={a.bezeichnung}
onChange={(ev) => act({ type: 'anzahlung', patch: { bezeichnung: ev.target.value } })}
/>
</Field>
</div>
</div>
)
}
const statusBereich = () => (
<>
{status.state === 'laedt' && (
<p className="msg muted" role="status" aria-live="polite">
Vorschau wird aktualisiert …
</p>
)}
{status.state === 'ungueltig' && status.message && <p className="msg muted">{status.message}</p>}
{status.state === 'fehler' && (
<p className="msg err" role="alert">
{status.message}
</p>
)}
</>
)
const vorschauZeilen = () => {
const daten = vorschau?.daten
if ((status.state !== 'bereit' && status.state !== 'laedt') || !daten) return null
return (
<RechnungVorschau
positionen={daten.positionen}
summeCents={daten.summeCents}
bearbeitbar={art === 'aufwand'}
zeilentexte={state.zeilentexte}
onZeilentext={(schl, text, original) => act({ type: 'zeilentext', schluessel: schl, text, original })}
abgeblendet={status.state === 'laedt'}
/>
)
}
const aufwandBlock = () => {
const konto = detail.data.konto
const laut = aufwandAuftrag(auftraege)
const satzInfo = `Stundensatz ${satzText(konto.zeit?.satzCents)}${laut ? `, laut ${laut.nummer}` : ''}`
const ausgeschlossen = [...(konto.zeitquelle || []), ...(konto.tickets || []).map((t) => t.id), ...weitereTickets.map((t) => t.ticketId)]
return (
<>
<AufwandAuswahl
blaetter={blaetter}
abgewaehlt={state.abgewaehlt}
onToggle={(id) => act({ type: 'blatt', id })}
satzInfo={satzInfo}
ticketOptionen={ticketOptionen(kundenTickets.tickets, { ausgeschlossen })}
ticketsLaden={kundenTickets.loading}
ticketsFehler={kundenTickets.error}
weitereTickets={weitereTickets}
weitereZustand={weitere}
onAddTicket={(o) =>
act({ type: 'ticket+', ticket: { ticketId: o.ticketId, woid: o.woid, titel: o.label.replace(/^#\S+ · /, '') } })
}
onRemoveTicket={(id) => act({ type: 'ticket-', ticketId: id, blattIds: (weitere[id]?.blaetter || []).map((b) => b.id) })}
/>
{statusBereich()}
{vorschauZeilen()}
{mitPositionen('zusatz', 'Zusätzliche Positionen', 'Zusatzposition', 'rd-zus', 0, '+ Zusatzposition', false)}
</>
)
}
const inhalt = () => {
if (art === 'frei' || art === 'festbetrag') return mitPositionen('items', 'Positionen', 'Position', 'rd-pos', 1, '+ Position', true)
if (art === 'aufwand') return aufwandBlock()
if (art === 'anzahlung') {
return (
<>
{abBlock()}
{anzahlungFelder()}
{statusBereich()}
{vorschauZeilen()}
</>
)
}
if (art === 'schluss') {
return (
<>
{abBlock()}
{mitPositionen('zusatz', 'Zusätzliche Positionen (Nachträge)', 'Zusatzposition', 'rd-zus', 0, '+ Zusatzposition', false)}
{statusBereich()}
{vorschauZeilen()}
</>
)
}
return null
}
const projektBereit = projekt !== null
const kontoBereit = projekt === '' || Boolean(detail.data)
const hinweise = [...new Set(optionen.filter((o) => !o.enabled && o.hint).map((o) => o.hint))]
// Enter sendet nie (das wäre eine Mail an den Kunden): das Formular hat keinen Absenden-Knopf, jeder Knopf darin ist type="button"
return (
<Dialog as="form" wide title="Rechnung schreiben" onClose={close} busy={gesperrt} onSubmit={(ev) => ev.preventDefault()} footer={footer}>
{error && (
<div className="msg err" role="alert" ref={errorRef}>
{error}
</div>
)}
<div className="rd-stack">
<Field label="Kunde" htmlFor={festerKunde ? undefined : 'rd-kunde'}>
{festerKunde ? (
<p>
<strong>{customerName(festerKunde) || 'Kunde'}</strong>
{festerKunde.email ? <span className="msg muted"> · {festerKunde.email}</span> : null}
</p>
) : quelle?.loading ? (
<p className="msg muted" role="status">
Kunden werden geladen …
</p>
) : quelle?.error ? (
<p className="msg warn" role="alert">
Die Kunden konnten nicht geladen werden: {quelle.error}
</p>
) : (
<AutocompleteInput
id="rd-kunde"
value={customerText}
onChange={kundeGeaendert}
getSuggestions={(t) => customerSuggestions(options, t)}
placeholder="Kunde suchen …"
/>
)}
</Field>
{blockiert && (
<p className="msg warn" role="alert">
{customerName(kundeDoc)}: {festerKunde ? canInvoice(festerKunde).reason : resolved.option?.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
</p>
)}
{!festerKunde && resolved.state === 'unknown' && (
<p className="rd-hint">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
)}
{!festerKunde && kunde && kunde.email && <p className="rd-hint">Die Rechnung geht an {kunde.email}.</p>}
{steuernummerHinweis(einstellungen) && (
<p className="msg warn" role="status">
{steuernummerHinweis(einstellungen)}
</p>
)}
{kunde && gate.warn && (
<AddressWarning
customer={kunde}
variant="dialog"
onShowOverview={
onShowOverview
? () => {
if (busy !== null) return
onClose()
onShowOverview()
}
: undefined
}
/>
)}
{kunde && (
<>
<Field label="Projekt" htmlFor="rd-projekt">
<select
id="rd-projekt"
className="select"
value={projekt === null ? '?' : projekt}
disabled={konten.loading}
onChange={(ev) => act({ type: 'projekt', projektId: ev.target.value })}
>
{projekt === null && (
<option value="?" disabled>
Projekt wählen …
</option>
)}
{projektOptionen(konten.data?.konten).map((o) => (
<option key={o.value || 'ohne'} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
{konten.loading && (
<p className="msg muted" role="status">
Projekte werden geladen …
</p>
)}
{konten.error && (
<p className="msg warn" role="alert">
Die Projekte konnten nicht geladen werden: {konten.error}{' '}
<button type="button" className="link" onClick={konten.reload}>
Erneut versuchen
</button>
</p>
)}
{konten.data && konten.data.konten.length === 0 && <p className="rd-hint">{KEINE_PROJEKTE_HINWEIS}</p>}
</>
)}
{kunde && projekt === '' && ohne.loading && (
<p className="msg muted" role="status">
Aufträge werden geladen …
</p>
)}
{kunde && projekt === '' && ohne.error && (
<p className="msg warn" role="alert">
Die Aufträge ohne Projekt konnten nicht geladen werden: {ohne.error}{' '}
<button type="button" className="link" onClick={ohne.reload}>
Erneut versuchen
</button>
</p>
)}
{kunde && projektBereit && projekt !== '' && (
<>
{detail.loading && !detail.data && (
<p className="msg muted" role="status">
Projektkonto wird geladen …
</p>
)}
{detail.error && !detail.data && (
<p className="msg warn" role="alert">
Das Projektkonto konnte nicht geladen werden: {detail.error}{' '}
<button type="button" className="link" onClick={detail.reload}>
Erneut versuchen
</button>
</p>
)}
{detail.data && (
<KontoZusammenfassung
zeile={kontoZeile(detail.data.konto, { stripeOk: detail.data.stripeOk })}
hinweis={stripeHinweis(detail.data.stripeOk)}
/>
)}
</>
)}
{kunde && projektBereit && kontoBereit && optionen.length > 1 && (
<fieldset className="rd-group">
<legend>Art der Rechnung</legend>
<div className="rd-stack">
<div className="seg rd-seg" role="group" aria-label="Art der Rechnung">
{optionen.map((o) => (
<button
key={o.key}
type="button"
aria-pressed={o.key === art}
disabled={!o.enabled}
title={o.hint || undefined}
onClick={() => act({ type: 'art', art: o.key })}
>
{o.label}
</button>
))}
</div>
{hinweise.map((h) => (
<p className="rd-hint" key={h}>
{h}
</p>
))}
</div>
</fieldset>
)}
{kunde && projektBereit && kontoBereit && art !== '' && inhalt()}
{kunde && projektBereit && kontoBereit && art !== '' && (
<>
<Field label="Leistungszeitraum (optional)" htmlFor="rd-zeitraum">
<input
id="rd-zeitraum"
className="input"
value={state.zeitraum}
placeholder={ZEITRAUM_PLATZHALTER}
onChange={(ev) => act({ type: 'zeitraum', text: ev.target.value })}
/>
</Field>
<div className="field-row">
<Field label="Zahlungsziel in Tagen" htmlFor="rd-tage">
<input
id="rd-tage"
className="input fin-amount"
inputMode="numeric"
value={state.days}
onChange={(ev) => act({ type: 'tage', wert: ev.target.value })}
/>
</Field>
<Field label="Notiz (erscheint auf der Rechnung)" htmlFor="rd-notiz">
<textarea
id="rd-notiz"
className="textarea"
rows={2}
maxLength={INVOICE_LIMITS.maxMemo}
value={state.memo}
onChange={(ev) => act({ type: 'notiz', wert: ev.target.value })}
/>
</Field>
</div>
</>
)}
</div>
</Dialog>
)
}
@@ -0,0 +1,100 @@
import { Button } from '../ui'
import { euro } from '../../lib/financeFormat'
import { INVOICE_LIMITS, lineTotalCents, invoiceTotalCents } from '../../lib/financeView'
/**
* Positionszeilen des Rechnungsdialogs (Beschreibung, Menge, Einzelpreis, Summe, Entfernen) mit „+ Position“-Knopf.
* Rein darstellend, hält keinen Zustand. Props: legende, labelPrefix (z. B. „Position“ oder „Zusatzposition“), idPrefix,
* zeilen ([{ uid, description, quantity, price }]), minZeilen, onChange(uid, feld, wert), onAdd(), onRemove(uid), addLabel,
* zeigeSumme (Gesamtsumme und Hinweis zu § 19 UStG).
*/
export default function RechnungPositionen({
legende,
labelPrefix,
idPrefix,
zeilen,
minZeilen = 0,
onChange,
onAdd,
onRemove,
addLabel,
zeigeSumme = false,
}) {
return (
<fieldset className="rd-group">
<legend>{legende}</legend>
<div className="rd-stack">
{zeilen.map((zeile, index) => {
const n = index + 1
const summe = lineTotalCents(zeile)
return (
<div className="rd-item" key={zeile.uid}>
<div className="rd-item-field rd-item-desc">
<label className="rd-item-label" htmlFor={`${idPrefix}-desc-${zeile.uid}`}>Beschreibung</label>
<input
id={`${idPrefix}-desc-${zeile.uid}`}
className="input"
value={zeile.description}
maxLength={INVOICE_LIMITS.maxDescription}
placeholder="z. B. Webdesign Startseite"
aria-label={`${labelPrefix} ${n}: Beschreibung`}
onChange={(e) => onChange(zeile.uid, 'description', e.target.value)}
/>
</div>
<div className="rd-item-field rd-item-qty">
<label className="rd-item-label" htmlFor={`${idPrefix}-qty-${zeile.uid}`}>Menge</label>
<input
id={`${idPrefix}-qty-${zeile.uid}`}
className="input fin-amount"
value={zeile.quantity}
inputMode="numeric"
aria-label={`${labelPrefix} ${n}: Menge`}
onChange={(e) => onChange(zeile.uid, 'quantity', e.target.value)}
/>
</div>
<div className="rd-item-field rd-item-price">
<label className="rd-item-label" htmlFor={`${idPrefix}-price-${zeile.uid}`}>Einzelpreis in €</label>
<input
id={`${idPrefix}-price-${zeile.uid}`}
className="input fin-amount"
value={zeile.price}
inputMode="decimal"
placeholder="0,00"
aria-label={`${labelPrefix} ${n}: Einzelpreis in Euro`}
onChange={(e) => onChange(zeile.uid, 'price', e.target.value)}
/>
</div>
<div className="rd-item-field rd-item-sumfeld">
<span className="rd-item-label">Summe</span>
<span className="fin-amount rd-item-sum">{summe === null ? '–' : euro(summe)}</span>
</div>
<div className="rd-item-remove">
<button
type="button"
className="link"
disabled={zeilen.length <= minZeilen}
aria-label={`${labelPrefix} ${n} entfernen`}
onClick={() => onRemove(zeile.uid)}
>
Entfernen
</button>
</div>
</div>
)
})}
<div className="rd-items-foot">
<Button type="button" onClick={onAdd} disabled={zeilen.length >= INVOICE_LIMITS.maxItems}>
{addLabel}
</Button>
{zeigeSumme && (
<div className="rd-total" aria-live="polite">
<span>Summe</span>
<span className="fin-amount">{euro(invoiceTotalCents(zeilen))}</span>
</div>
)}
</div>
{zeigeSumme && <p className="rd-hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>}
</div>
</fieldset>
)
}
@@ -0,0 +1,69 @@
import { euro } from '../../lib/financeFormat'
import { isNegText } from '../../lib/financeUi'
import { zeileTeilen, zeileBearbeitbar, maxVorn } from '../../lib/rechnungAnfrage'
/**
* Vorschau der Rechnungszeilen, wie der Server sie baut. Zeigt immer alle Positionen der Antwort (eine Schlussrechnung hat
* bis zu 40). Rein darstellend. Props: positionen ([{ schluessel, beschreibung, menge, einzelpreisCents, betragCents }]),
* summeCents, bearbeitbar (Tages- und Wochenzeilen der Aufwandsrechnung lassen den Text vor dem letzten „ – “ ändern),
* zeilentexte ({ [schluessel]: Text }), onZeilentext(schluessel, text, original), abgeblendet (Vorschau wird gerade aktualisiert).
*/
export default function RechnungVorschau({ positionen, summeCents, bearbeitbar = false, zeilentexte = {}, onZeilentext, abgeblendet = false }) {
const liste = Array.isArray(positionen) ? positionen : []
const mitEingabe = bearbeitbar && liste.some((p) => zeileBearbeitbar(p))
return (
<div className={abgeblendet ? 'rd-dim' : undefined} aria-busy={abgeblendet}>
<table className="table rd-lines" role="table">
<caption className="rd-sr">Rechnungspositionen</caption>
<thead>
<tr>
<th scope="col">Position</th>
<th scope="col" className="fin-amount">Betrag</th>
</tr>
</thead>
<tbody>
{liste.map((p, index) => {
const betrag = euro(p.betragCents)
const bearbeitbarEs = bearbeitbar && zeileBearbeitbar(p)
const { vorn, hinten } = zeileTeilen(p.beschreibung)
return (
<tr key={`${p.schluessel}-${index}`}>
<td className="rd-line-text">
{bearbeitbarEs ? (
<>
<input
className="input rd-line-input"
aria-label={`Text zu Zeile ${index + 1}`}
value={zeilentexte[p.schluessel] ?? vorn}
maxLength={maxVorn(hinten)}
disabled={abgeblendet}
onChange={(e) => onZeilentext(p.schluessel, e.target.value, vorn)}
/>
<span className="rd-line-tail">{hinten}</span>
</>
) : (
<>
{p.beschreibung}
{p.menge > 1 && (
<div className="cell-sub">
{p.menge} × {euro(p.einzelpreisCents)}
</div>
)}
</>
)}
</td>
<td className={`fin-amount${isNegText(betrag) ? ' fin-neg' : ''}`}>{betrag}</td>
</tr>
)
})}
</tbody>
</table>
<div className="rd-total">
<span>Summe</span>
<span className="fin-amount">{euro(summeCents)}</span>
</div>
<p className="rd-hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
{mitEingabe && <p className="rd-hint">Die Beschreibung der Zeilen lässt sich ändern, Stunden und Beträge stehen fest.</p>}
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
/* Auftragsbestaetigungen (W2): Liste, Dialog-Schritte, Vorschau. Nur Tokens, alle Klassen beginnen mit auftrag-.
Die Datei wird von der Komponente geladen, global.css kommt danach: darum nie eine globale Klasse ueberschreiben. */
/* Liste */
.auftrag-body { container-type: inline-size; }
.auftrag-notice { margin: 0 0 8px; }
.auftrag-table-wrap { overflow-x: auto; }
.auftrag-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.auftrag-nummer { white-space: nowrap; }
.auftrag-titel { overflow-wrap: anywhere; }
.auftrag-table th.auftrag-amount { text-align: right; }
.auftrag-amount { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.auftrag-note { margin-top: 2px; font-size: 13px; overflow-wrap: anywhere; }
.auftrag-actions { display: flex; flex-wrap: wrap; gap: 4px 14px; }
.auftrag-zuordnen { padding: 2px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 8px; }
.auftrag-zuordnen .select { flex: 1 1 180px; min-width: 0; max-width: 100%; }
.auftrag-liste button.link:disabled, .auftrag-liste button.link-danger:disabled { cursor: not-allowed; }
@container (max-width: 860px) {
.auftrag-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.auftrag-table, .auftrag-table tbody { display: block; }
.auftrag-liste .auftrag-table tr {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; align-items: baseline;
grid-template-areas: "nummer status" "titel titel" "datum summe" "aktionen aktionen";
padding: 10px 0; border-bottom: 1px solid var(--line);
}
.auftrag-liste .auftrag-table td, .auftrag-liste .auftrag-table td:first-child, .auftrag-liste .auftrag-table td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
.auftrag-nummer { grid-area: nummer; }
.auftrag-status { grid-area: status; text-align: right; }
.auftrag-titel { grid-area: titel; }
.auftrag-datum { grid-area: datum; }
.auftrag-liste .auftrag-table td.auftrag-amount { grid-area: summe; }
.auftrag-liste .auftrag-table td.auftrag-aktionen { grid-area: aktionen; padding-top: 4px; }
.auftrag-table td[data-label]::before { content: attr(data-label) " "; color: var(--text-2); font-weight: 500; }
}
/* Dialog: Schritte 1 bis 3 */
.auftrag-schritt { display: grid; gap: 16px; min-width: 0; }
.auftrag-schritt .field { min-width: 0; }
.auftrag-art { border: 0; padding: 0; margin: 0; display: grid; gap: 8px; min-width: 0; }
.auftrag-art legend { font-weight: 500; padding: 0; margin-bottom: 4px; }
.auftrag-art .check { flex-wrap: wrap; }
.auftrag-check-text { flex: 1 1 0; min-width: 0; overflow-wrap: anywhere; }
.auftrag-zeitquelle-liste .auftrag-check-text { flex: 0 1 auto; }
.auftrag-art .check .hint { flex-basis: 100%; padding-left: 23px; font-size: 13px; color: var(--text-2); }
.auftrag-items { border: 0; padding: 0; margin: 0; display: grid; gap: 12px; min-width: 0; }
.auftrag-items legend { font-weight: 500; padding: 0; margin-bottom: 4px; }
.auftrag-item {
display: grid; grid-template-columns: minmax(0, 1fr) 80px 120px 110px auto; gap: 8px 12px; align-items: end;
grid-template-areas: "desc qty price sum remove";
}
.auftrag-item .input { width: 100%; }
.auftrag-item > div { display: grid; gap: 4px; min-width: 0; }
.auftrag-item-desc { grid-area: desc; }
.auftrag-item-qty { grid-area: qty; }
.auftrag-item-price { grid-area: price; }
.auftrag-item-sum { grid-area: sum; }
.auftrag-item-remove { grid-area: remove; }
.auftrag-item-qty .input, .auftrag-item-price .input { text-align: right; font-variant-numeric: tabular-nums; }
.auftrag-item-wert { min-height: 36px; display: flex; align-items: center; justify-content: flex-end; font-variant-numeric: tabular-nums; white-space: nowrap; }
.auftrag-item-remove { min-height: 36px; align-content: center; }
.auftrag-item-label { font-size: 12px; color: var(--text-2); }
.auftrag-item + .auftrag-item .auftrag-item-label { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.auftrag-total { display: flex; justify-content: space-between; gap: 16px; font-weight: 600; }
.auftrag-narrow { max-width: 120px; }
.auftrag-plan-vorschau { margin: 0; overflow-wrap: anywhere; }
.auftrag-hinweis { margin: 0; overflow-wrap: anywhere; }
.auftrag-zeitquelle { border: 0; padding: 0; margin: 0; display: grid; gap: 6px; min-width: 0; }
.auftrag-zeitquelle legend { font-weight: 500; padding: 0; margin-bottom: 4px; }
.auftrag-zeitquelle-liste { display: grid; gap: 6px; max-height: 220px; overflow-y: auto; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 6px 0; }
.auftrag-zeitquelle-liste .check { flex-wrap: wrap; overflow-wrap: anywhere; }
@media (max-width: 860px) {
.auftrag-item {
grid-template-columns: minmax(0, .7fr) minmax(0, 1fr) minmax(0, 1fr) auto;
grid-template-areas: "desc desc desc desc" "qty price sum remove";
}
.auftrag-item + .auftrag-item .auftrag-item-label, .auftrag-item-label { position: static; width: auto; height: auto; margin: 0; overflow: visible; clip: auto; white-space: normal; }
.auftrag-narrow { max-width: none; }
}
/* Dialog: Schrittleiste, Vorschau, Fuss */
.auftrag-schritte { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 13px; color: var(--text-2); }
.auftrag-schritte li[aria-current="step"] { color: var(--text); font-weight: 600; }
.auftrag-schritt-nr { margin-right: 6px; }
.auftrag-pdf { width: 100%; height: min(70vh, 640px); border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-subtle); }
.auftrag-foot { display: flex; flex: 1; flex-wrap: wrap; align-items: center; gap: 8px; }
.auftrag-foot .grow { flex: 1; }
@media (max-width: 860px) {
.auftrag-schritt-text { display: none; }
.auftrag-schritte li[aria-current="step"] .auftrag-schritt-text { display: inline; }
.auftrag-pdf { height: 50vh; }
.auftrag-foot .btn { flex: 1 1 auto; }
}
+30
View File
@@ -0,0 +1,30 @@
/* Projektliste im Kundenreiter „Aufträge und Rechnungen“. .pk ist der Container der Abfrage unten (in W1 nur hier
container-type; der Rechnungsdialog liegt nie darin). Jede Regel trägt .pk und überstimmt so .table td aus global.css. */
.pk { container-type: inline-size; }
.pk .pk-hinweis { margin: 0 0 8px; }
.pk .pk-table th, .pk .pk-table td { vertical-align: top; }
.pk .pk-name { overflow-wrap: anywhere; } /* lange Projektnamen ohne Umbruchstelle dürfen die Spalte nie aufweiten */
.pk .pk-table td .cell-sub { overflow-wrap: break-word; }
/* Beträge und Stunden brechen nie um: das € bleibt am Betrag */
.pk .pk-table .num, .pk .pk-berechnet .cell-sub, .pk .pk-zeit .cell-sub { white-space: nowrap; }
.pk .pk-table .num { font-variant-numeric: tabular-nums; }
.pk .pk-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 14px; }
.pk .pk-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@container (max-width: 860px) {
.pk .pk-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.pk .pk-table, .pk .pk-table tbody { display: block; }
.pk .pk-table tr {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; align-items: start;
grid-template-areas: "name name" "vereinbart berechnet" "offen zeit" "actions actions";
padding: 12px 0; border-bottom: 1px solid var(--line);
}
.pk .pk-table td, .pk .pk-table td:first-child, .pk .pk-table td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
.pk .pk-name { grid-area: name; }
.pk .pk-vereinbart { grid-area: vereinbart; }
.pk .pk-berechnet { grid-area: berechnet; }
.pk .pk-offen { grid-area: offen; }
.pk .pk-zeit { grid-area: zeit; }
.pk .pk-actions-cell { grid-area: actions; }
.pk .pk-actions { justify-content: flex-start; }
.pk .pk-table td[data-label]::before { content: attr(data-label); display: block; font-size: 12px; font-weight: 500; color: var(--text-2); }
}
@@ -0,0 +1,59 @@
/* Rechnung schreiben (Dialog). Dieses Stylesheet steht im Bundle vor global.css: Regeln, die globale Klassen überstimmen
müssen, tragen zwei Klassen. Task 9 hängt am Ende die Spalten der Rechnungstabelle an. */
.rd-group { border: 0; margin: 0; padding: 0; min-width: 0; }
.rd-group > legend { padding: 0; margin: 0 0 6px; font-weight: 500; }
.rd-stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; }
.rd-facts.facts { margin: 0; }
.seg.rd-seg { flex-wrap: wrap; max-width: 100%; justify-self: start; }
.rd-hint { margin: 0; font-size: 13px; color: var(--text-2); overflow-wrap: break-word; }
/* Positionen */
.rd-item { display: grid; grid-template-columns: minmax(0, 1fr) 80px 120px 110px auto; gap: 8px 12px; align-items: end; }
.rd-item .input { width: 100%; }
.rd-item-field { min-width: 0; display: grid; gap: 4px; }
.rd-item-label { font-size: 12px; color: var(--text-2); }
.rd-item-sum { text-align: right; white-space: nowrap; padding-bottom: 6px; }
.rd-items-foot { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.rd-total { display: flex; gap: 16px; font-weight: 600; }
@media (max-width: 860px) {
.rd-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) auto; grid-template-areas: "desc desc desc desc" "qty price sum remove"; }
.rd-item-desc { grid-area: desc; }
.rd-item-qty { grid-area: qty; }
.rd-item-price { grid-area: price; }
.rd-item-sumfeld { grid-area: sum; }
.rd-item-remove { grid-area: remove; }
}
/* Arbeitsblätter */
.rd-blaetter { border-top: 1px solid var(--line); }
.rd-blatt { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 4px 12px; align-items: start; padding: 8px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
.rd-blatt input { accent-color: var(--accent); width: 15px; height: 15px; margin-top: 3px; }
.rd-blatt-text { min-width: 0; overflow-wrap: break-word; }
.rd-blatt-zeit { white-space: nowrap; font-variant-numeric: tabular-nums; }
.rd-weiter { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; }
/* Vorschau der Rechnungszeilen */
.rd-lines { table-layout: fixed; }
.rd-lines .rd-line-text { overflow-wrap: break-word; }
.rd-lines .fin-amount { width: 7.5rem; }
.rd-lines th.fin-amount { text-align: right; }
.rd-line-input { width: 100%; min-width: 0; }
.rd-line-tail { display: block; font-size: 13px; color: var(--text-2); overflow-wrap: break-word; }
.rd-dim { opacity: 0.5; }
/* nur für Screenreader sichtbar */
.rd-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* Fuß des Dialogs */
.rd-foot { display: flex; flex: 1; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
@media (max-width: 860px) { .rd-foot .btn { flex: 1 1 auto; } }
/* Rechnungstabelle (InvoiceTable): Spalten Projekt und Art. Zwei Klassen vor dem Elementnamen, damit diese Rasterflächen
die Regel .fin-invoices tr aus global.css überstimmen (dieses Stylesheet steht davor im Bundle). */
.fin-block .fin-inv-project { overflow-wrap: break-word; }
.fin-block .fin-inv-art { white-space: nowrap; }
@container (max-width: 860px) {
.fin-block .fin-invoices tr { grid-template-areas: "number status" "customer customer" "projekt art" "created due" "total remaining" "actions actions"; }
.fin-block .fin-inv-project { grid-area: projekt; }
.fin-block .fin-inv-art { grid-area: art; }
}
@@ -1,29 +0,0 @@
import { Button, EmptyState } from '../ui'
import AddressWarning from './AddressWarning'
import InvoiceTable from './InvoiceTable'
import { customerInvoicesGate } from '../../lib/financeView'
/**
* Reiter „Rechnungen“ am Kunden. Kann für den Kunden keine Rechnung geschrieben werden (noch Lead, abgesagt, keine
* E-Mail), steht der Grund da, mit „Daten erfassen“ (onShowOverview wechselt zum Reiter Übersicht). Sonst die
* Rechnungen dieses Kunden mit „Rechnung schreiben“ (der Kunde steht im Dialog fest). Fehlt dem Kunden die Anschrift
* (Straße, PLZ, Ort), steht darüber eine Warnung mit Knopf zur Übersicht; „Senden“ ist dann über 250 € gesperrt.
*/
export default function CustomerInvoices({ customer, onShowOverview }) {
const gate = customerInvoicesGate(customer)
if (!gate.ok) {
return (
<EmptyState
title={gate.title}
hint={gate.hint}
action={onShowOverview ? <Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button> : null}
/>
)
}
return (
<>
<AddressWarning customer={customer} onShowOverview={onShowOverview} variant="page" />
<InvoiceTable customer={customer} onShowOverview={onShowOverview} />
</>
)
}
@@ -1,320 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { AutocompleteInput, Button, Dialog, Field } from '../ui'
import AddressWarning from './AddressWarning'
import { useCustomers } from '../../hooks/useCustomers'
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
import { createInvoice } from '../../lib/financeApi'
import { canInvoice, euro } from '../../lib/financeFormat'
import {
ADDRESS_INCOMPLETE_TEXT,
INVOICE_LIMITS,
customerName,
customerPickerOptions,
customerSuggestions,
finalizedFilter,
invoiceFailureMessage,
invoiceSendConfirmText,
invoiceSendGate,
invoiceTotalCents,
isFinalizedNotSent,
lineTotalCents,
resolveCustomerText,
validateInvoiceForm,
} from '../../lib/financeView'
let itemCounter = 0
const newItem = () => ({ uid: ++itemCounter, description: '', quantity: '1', price: '' })
/**
* Dialog „Rechnung schreiben“. Kunde (nur feste Kunden mit E-Mail wählbar; mit `fixedCustomer` steht er fest),
* Positionen (Beschreibung, Menge, Einzelpreis in Euro), Zahlungsziel (Standard 14 Tage), Notiz, Summe und der
* Hinweis nach § 19 UStG. „Als Entwurf speichern“ und „Senden“ sperren sich gegenseitig, solange die Anfrage läuft;
* Fehler des Servers stehen im Dialog (und werden in den sichtbaren Bereich geholt). „Senden“ fragt vorher nach
* (die Rechnung wird festgeschrieben und per Mail an den Kunden geschickt). Fehlt dem Kunden die Anschrift (Straße,
* PLZ, Ort), steht eine Warnung mit Weg zur Übersicht da, und „Senden“ ist über 250 € gesperrt (§ 14 Abs. 4 Nr. 1
* UStG; Entwürfe bleiben möglich). `onShowOverview` (Reiter am Kunden) wechselt zur Übersicht, sonst führt ein Link
* zur Kundenseite.
*
* onCreated(rechnung, { senden }) nach Erfolg; wurde die Rechnung festgeschrieben, aber nicht versendet (502 mit
* invoiceId), kommt onCreated(null, { senden: true, notice, tone: 'warn', finalizedFilter }) und der Dialog schließt
* (die Rechnung steht dann „offen“ in der Liste und lässt sich dort erneut senden). Danach ruft der Dialog onClose().
*/
export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCustomer = null, onShowOverview = null }) {
if (!open) return null
// Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht
return fixedCustomer ? (
<InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={fixedCustomer} source={null} onShowOverview={onShowOverview} />
) : (
<PickerForm onClose={onClose} onCreated={onCreated} />
)
}
function PickerForm({ onClose, onCreated }) {
const { customers, loading, error } = useCustomers()
return <InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={null} source={{ customers, loading, error }} onShowOverview={null} />
}
function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview }) {
const [customerText, setCustomerText] = useState('')
const [items, setItems] = useState(() => [newItem()])
const [days, setDays] = useState(String(INVOICE_LIMITS.defaultDays))
const [memo, setMemo] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(null) // 'draft' | 'send'
const lockRef = useRef(false)
const aliveRef = useRef(true)
const errorRef = useRef(null)
// Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen
useScrollIntoView(errorRef, error)
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
// × und Esc kommen vom Dialog (er ignoriert Esc, wenn die Kundenliste die Taste schon behandelt hat, und solange `busy` gilt).
// Die Sperre deckt den Augenblick zwischen Klick und erstem Zeichnen von `busy` ab.
const close = () => {
if (lockRef.current) return
onClose()
}
const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers])
const resolved = fixedCustomer
? { state: canInvoice(fixedCustomer).ok ? 'ok' : 'blocked', option: null }
: resolveCustomerText(options, customerText)
const customer = fixedCustomer || resolved.option?.customer || null
const customerBlocked = fixedCustomer ? canInvoice(fixedCustomer) : resolved.option ? { ok: resolved.option.ok, reason: resolved.option.reason } : { ok: true }
const total = invoiceTotalCents(items)
// Anschrift des Empfängers (§ 14 Abs. 4 Nr. 1 UStG): ohne sie ist „Senden“ nur bis 250 € möglich
const gate = invoiceSendGate(customerBlocked.ok ? customer : null, total)
const updateItem = (uid, field, value) => {
setItems((list) => list.map((it) => (it.uid === uid ? { ...it, [field]: value } : it)))
if (error) setError('')
}
async function submit(senden) {
if (lockRef.current) return
const check = validateInvoiceForm({ customer, items, days, memo })
if (!check.ok) {
setError(check.error)
return
}
if (senden) {
if (!invoiceSendGate(customer, check.totalCents).sendAllowed) {
setError(ADDRESS_INCOMPLETE_TEXT)
return
}
// Senden schreibt die Rechnung fest und schickt sie an den Kunden: erst nach Rückfrage
const sure = window.confirm(
invoiceSendConfirmText({ name: customerName(customer), email: customer.email, totalCents: check.totalCents })
)
if (!sure) return
}
lockRef.current = true
setBusy(senden ? 'send' : 'draft')
setError('')
try {
const data = await createInvoice({ ...check.payload, senden })
if (onCreated) onCreated(data?.rechnung || null, { senden })
onClose()
} catch (err) {
if (isFinalizedNotSent(err)) {
// Die Rechnung steht bei Stripe: keine zweite anlegen, die Liste zeigt sie
if (onCreated) onCreated(null, { senden: true, notice: invoiceFailureMessage(err), tone: 'warn', finalizedFilter: finalizedFilter(err) })
onClose()
return
}
if (aliveRef.current) setError(invoiceFailureMessage(err))
} finally {
lockRef.current = false
if (aliveRef.current) setBusy(null)
}
}
const disabled = busy !== null
const footer = (
<div className="fin-foot">
<Button type="button" onClick={close} disabled={disabled}>Abbrechen</Button>
<Button type="button" onClick={() => submit(false)} disabled={disabled}>
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
</Button>
<Button
type="button"
variant="primary"
onClick={() => submit(true)}
disabled={disabled || !gate.sendAllowed}
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
>
{busy === 'send' ? 'Sende …' : 'Senden'}
</Button>
</div>
)
// Enter sendet nie (das wäre eine Mail an den Kunden): das Formular hat keinen Absenden-Knopf, jeder Knopf darin ist type="button"
return (
<Dialog as="form" wide title="Rechnung schreiben" onClose={close} busy={disabled} onSubmit={(e) => e.preventDefault()} footer={footer}>
{error && (
<div className="msg err" role="alert" ref={errorRef}>
{error}
</div>
)}
<Field label="Kunde" htmlFor="fin-invoice-customer">
{fixedCustomer ? (
<p className="fin-fixed-customer">
<strong className="strong">{customerName(fixedCustomer) || 'Kunde'}</strong>
{fixedCustomer.email ? <span className="msg muted"> · {fixedCustomer.email}</span> : null}
</p>
) : source?.loading ? (
<p className="msg muted" role="status">Kunden werden geladen …</p>
) : source?.error ? (
<p className="msg warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
) : (
<AutocompleteInput
id="fin-invoice-customer"
value={customerText}
onChange={(v) => {
setCustomerText(v)
if (error) setError('')
}}
getSuggestions={(t) => customerSuggestions(options, t)}
placeholder="Kunde suchen …"
/>
)}
{!customerBlocked.ok && (
<p className="msg warn" role="alert">
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
</p>
)}
{!fixedCustomer && resolved.state === 'unknown' && (
<p className="msg muted">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
)}
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
<p className="msg muted">Die Rechnung geht an {customer.email}.</p>
)}
{gate.warn && (
<AddressWarning
customer={customer}
variant="dialog"
onShowOverview={
onShowOverview
? () => {
if (lockRef.current) return
onClose()
onShowOverview()
}
: undefined
}
/>
)}
</Field>
<fieldset className="fin-items">
<legend>Positionen</legend>
{items.map((item, index) => {
const line = lineTotalCents(item)
const n = index + 1
return (
<div key={item.uid} className="fin-item">
<div className="fin-item-field fin-item-desc">
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
<input
id={`fin-item-desc-${item.uid}`}
className="input"
maxLength={INVOICE_LIMITS.maxDescription}
value={item.description}
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
aria-label={`Position ${n}: Beschreibung`}
placeholder="z. B. Webdesign Startseite"
/>
</div>
<div className="fin-item-field fin-item-qty">
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
<input
id={`fin-item-qty-${item.uid}`}
className="input fin-amount"
inputMode="numeric"
value={item.quantity}
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
aria-label={`Position ${n}: Menge`}
/>
</div>
<div className="fin-item-field fin-item-price">
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
<input
id={`fin-item-price-${item.uid}`}
className="input fin-amount"
inputMode="decimal"
value={item.price}
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
aria-label={`Position ${n}: Einzelpreis in Euro`}
placeholder="0,00"
/>
</div>
<div className="fin-item-field fin-item-sum">
<span className="fin-item-label">Summe</span>
<span className="fin-amount fin-item-total">{line === null ? '–' : euro(line)}</span>
</div>
<div className="fin-item-field fin-item-remove">
<button
type="button"
className="link"
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
disabled={items.length <= 1}
aria-label={`Position ${n} entfernen`}
>
Entfernen
</button>
</div>
</div>
)
})}
<div className="fin-items-foot">
<Button
type="button"
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
disabled={items.length >= INVOICE_LIMITS.maxItems}
>
+ Position
</Button>
<div className="fin-total" aria-live="polite">
<span>Summe</span>
<span className="fin-amount">{euro(total)}</span>
</div>
</div>
<p className="msg muted">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
</fieldset>
<div className="fin-dialog-row">
<Field label="Zahlungsziel in Tagen" htmlFor="fin-invoice-days">
<input
id="fin-invoice-days"
className="input fin-amount"
inputMode="numeric"
value={days}
onChange={(e) => {
setDays(e.target.value)
if (error) setError('')
}}
/>
</Field>
<Field label="Notiz (erscheint auf der Rechnung)" htmlFor="fin-invoice-memo">
<textarea
id="fin-invoice-memo"
className="textarea"
rows={2}
maxLength={INVOICE_LIMITS.maxMemo}
value={memo}
onChange={(e) => setMemo(e.target.value)}
/>
</Field>
</div>
</Dialog>
)
}
+10 -3
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Badge, Button, Card, EmptyState, Stat } from '../ui'
import InvoiceCreateModal from './InvoiceCreateModal'
import RechnungDialog from '../auftraege/RechnungDialog'
import Pager from './Pager'
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
import { pageOf } from '../../lib/financeFormat'
import { noticeProps } from '../../lib/financeUi'
import { artLabel, projektSpalte } from '../../lib/projektkontoView'
import {
ADDRESS_INCOMPLETE_TEXT,
DEFAULT_INVOICE_FILTER,
@@ -37,6 +38,8 @@ const CALLS = { send: sendInvoice, resend: resendInvoice, void: voidInvoice, del
* wird jede Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. „Senden“ eines Entwurfs
* fragt vorher nach. In der Kundenansicht ist „Senden“ gesperrt, solange dem Kunden die Anschrift fehlt und der
* Entwurf über 250 € liegt (§ 14 Abs. 4 Nr. 1 UStG); `onShowOverview` führt dann im Dialog zur Übersicht.
* Die Tabelle zeigt zusätzlich die Spalten Projekt und Art; „Rechnung schreiben“ öffnet den Dialog `RechnungDialog`
* (Projektwahl, Arten, Vorschau).
*
* „Offen“ zeigt offene und überfällige Rechnungen (zwei Abrufe, zusammengeführt, neueste zuerst). Ändert sich
* `refreshToken` (die Seite lädt nach Zuordnen, Bankabruf usw. alles neu), lädt auch die Liste neu.
@@ -204,6 +207,8 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
<tr role="row">
<th role="columnheader" scope="col">Nr.</th>
<th role="columnheader" scope="col">Kunde</th>
<th role="columnheader" scope="col">Projekt</th>
<th role="columnheader" scope="col">Art</th>
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col">Fällig</th>
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
@@ -220,6 +225,8 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
{row.isAbo && <span className="fin-inv-abo muted"> · Abo</span>}
</td>
<td role="cell" className="fin-inv-customer">{row.customerText}</td>
<td role="cell" className="fin-inv-project" data-label="Projekt">{projektSpalte(byId[row.id])}</td>
<td role="cell" className="fin-inv-art" data-label="Art">{artLabel(byId[row.id]?.art)}</td>
<td role="cell" className="fin-inv-created" data-label="Datum">{row.createdText}</td>
<td role="cell" className="fin-inv-due" data-label="Fällig">{row.dueText}</td>
<td role="cell" className="fin-amount fin-inv-total" data-label="Betrag">{row.totalText}</td>
@@ -290,11 +297,11 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
</div>
</Card>
<InvoiceCreateModal
<RechnungDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={handleCreated}
fixedCustomer={customer}
kunde={customer}
onShowOverview={onShowOverview}
/>
</section>
+25 -4
View File
@@ -13,6 +13,7 @@ import {
settingsToForm,
validateSettingsForm,
} from '../../lib/financeSettings'
import { STEUERNUMMER_HINWEIS, steuernummerAenderung } from '../../lib/kundenFelder'
const LOAD_ERROR = 'Die Einstellungen konnten nicht geladen werden.'
const fieldId = (name) => `fin-set-${name}`
@@ -44,6 +45,8 @@ export default function SettingsCard() {
function SettingsForm({ initial }) {
const [form, setForm] = useState(() => settingsToForm(initial))
const [steuernummer, setSteuernummer] = useState(() => (typeof initial?.steuernummer === 'string' ? initial.steuernummer : ''))
const gespeichertRef = useRef(typeof initial?.steuernummer === 'string' ? initial.steuernummer : '')
const [errors, setErrors] = useState({})
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null) // { tone: 'ok' | 'warn', text }
@@ -68,10 +71,12 @@ function SettingsForm({ initial }) {
e.preventDefault()
if (lockRef.current) return
const check = validateSettingsForm(form)
if (!check.ok) {
setErrors(check.errors)
const stn = steuernummerAenderung(steuernummer, gespeichertRef.current)
if (!check.ok || !stn.ok) {
const fehler = { ...(check.errors || {}), ...(stn.ok ? {} : { steuernummer: stn.fehler }) }
setErrors(fehler)
setNotice({ tone: 'warn', text: 'Bitte die markierten Felder prüfen. Es wurde nichts gespeichert.' })
const first = SETTINGS_FIELD_ORDER.find((name) => check.errors[name])
const first = [...SETTINGS_FIELD_ORDER, 'steuernummer'].find((name) => fehler[name])
if (first) document.getElementById(fieldId(first))?.focus()
return
}
@@ -80,9 +85,12 @@ function SettingsForm({ initial }) {
setErrors({})
setNotice(null)
try {
const data = await patchSettings(check.payload)
const data = await patchSettings({ ...check.payload, ...stn.payload })
if (!aliveRef.current) return
if (data?.einstellungen) setForm(settingsToForm(data.einstellungen))
const gespeichert = typeof data?.einstellungen?.steuernummer === 'string' ? data.einstellungen.steuernummer : steuernummer.trim()
setSteuernummer(gespeichert)
gespeichertRef.current = gespeichert
setNotice({ tone: 'ok', text: 'Gespeichert.' })
} catch (err) {
if (aliveRef.current) setNotice({ tone: 'warn', text: apiErrorMessage(err) })
@@ -148,6 +156,19 @@ function SettingsForm({ initial }) {
<div className={`fin-counter${tooLong ? ' fin-counter-over' : ''}`}>
{length} / {SETTINGS_LIMITS.maxBankDetails} Zeichen
</div>
<FormField id={fieldId('steuernummer')} label="Steuernummer" hint={STEUERNUMMER_HINWEIS} error={errors.steuernummer}>
<input
className="input"
autoComplete="off"
value={steuernummer}
onChange={(e) => {
setSteuernummer(e.target.value)
setErrors((er) => (er.steuernummer ? { ...er, steuernummer: undefined } : er))
if (notice) setNotice(null)
}}
placeholder="z. B. 12/345/67890 oder DE123456789"
/>
</FormField>
</fieldset>
{notice && <p {...noticeProps(notice.tone, 'fin-notice')}>{notice.text}</p>}