Rechnung schreiben: neuer Dialog mit Projektwahl, Zusammenfassung, Art, Aufwand mit Vorschau, Anzahlung und Schlussrechnung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8ab78b7b2c
commit
2aa4abeadb
3 files changed
+603
-9
No files matched your search
@@ -1,13 +1,599 @@
|
|||||||
|
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,
|
||||||
|
useProjektkontoDetail,
|
||||||
|
useProjektkonten,
|
||||||
|
useRechnungVorschau,
|
||||||
|
useWeitereBlaetter,
|
||||||
|
} from '../../hooks/useRechnungDaten'
|
||||||
|
import { createInvoice } from '../../lib/financeApi'
|
||||||
|
import { canInvoice, euro } 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,
|
||||||
|
aufwandAuftrag,
|
||||||
|
berechnetFuerAuftrag,
|
||||||
|
gueltigeFestpreisAbs,
|
||||||
|
projektOptionen,
|
||||||
|
standardArt,
|
||||||
|
standardAuftrag,
|
||||||
|
startProjekt,
|
||||||
|
ticketOptionen,
|
||||||
|
wirksamesProjekt,
|
||||||
|
} from '../../lib/rechnungDialogView'
|
||||||
|
import {
|
||||||
|
BEZEICHNUNG_MAX,
|
||||||
|
VORSCHAU_ARTEN,
|
||||||
|
createSubmitter,
|
||||||
|
darfSpeichern,
|
||||||
|
rechnungAnfrage,
|
||||||
|
vorschauAnfrage,
|
||||||
|
vorschauSchluessel,
|
||||||
|
vorschauStatus,
|
||||||
|
} from '../../lib/rechnungAnfrage'
|
||||||
|
import { anfangszustand, reduce } from '../../lib/rechnungDialogState'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Platzhalter von W0: Strang W1 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben).
|
* Dialog „Rechnung schreiben“ (ersetzt InvoiceCreateModal). 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 (V8): open, onClose, onCreated(rechnung, info), kunde (Kundendokument oder null = Kundenwahl im Dialog),
|
* Props: open, onClose, onCreated(rechnung, info), kunde (Kundendokument; null = Kundenwahl im Dialog), projektId (undefined =
|
||||||
* projektId (undefined = keine Vorauswahl, null = „Ohne Projekt“, Text = dieses Projekt), auftraegeAktiv (nur der Startwert,
|
* keine Vorauswahl, null = „Ohne Projekt“, Text = dieses Projekt), auftraegeAktiv (nur der Startwert, danach gilt
|
||||||
* danach gilt getProjektkonten, das der Dialog selbst lädt), onShowOverview (optional, „Zur Übersicht“ in der Adresswarnung).
|
* getProjektkonten), onShowOverview (optional, „Zur Übersicht“ in der Adresswarnung).
|
||||||
* `info` von onCreated wird unverändert weitergereicht wie im bisherigen InvoiceCreateModal ({ senden }, bei festgeschriebener,
|
* onCreated bekommt wie im bisherigen Dialog ({ senden }) bzw. bei festgeschriebener, nicht versendeter Rechnung
|
||||||
* nicht versendeter Rechnung { senden: true, notice, tone: 'warn', finalizedFilter }); danach ruft der Dialog onClose().
|
* (502 mit invoiceId) (null, { senden: true, notice, tone: 'warn', finalizedFilter }); danach ruft der Dialog onClose().
|
||||||
* Der Dialog sitzt zusätzlich in InvoiceTable.jsx (dort ersetzt W1 das Modal), FinancePage.jsx bleibt unverändert.
|
* Enter sendet nie (das Formular hat keinen Absenden-Knopf); „Senden“ fragt vorher nach.
|
||||||
*/
|
*/
|
||||||
export default function RechnungDialog({ open, onClose, onCreated, kunde, projektId, auftraegeAktiv, onShowOverview }) {
|
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 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 || '')
|
||||||
|
const auftraege = detail.data?.auftraege || []
|
||||||
|
const rechnungen = detail.data?.rechnungen || []
|
||||||
|
|
||||||
|
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 art = optionen.some((o) => o.key === state.art && o.enabled)
|
||||||
|
? state.art
|
||||||
|
: standardArt(optionen, { auftraege, rechnungen, offeneMinuten: detail.data?.konto?.zeit?.offeneMinuten || 0 })
|
||||||
|
|
||||||
|
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 !== ''
|
||||||
|
|
||||||
|
// --- 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(detail.reload)
|
||||||
|
detailReloadRef.current = detail.reload
|
||||||
|
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) detail.reload()
|
||||||
|
} 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
|
||||||
|
const berechnet = berechnetFuerAuftrag(rechnungen, ab.id)
|
||||||
|
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">
|
||||||
|
Auftragssumme {euro(ab.summeCents)} · bereits berechnet {euro(berechnet)} · noch nicht berechnet {euro(Math.max(0, ab.summeCents - berechnet))}
|
||||||
|
</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
|
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>}
|
||||||
|
{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 && 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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
müssen, tragen zwei Klassen. Task 9 hängt am Ende die Spalten der Rechnungstabelle an. */
|
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 { border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||||
.rd-group > legend { padding: 0; margin: 0 0 6px; font-weight: 500; }
|
.rd-group > legend { padding: 0; margin: 0 0 6px; font-weight: 500; }
|
||||||
.rd-stack { display: grid; gap: 8px; }
|
.rd-stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; }
|
||||||
.rd-facts.facts { margin: 0; }
|
.rd-facts.facts { margin: 0; }
|
||||||
.seg.rd-seg { flex-wrap: wrap; max-width: 100%; justify-self: start; }
|
.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: anywhere; }
|
.rd-hint { margin: 0; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
.rd-lines { table-layout: fixed; }
|
.rd-lines { table-layout: fixed; }
|
||||||
.rd-lines .rd-line-text { overflow-wrap: anywhere; }
|
.rd-lines .rd-line-text { overflow-wrap: anywhere; }
|
||||||
.rd-lines .fin-amount { width: 7.5rem; }
|
.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-input { width: 100%; min-width: 0; }
|
||||||
.rd-line-tail { display: block; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
|
.rd-line-tail { display: block; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
|
||||||
.rd-dim { opacity: 0.5; }
|
.rd-dim { opacity: 0.5; }
|
||||||
|
|||||||
@@ -80,12 +80,19 @@ export function useRechnungVorschau({ anfrage, schluessel, aktiv }) {
|
|||||||
const bodyRef = useRef(null)
|
const bodyRef = useRef(null)
|
||||||
bodyRef.current = anfrage?.ok ? anfrage.body : null
|
bodyRef.current = anfrage?.ok ? anfrage.body : null
|
||||||
const datenRef = useRef(null)
|
const datenRef = useRef(null)
|
||||||
|
const kontextRef = useRef('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!aktiv || !bodyRef.current || !schluessel) {
|
if (!aktiv || !bodyRef.current || !schluessel) {
|
||||||
datenRef.current = null
|
datenRef.current = null
|
||||||
|
kontextRef.current = ''
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
// Vorherige Zeilen bleiben nur innerhalb derselben Rechnung (Kunde, Art, Projekt, AB) abgeblendet stehen
|
||||||
|
const { customerId, art, projektId, auftragId } = bodyRef.current
|
||||||
|
const kontext = JSON.stringify([customerId, art, projektId, auftragId])
|
||||||
|
if (kontext !== kontextRef.current) datenRef.current = null
|
||||||
|
kontextRef.current = kontext
|
||||||
let aktuell = true
|
let aktuell = true
|
||||||
setVorschau({ key: schluessel, status: 'laedt', daten: datenRef.current })
|
setVorschau({ key: schluessel, status: 'laedt', daten: datenRef.current })
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user