632 lines
24 KiB
React
632 lines
24 KiB
React
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>
|
||
)
|
||
}
|