Merge auftraege/w1-rechnungen: Rechnungs-UI (Strang W1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
05170068bc
23 files changed
+2786
-339
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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,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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,81 @@
|
||||
import './projektkonto.css'
|
||||
import { Card } from '../ui'
|
||||
import { kontoZeile, ohneProjektZeile, stripeHinweis, KEINE_PROJEKTE_HINWEIS } from '../../lib/projektkontoView'
|
||||
|
||||
/**
|
||||
* Platzhalter von W0: Strang W1 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben).
|
||||
* Die fertige Komponente rendert ihren Abschnitt selbst: <Card title="Projekte"> (ein <section>; den Abstand zu den
|
||||
* Nachbarn liefert `.page section + section`).
|
||||
*
|
||||
* Props (V8): konten (Projektkonten), ohneProjekt ({ berechnetCents, bezahltCents, anzahl }), auftraegeAktiv, stripeOk,
|
||||
* onRechnung(projektId) und onAuftrag(projektId); die Kennung ist die des Projekts, `null` die Zeile „Ohne Projekt“.
|
||||
* 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 }) {
|
||||
return null
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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“. 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),
|
||||
* projektId (undefined = keine Vorauswahl, null = „Ohne Projekt“, Text = dieses Projekt), auftraegeAktiv (nur der Startwert,
|
||||
* danach gilt getProjektkonten, das der Dialog selbst lädt), onShowOverview (optional, „Zur Übersicht“ in der Adresswarnung).
|
||||
* `info` von onCreated wird unverändert weitergereicht wie im bisherigen InvoiceCreateModal ({ senden }, bei festgeschriebener,
|
||||
* nicht versendeter Rechnung { 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.
|
||||
* 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, projektId, auftraegeAktiv, onShowOverview }) {
|
||||
return null
|
||||
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
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* 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, .pk .pk-table td .cell-sub { overflow-wrap: anywhere; }
|
||||
.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: anywhere; }
|
||||
|
||||
/* 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: anywhere; }
|
||||
.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: anywhere; }
|
||||
.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: anywhere; }
|
||||
.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: anywhere; }
|
||||
.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,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getAbgerechnet } from '../lib/teamApi'
|
||||
|
||||
const NICHTS = Object.freeze({})
|
||||
|
||||
/**
|
||||
* Arbeitsblatt-Kennung -> Rechnungsnummer oder „Entwurf“ für die Tickets in `ticketIds` (Team-Route, alle Mitarbeiter,
|
||||
* ohne Beträge). Ein Fehler verbirgt nur die Abzeichen, das Arbeitsblatt bleibt bedienbar; der Hook wirft nie.
|
||||
*/
|
||||
export function useAbgerechnet(ticketIds) {
|
||||
const key = (ticketIds || []).join(',')
|
||||
const [state, setState] = useState({ key: '', map: NICHTS })
|
||||
useEffect(() => {
|
||||
if (!key) return undefined
|
||||
let cancelled = false
|
||||
Promise.all(key.split(',').map((id) => getAbgerechnet(id).then((r) => r?.abgerechnet || NICHTS).catch(() => NICHTS)))
|
||||
.then((maps) => { if (!cancelled) setState({ key, map: Object.assign({}, ...maps) }) })
|
||||
return () => { cancelled = true }
|
||||
}, [key])
|
||||
return state.key === key ? state.map : NICHTS
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Hooks des Rechnungsdialogs: Projektliste, Projektkonto, Vorschau, Tickets des Kunden und Arbeitsblätter weiterer Tickets.
|
||||
// Überall zählt nur die jüngste Antwort; nach dem Ausbau eines Hooks zählt keine mehr.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { getProjektkonten, getProjektkonto, previewInvoice } from '../lib/financeApi'
|
||||
import { getAbgerechnet } from '../lib/teamApi'
|
||||
import { listKundenTickets, listTicketBlaetter } from '../lib/rechnungDialogApi'
|
||||
import { blattAusAppwrite } from '../lib/rechnungDialogView'
|
||||
import { createRequestGuard } from '../lib/requestGuard'
|
||||
|
||||
const VORSCHAU_WARTEZEIT_MS = 350
|
||||
|
||||
/**
|
||||
* Lädt eine Ressource zu einem Schlüssel (Kunde oder Projekt). Ohne Schlüssel wird nichts geladen. Die Daten gehören zu
|
||||
* ihrem Schlüssel: nach einem Wechsel ist sofort `data: null` und `loading: true`. `reload()` behält die Daten stehen und
|
||||
* liefert ein Promise, das nie wirft.
|
||||
*/
|
||||
function useGeladen(laden, schluessel, fallbackError) {
|
||||
const [state, setState] = useState({ key: null, data: null, loading: Boolean(schluessel), error: '' })
|
||||
const guardRef = useRef(null)
|
||||
if (!guardRef.current) guardRef.current = createRequestGuard()
|
||||
const ladenRef = useRef(laden)
|
||||
ladenRef.current = laden
|
||||
const lebtRef = useRef(true)
|
||||
|
||||
const lade = useCallback(
|
||||
async (key) => {
|
||||
const guard = guardRef.current
|
||||
const id = guard.next()
|
||||
setState((s) => (s.key === key ? { ...s, loading: true, error: '' } : { key, data: null, loading: true, error: '' }))
|
||||
const gilt = () => lebtRef.current && guard.isCurrent(id)
|
||||
try {
|
||||
const data = await ladenRef.current()
|
||||
if (gilt()) setState({ key, data: data && typeof data === 'object' ? data : {}, loading: false, error: '' })
|
||||
} catch (err) {
|
||||
if (gilt()) setState((s) => ({ ...s, key, loading: false, error: err?.message || fallbackError }))
|
||||
}
|
||||
},
|
||||
[fallbackError]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
lebtRef.current = true
|
||||
if (!schluessel) {
|
||||
guardRef.current.next()
|
||||
setState({ key: null, data: null, loading: false, error: '' })
|
||||
} else {
|
||||
lade(schluessel)
|
||||
}
|
||||
return () => {
|
||||
lebtRef.current = false
|
||||
}
|
||||
}, [schluessel, lade])
|
||||
|
||||
const reload = useCallback(() => (schluessel ? lade(schluessel) : Promise.resolve()), [schluessel, lade])
|
||||
const passt = Boolean(schluessel) && state.key === schluessel
|
||||
return {
|
||||
data: passt ? state.data : null,
|
||||
loading: Boolean(schluessel) && (!passt || state.loading),
|
||||
error: passt ? state.error : '',
|
||||
reload,
|
||||
}
|
||||
}
|
||||
|
||||
/** Projektkonten eines Kunden: { auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk }. */
|
||||
export function useProjektkonten(kundeId) {
|
||||
return useGeladen(() => getProjektkonten(kundeId), kundeId, 'Die Projekte konnten nicht geladen werden.')
|
||||
}
|
||||
|
||||
/** Detail eines Projektkontos: { konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk }. */
|
||||
export function useProjektkontoDetail(projektId) {
|
||||
return useGeladen(() => getProjektkonto(projektId), projektId, 'Das Projektkonto konnte nicht geladen werden.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschau der Rechnung zum Körper der Anfrage. `vorschau = { key, status: 'laedt' | 'ok' | 'fehler', daten?, message?, httpStatus? }`.
|
||||
* Der Körper wird über einen Ref gelesen, der Effekt hängt nur an [aktiv, schluessel]; nach 350 ms Ruhe geht die Anfrage raus.
|
||||
*/
|
||||
export function useRechnungVorschau({ anfrage, schluessel, aktiv }) {
|
||||
const [vorschau, setVorschau] = useState(null)
|
||||
const bodyRef = useRef(null)
|
||||
bodyRef.current = anfrage?.ok ? anfrage.body : null
|
||||
const datenRef = useRef(null)
|
||||
const kontextRef = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!aktiv || !bodyRef.current || !schluessel) {
|
||||
datenRef.current = null
|
||||
kontextRef.current = ''
|
||||
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
|
||||
setVorschau({ key: schluessel, status: 'laedt', daten: datenRef.current })
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const daten = await previewInvoice(bodyRef.current)
|
||||
if (!aktuell) return
|
||||
datenRef.current = daten
|
||||
setVorschau({ key: schluessel, status: 'ok', daten })
|
||||
} catch (err) {
|
||||
if (!aktuell) return
|
||||
setVorschau({
|
||||
key: schluessel,
|
||||
status: 'fehler',
|
||||
message: err?.message || 'Die Vorschau konnte nicht geladen werden.',
|
||||
httpStatus: err?.status,
|
||||
daten: datenRef.current,
|
||||
})
|
||||
}
|
||||
}, VORSCHAU_WARTEZEIT_MS)
|
||||
return () => {
|
||||
aktuell = false
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [aktiv, schluessel])
|
||||
|
||||
return vorschau
|
||||
}
|
||||
|
||||
/** Tickets des Kunden (einmal je Kunde, sobald `aktiv` wahr ist). */
|
||||
export function useKundenTickets(kundeId, aktiv) {
|
||||
const [state, setState] = useState({ key: null, tickets: [], loading: false, error: '' })
|
||||
useEffect(() => {
|
||||
if (!aktiv || !kundeId) return undefined
|
||||
let aktuell = true
|
||||
setState({ key: kundeId, tickets: [], loading: true, error: '' })
|
||||
listKundenTickets(kundeId).then(
|
||||
(tickets) => aktuell && setState({ key: kundeId, tickets, loading: false, error: '' }),
|
||||
() => aktuell && setState({ key: kundeId, tickets: [], loading: false, error: 'Die Tickets konnten nicht geladen werden.' })
|
||||
)
|
||||
return () => {
|
||||
aktuell = false
|
||||
}
|
||||
}, [kundeId, aktiv])
|
||||
const passt = state.key === kundeId
|
||||
return { tickets: passt ? state.tickets : [], loading: Boolean(aktiv && kundeId) && (!passt || state.loading), error: passt ? state.error : '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Arbeitsblätter weiterer Tickets: je Ticket einmal Blätter und Abgerechnet-Liste laden.
|
||||
* Ergebnis: { [ticketId]: { status: 'laedt' | 'ok' | 'fehler', blaetter, message } }; entfernte Tickets verschwinden.
|
||||
*/
|
||||
export function useWeitereBlaetter(weitereTickets) {
|
||||
const [ergebnis, setErgebnis] = useState({})
|
||||
const angefragtRef = useRef(new Set())
|
||||
const lebtRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
lebtRef.current = true
|
||||
return () => {
|
||||
lebtRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const liste = Array.isArray(weitereTickets) ? weitereTickets : []
|
||||
const ids = new Set(liste.map((t) => t.ticketId))
|
||||
for (const id of [...angefragtRef.current]) if (!ids.has(id)) angefragtRef.current.delete(id)
|
||||
for (const ticket of liste) {
|
||||
if (angefragtRef.current.has(ticket.ticketId)) continue
|
||||
angefragtRef.current.add(ticket.ticketId)
|
||||
const gilt = () => lebtRef.current && angefragtRef.current.has(ticket.ticketId)
|
||||
setErgebnis((r) => ({ ...r, [ticket.ticketId]: { status: 'laedt', blaetter: [], message: '' } }))
|
||||
Promise.all([listTicketBlaetter(ticket.woid), getAbgerechnet(ticket.ticketId)]).then(
|
||||
([dokumente, antwort]) => {
|
||||
if (!gilt()) return
|
||||
const blaetter = dokumente
|
||||
.map((ws) => blattAusAppwrite(ws, antwort?.abgerechnet))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => String(a.datum).localeCompare(String(b.datum)) || String(a.id).localeCompare(String(b.id)))
|
||||
setErgebnis((r) => ({ ...r, [ticket.ticketId]: { status: 'ok', blaetter, message: '' } }))
|
||||
},
|
||||
() => {
|
||||
if (!gilt()) return
|
||||
setErgebnis((r) => ({
|
||||
...r,
|
||||
[ticket.ticketId]: { status: 'fehler', blaetter: [], message: `Die Arbeitsblätter von #${ticket.woid} konnten nicht geladen werden.` },
|
||||
}))
|
||||
}
|
||||
)
|
||||
}
|
||||
setErgebnis((r) => {
|
||||
const keys = Object.keys(r).filter((k) => !ids.has(k))
|
||||
if (keys.length === 0) return r
|
||||
const rest = { ...r }
|
||||
for (const k of keys) delete rest[k]
|
||||
return rest
|
||||
})
|
||||
}, [weitereTickets])
|
||||
|
||||
return ergebnis
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Anzeigelogik des Projektkontos (rein, ohne React und Appwrite; unter Node testbar).
|
||||
// Aufgaben: Zeilen der Projektliste im Kundenreiter „Aufträge und Rechnungen“, die Spalten „Projekt“ und „Art“ der
|
||||
// Rechnungstabelle und das Abzeichen „abgerechnet“ der Arbeitsblätter. Alle Beträge sind Cent; Ausgaben sind Zeichenketten.
|
||||
import { euro, euroWhole } from './financeFormat'
|
||||
|
||||
const PLATZHALTER = '–'
|
||||
|
||||
export const STRIPE_HINWEIS = 'Stripe ist gerade nicht erreichbar. Berechnete und bezahlte Beträge sind deshalb nicht verfügbar.'
|
||||
export const KEINE_PROJEKTE_HINWEIS = 'Dieser Kunde hat noch keine Projekte. Rechnungen entstehen ohne Projektbezug.'
|
||||
|
||||
const ART_LABEL = {
|
||||
frei: 'Ohne Projekt',
|
||||
festbetrag: 'Festbetrag',
|
||||
aufwand: 'Aufwand',
|
||||
anzahlung: 'Anzahlung',
|
||||
schluss: 'Schlussrechnung',
|
||||
abo: 'Abo',
|
||||
}
|
||||
|
||||
function istZahl(v) {
|
||||
return typeof v === 'number' && Number.isFinite(v)
|
||||
}
|
||||
|
||||
function eigen(obj, key) {
|
||||
return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key)
|
||||
}
|
||||
|
||||
/** 750 -> '12,5 Std.' (Dezimalstunden, höchstens zwei Nachkommastellen, ohne überflüssige Nullen). */
|
||||
export function stundenDe(minuten) {
|
||||
const min = istZahl(minuten) ? minuten : 0
|
||||
const h = Math.round((min / 60) * 100) / 100
|
||||
return `${String(h).replace('.', ',')} Std.`
|
||||
}
|
||||
|
||||
/** 10000 -> '100 €/Std.', 8550 -> '85,50 €/Std.', keine Zahl -> '–'. */
|
||||
export function satzText(cents) {
|
||||
if (!istZahl(cents)) return PLATZHALTER
|
||||
const ganz = Math.round(cents) % 100 === 0
|
||||
return `${ganz ? euroWhole(cents) : euro(cents)}/Std.`
|
||||
}
|
||||
|
||||
/** Ticketnummern mit vorangestellter Raute, durch Komma getrennt; Tickets ohne Nummer fallen weg. */
|
||||
export function ticketsText(tickets) {
|
||||
if (!Array.isArray(tickets)) return ''
|
||||
return tickets
|
||||
.map((t) => String(t?.woid ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.map((w) => `#${w}`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
/** Hinweis nur, wenn Stripe ausdrücklich nicht erreichbar war. */
|
||||
export function stripeHinweis(stripeOk) {
|
||||
return stripeOk === false ? STRIPE_HINWEIS : ''
|
||||
}
|
||||
|
||||
/** Text der Spalte „Vereinbart“: Festpreis, Aufwand mit Satz und Schätzung, beides oder „keine AB“. */
|
||||
export function vereinbartText(vereinbart) {
|
||||
const teile = []
|
||||
if (istZahl(vereinbart?.festpreisCents)) teile.push(euro(vereinbart.festpreisCents))
|
||||
const aufwand = vereinbart?.aufwand
|
||||
if (aufwand) {
|
||||
let t = `nach Aufwand, ${satzText(aufwand.stundensatzCents)}`
|
||||
if (istZahl(aufwand.schaetzungMinuten) && aufwand.schaetzungMinuten > 0) t += `, Schätzung ${stundenDe(aufwand.schaetzungMinuten)}`
|
||||
teile.push(t)
|
||||
}
|
||||
return teile.length ? teile.join(' und ') : 'keine AB'
|
||||
}
|
||||
|
||||
/** Eine Zeile der Projektliste. `stripeOk: false` ersetzt Berechnet, bezahlt und (Festpreis) Noch offen durch „–“. */
|
||||
export function kontoZeile(konto, { stripeOk = true } = {}) {
|
||||
const stripe = stripeOk !== false
|
||||
const k = konto || {}
|
||||
const zeit = k.zeit || {}
|
||||
const vereinbart = k.vereinbart || {}
|
||||
const name = String(k.name ?? '').trim() || 'Projekt'
|
||||
const meta = [String(k.projektTyp ?? '').trim(), ticketsText(k.tickets)].filter(Boolean).join(' · ')
|
||||
let offen = PLATZHALTER
|
||||
if (istZahl(vereinbart.festpreisCents)) offen = stripe ? euro(k.offenCents) : PLATZHALTER
|
||||
else if (vereinbart.aufwand) offen = euro(zeit.offenerWertCents)
|
||||
return {
|
||||
key: k.projektId,
|
||||
projektId: k.projektId,
|
||||
name,
|
||||
meta,
|
||||
vereinbart: vereinbartText(vereinbart),
|
||||
berechnet: stripe ? euro(k.berechnetCents) : PLATZHALTER,
|
||||
bezahlt: stripe ? `bezahlt ${euro(k.bezahltCents)}` : '',
|
||||
offen,
|
||||
zeit: {
|
||||
stunden: stundenDe(zeit.minuten),
|
||||
wert: `Wert ${euro(zeit.wertCents)}`,
|
||||
abo: istZahl(zeit.aboMinuten) && zeit.aboMinuten > 0 ? `über Abo: ${stundenDe(zeit.aboMinuten)}` : '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Die feste Zeile „Ohne Projekt“ am Ende der Liste. */
|
||||
export function ohneProjektZeile(ohne, { stripeOk = true } = {}) {
|
||||
const stripe = stripeOk !== false
|
||||
const anzahl = istZahl(ohne?.anzahl) ? ohne.anzahl : 0
|
||||
const meta = anzahl <= 0 ? 'keine Rechnungen' : anzahl === 1 ? '1 Rechnung' : `${anzahl} Rechnungen`
|
||||
return {
|
||||
key: 'ohne-projekt',
|
||||
projektId: '',
|
||||
name: 'Ohne Projekt',
|
||||
meta,
|
||||
vereinbart: PLATZHALTER,
|
||||
berechnet: stripe ? euro(ohne?.berechnetCents ?? 0) : PLATZHALTER,
|
||||
bezahlt: stripe ? `bezahlt ${euro(ohne?.bezahltCents ?? 0)}` : '',
|
||||
offen: PLATZHALTER,
|
||||
zeit: { stunden: PLATZHALTER, wert: '', abo: '' },
|
||||
}
|
||||
}
|
||||
|
||||
/** Spalte „Art“ der Rechnungstabelle. */
|
||||
export function artLabel(art) {
|
||||
return typeof art === 'string' && eigen(ART_LABEL, art) ? ART_LABEL[art] : PLATZHALTER
|
||||
}
|
||||
|
||||
/** Spalte „Projekt“ der Rechnungstabelle. */
|
||||
export function projektSpalte(invoice) {
|
||||
return String(invoice?.projektName ?? '').trim() || PLATZHALTER
|
||||
}
|
||||
|
||||
function abgerechnetWert(abgerechnet, worksheetId) {
|
||||
if (!eigen(abgerechnet, worksheetId)) return null
|
||||
const wert = abgerechnet[worksheetId]
|
||||
return typeof wert === 'string' && wert.trim() ? wert.trim() : null
|
||||
}
|
||||
|
||||
/** Abzeichen am Arbeitsblatt: 'abgerechnet (WEBKLAR-0009)' oder 'abgerechnet (Entwurf)'; sonst null. */
|
||||
export function abgerechnetText(abgerechnet, worksheetId) {
|
||||
const wert = abgerechnetWert(abgerechnet, worksheetId)
|
||||
return wert ? `abgerechnet (${wert})` : null
|
||||
}
|
||||
|
||||
/** Warnung beim Ändern der Zeit eines abgerechneten Arbeitsblatts; sonst null. */
|
||||
export function abgerechnetWarnung(abgerechnet, worksheetId) {
|
||||
const wert = abgerechnetWert(abgerechnet, worksheetId)
|
||||
return wert ? `Dieses Arbeitsblatt ist abgerechnet (${wert}). Eine Änderung ändert die Rechnung nicht.` : null
|
||||
}
|
||||
|
||||
/** Eindeutige Ticket-Kennungen der Arbeitsblätter in Reihenfolge des ersten Auftretens, höchstens fünf. */
|
||||
export function ticketIdsOf(worksheets) {
|
||||
if (!Array.isArray(worksheets)) return []
|
||||
const ids = []
|
||||
for (const w of worksheets) {
|
||||
const id = String(w?.workorderId ?? '').trim()
|
||||
if (id && !ids.includes(id)) ids.push(id)
|
||||
if (ids.length >= 5) break
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Anfragen des Rechnungsdialogs (rein, ohne React und Appwrite; unter Node testbar).
|
||||
// Aufgaben: Körper für die Vorschau und für „Rechnung anlegen“ bauen und prüfen, Status der Vorschau, Zeilentexte der
|
||||
// Aufwandsrechnung und das Absenden mit Rückfrage, Adress-Sperre und Doppelklick-Sperre.
|
||||
// Die Eingabe `e` des Dialogs ist ein flaches Objekt (siehe rechnungAnfrage). Beträge sind Cent.
|
||||
import {
|
||||
INVOICE_LIMITS,
|
||||
ADDRESS_INCOMPLETE_TEXT,
|
||||
customerName,
|
||||
finalizedFilter,
|
||||
invoiceFailureMessage,
|
||||
invoiceSendConfirmText,
|
||||
invoiceSendGate,
|
||||
isFinalizedNotSent,
|
||||
parseQuantity,
|
||||
parseUnitPrice,
|
||||
validateInvoiceForm,
|
||||
} from './financeView'
|
||||
import { ausgewaehlteIds } from './rechnungDialogView'
|
||||
|
||||
export const LEISTUNGSZEITRAUM_MAX = 140
|
||||
export const BEZEICHNUNG_MAX = 60
|
||||
export const VORSCHAU_ARTEN = ['aufwand', 'anzahlung', 'schluss']
|
||||
|
||||
const ZEILEN_ART = /^(\d{4}-\d{2}-\d{2}|KW-\d{4}-\d{2})$/
|
||||
const TRENNER = ' – ' // Leerzeichen, Gedankenstrich (U+2013), Leerzeichen
|
||||
|
||||
const text = (v) => String(v ?? '').trim()
|
||||
|
||||
/** Zusatzzeilen prüfen; eine Zeile ohne Beschreibung und ohne Preis zählt nicht. */
|
||||
function zusatzPruefen(rows, label = 'Zusatzposition') {
|
||||
const items = []
|
||||
const liste = Array.isArray(rows) ? rows : []
|
||||
for (let i = 0; i < liste.length; i += 1) {
|
||||
const row = liste[i] || {}
|
||||
const description = text(row.description)
|
||||
if (!description && !text(row.price)) continue
|
||||
const name = `${label} ${i + 1}`
|
||||
if (!description) return { ok: false, error: `${name}: Bitte eine Beschreibung eingeben.` }
|
||||
if (description.length > INVOICE_LIMITS.maxDescription) {
|
||||
return { ok: false, error: `${name}: Die Beschreibung darf höchstens ${INVOICE_LIMITS.maxDescription} Zeichen lang sein.` }
|
||||
}
|
||||
const quantity = parseQuantity(row.quantity)
|
||||
if (quantity === null) return { ok: false, error: `${name}: Die Menge muss eine ganze Zahl von 1 bis ${INVOICE_LIMITS.maxQuantity} sein.` }
|
||||
const unit = parseUnitPrice(row.price)
|
||||
if (unit === null) return { ok: false, error: `${name}: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).` }
|
||||
items.push({ description, quantity, unitAmountCents: unit })
|
||||
}
|
||||
return { ok: true, items }
|
||||
}
|
||||
|
||||
/** '33,5' -> 33.5; nur 0 < Wert <= 100 mit höchstens zwei Nachkommastellen, sonst null. */
|
||||
function parseProzent(value) {
|
||||
const s = text(value).replace(',', '.')
|
||||
if (!/^[0-9]{1,3}(\.[0-9]{1,2})?$/.test(s)) return null
|
||||
const n = Number(s)
|
||||
return n > 0 && n <= 100 ? n : null
|
||||
}
|
||||
|
||||
/** Körper für die Vorschau; `error: ''` heißt „keine Vorschau für diese Art“. */
|
||||
export function vorschauAnfrage(e) {
|
||||
const art = e?.art
|
||||
if (!VORSCHAU_ARTEN.includes(art)) return { ok: false, error: '' }
|
||||
if (!e.kunde) return { ok: false, error: 'Bitte einen Kunden wählen.' }
|
||||
const body = { customerId: e.kunde.$id, art, projektId: e.projektId }
|
||||
|
||||
if (art === 'aufwand') {
|
||||
const ids = ausgewaehlteIds(e.blaetter, e.abgewaehlt)
|
||||
if (ids.length === 0) return { ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
|
||||
body.arbeitsblattIds = ids
|
||||
} else {
|
||||
if (!e.auftragId) return { ok: false, error: 'Bitte eine Auftragsbestätigung wählen.' }
|
||||
body.auftragId = e.auftragId
|
||||
}
|
||||
|
||||
if (art === 'anzahlung') {
|
||||
const a = e.anzahlung || {}
|
||||
const betrag = {}
|
||||
if (a.modus === 'betrag') {
|
||||
const cents = parseUnitPrice(a.wert)
|
||||
if (cents === null) return { ok: false, error: 'Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.' }
|
||||
betrag.betragCents = cents
|
||||
} else {
|
||||
const prozent = parseProzent(a.wert)
|
||||
if (prozent === null) return { ok: false, error: 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.' }
|
||||
betrag.prozent = prozent
|
||||
}
|
||||
const bezeichnung = text(a.bezeichnung)
|
||||
if (!bezeichnung || bezeichnung.length > BEZEICHNUNG_MAX) {
|
||||
return { ok: false, error: `Bitte eine Bezeichnung eingeben (höchstens ${BEZEICHNUNG_MAX} Zeichen).` }
|
||||
}
|
||||
body.anzahlung = { ...betrag, bezeichnung }
|
||||
} else {
|
||||
const z = zusatzPruefen(e.zusatz)
|
||||
if (!z.ok) return z
|
||||
if (z.items.length) body.items = z.items
|
||||
}
|
||||
|
||||
if (art === 'aufwand') {
|
||||
const zeilentexte = {}
|
||||
for (const [schluessel, t] of Object.entries(e.zeilentexte || {})) {
|
||||
if (text(t)) zeilentexte[schluessel] = text(t)
|
||||
}
|
||||
if (Object.keys(zeilentexte).length) body.zeilentexte = zeilentexte
|
||||
}
|
||||
|
||||
const zeitraum = leistungszeitraumFeld(e)
|
||||
if (zeitraum.error) return { ok: false, error: zeitraum.error }
|
||||
if (zeitraum.wert) body.leistungszeitraum = zeitraum.wert
|
||||
return { ok: true, body }
|
||||
}
|
||||
|
||||
function leistungszeitraumFeld(e) {
|
||||
const wert = e?.zeitraumGeaendert ? text(e.zeitraum) : ''
|
||||
if (wert.length > LEISTUNGSZEITRAUM_MAX) {
|
||||
return { error: `Der Leistungszeitraum darf höchstens ${LEISTUNGSZEITRAUM_MAX} Zeichen lang sein.`, wert: '' }
|
||||
}
|
||||
return { error: '', wert }
|
||||
}
|
||||
|
||||
/** Körper für „Rechnung anlegen“ (ohne `senden`); `totalCents` nur bei freien Positionen und Festbetrag. */
|
||||
export function rechnungAnfrage(e) {
|
||||
const art = e?.art
|
||||
if (art === 'frei' || art === 'festbetrag') {
|
||||
// die Sätze und Grenzen für Kunde, Positionen, Zahlungsziel und Notiz gelten unverändert aus dem bisherigen Dialog
|
||||
const form = validateInvoiceForm({ customer: e.kunde, items: e.items, days: e.days, memo: e.memo })
|
||||
if (!form.ok) return { ok: false, error: form.error }
|
||||
if (art === 'festbetrag' && !e.projektId) return { ok: false, error: 'Bitte ein Projekt wählen.' }
|
||||
const zeitraum = leistungszeitraumFeld(e)
|
||||
if (zeitraum.error) return { ok: false, error: zeitraum.error }
|
||||
const body = { ...form.payload, art }
|
||||
if (art === 'festbetrag') body.projektId = e.projektId
|
||||
if (zeitraum.wert) body.leistungszeitraum = zeitraum.wert
|
||||
return { ok: true, body, totalCents: form.totalCents }
|
||||
}
|
||||
const v = vorschauAnfrage(e)
|
||||
if (!v.ok) return v
|
||||
const form = validateInvoiceForm({
|
||||
customer: e.kunde,
|
||||
items: [{ description: 'x', quantity: '1', price: '1' }],
|
||||
days: e.days,
|
||||
memo: e.memo,
|
||||
})
|
||||
if (!form.ok) return { ok: false, error: form.error }
|
||||
const body = { ...v.body, daysUntilDue: form.payload.daysUntilDue }
|
||||
if (form.payload.memo) body.memo = form.payload.memo
|
||||
return { ok: true, body, totalCents: null }
|
||||
}
|
||||
|
||||
/** Schlüssel der Eingabe, die Zahlen der Vorschau ändert (Texte und Leistungszeitraum zählen nicht). */
|
||||
export function vorschauSchluessel(body) {
|
||||
if (!body) return ''
|
||||
const { zeilentexte, leistungszeitraum, ...rest } = body
|
||||
void zeilentexte
|
||||
void leistungszeitraum
|
||||
if (Array.isArray(rest.arbeitsblattIds)) rest.arbeitsblattIds = [...rest.arbeitsblattIds].sort()
|
||||
return JSON.stringify(rest)
|
||||
}
|
||||
|
||||
/** Zustand der Vorschau: nicht-noetig | ungueltig | laedt | fehler | bereit. */
|
||||
export function vorschauStatus({ art, anfrage, vorschau, schluessel }) {
|
||||
if (!VORSCHAU_ARTEN.includes(art)) return { state: 'nicht-noetig', message: '' }
|
||||
if (!anfrage?.ok) return { state: 'ungueltig', message: anfrage?.error || '' }
|
||||
if (!vorschau || vorschau.key !== schluessel || vorschau.status === 'laedt') return { state: 'laedt', message: '' }
|
||||
if (vorschau.status === 'fehler') return { state: 'fehler', message: vorschau.message || 'Die Vorschau konnte nicht geladen werden.' }
|
||||
if (!(Number(vorschau.daten?.summeCents) > 0)) return { state: 'fehler', message: 'Nichts mehr offen.' }
|
||||
return { state: 'bereit', message: '' }
|
||||
}
|
||||
|
||||
/** Entwurf und Senden sind nur erlaubt, wenn keine Vorschau nötig ist oder sie zur aktuellen Eingabe gehört. */
|
||||
export function darfSpeichern(status) {
|
||||
return status?.state === 'bereit' || status?.state === 'nicht-noetig'
|
||||
}
|
||||
|
||||
/** Zeile der Aufwandsrechnung am letzten ' – ' teilen: vorn bearbeitbar, hinten fest. */
|
||||
export function zeileTeilen(beschreibung) {
|
||||
const s = String(beschreibung ?? '')
|
||||
const i = s.lastIndexOf(TRENNER)
|
||||
if (i < 0) return { vorn: s, hinten: '' }
|
||||
return { vorn: s.slice(0, i), hinten: s.slice(i) }
|
||||
}
|
||||
|
||||
/** Nur Zeilen mit Tag oder Kalenderwoche als Schlüssel und festem Hinten-Teil sind bearbeitbar. */
|
||||
export function zeileBearbeitbar(position) {
|
||||
return ZEILEN_ART.test(String(position?.schluessel ?? '')) && zeileTeilen(position?.beschreibung).hinten !== ''
|
||||
}
|
||||
|
||||
/** Höchstlänge des bearbeitbaren Teils, damit die ganze Zeile bei 200 Zeichen bleibt. */
|
||||
export function maxVorn(hinten) {
|
||||
return Math.max(1, INVOICE_LIMITS.maxDescription - String(hinten ?? '').length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Absenden mit Rückfrage und Sperren. `submit({ anfrage, senden, kunde, totalCents })` liefert
|
||||
* ok | abgebrochen | laeuft | fehler | festgeschrieben. Ein zweiter Aufruf, solange der erste läuft, wird verworfen.
|
||||
*/
|
||||
export function createSubmitter({ create, confirm }) {
|
||||
let running = false
|
||||
return async function submit({ anfrage, senden, kunde, totalCents }) {
|
||||
if (running) return { status: 'laeuft' }
|
||||
if (!anfrage?.ok) return { status: 'fehler', message: anfrage?.error || '' }
|
||||
if (senden) {
|
||||
if (!invoiceSendGate(kunde, totalCents).sendAllowed) return { status: 'fehler', message: ADDRESS_INCOMPLETE_TEXT }
|
||||
const frage = invoiceSendConfirmText({ name: customerName(kunde), email: kunde?.email, totalCents })
|
||||
if (!confirm(frage)) return { status: 'abgebrochen' }
|
||||
}
|
||||
running = true
|
||||
try {
|
||||
const data = await create({ ...anfrage.body, senden: Boolean(senden) })
|
||||
return { status: 'ok', rechnung: data?.rechnung || null, senden: Boolean(senden) }
|
||||
} catch (err) {
|
||||
if (isFinalizedNotSent(err)) {
|
||||
return { status: 'festgeschrieben', notice: invoiceFailureMessage(err), finalizedFilter: finalizedFilter(err) }
|
||||
}
|
||||
const out = { status: 'fehler', message: invoiceFailureMessage(err) }
|
||||
if (Number.isInteger(err?.status)) out.httpStatus = err.status
|
||||
return out
|
||||
} finally {
|
||||
running = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from './appwrite'
|
||||
|
||||
/** Tickets eines Kunden, neueste zuerst (für „Weiteres Ticket hinzunehmen“). */
|
||||
export async function listKundenTickets(customerId) {
|
||||
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(100),
|
||||
])
|
||||
return res.documents
|
||||
}
|
||||
|
||||
/** Alle Arbeitsblätter eines Tickets (nach Ticketnummer `woid`). */
|
||||
export async function listTicketBlaetter(woid) {
|
||||
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKSHEETS, [
|
||||
Query.equal('woid', woid),
|
||||
Query.orderAsc('$createdAt'),
|
||||
Query.limit(5000),
|
||||
])
|
||||
return res.documents
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Zustand des Rechnungsdialogs „Rechnung schreiben“ (rein, ohne React; unter Node testbar).
|
||||
// `anfangszustand` liefert den leeren Zustand, `reduce` verarbeitet die Aktionen des Dialogs. Unbekannte Aktionen,
|
||||
// unbekannte Listen und Felder sowie Aktionen ohne Wirkung geben denselben Zustand (dieselbe Referenz) zurück.
|
||||
import { INVOICE_LIMITS } from './financeView'
|
||||
|
||||
const LISTEN = ['items', 'zusatz']
|
||||
const FELDER = ['description', 'quantity', 'price']
|
||||
const MINDESTENS = { items: 1, zusatz: 0 }
|
||||
|
||||
const leereZeile = (uid) => ({ uid, description: '', quantity: '1', price: '' })
|
||||
|
||||
/** Startzustand. projektId: null = noch zu wählen, '' = Ohne Projekt, Text = dieses Projekt. */
|
||||
export function anfangszustand({ projektId = null } = {}) {
|
||||
return {
|
||||
projektId,
|
||||
art: '',
|
||||
auftragId: '',
|
||||
items: [leereZeile(1)],
|
||||
zusatz: [],
|
||||
abgewaehlt: [],
|
||||
weitereTickets: [],
|
||||
anzahlung: {},
|
||||
zeilentexte: {},
|
||||
zeitraum: '',
|
||||
zeitraumGeaendert: false,
|
||||
days: String(INVOICE_LIMITS.defaultDays),
|
||||
memo: '',
|
||||
}
|
||||
}
|
||||
|
||||
function neueUid(s) {
|
||||
return 1 + Math.max(0, ...s.items.map((z) => z.uid), ...s.zusatz.map((z) => z.uid))
|
||||
}
|
||||
|
||||
export function reduce(s, a) {
|
||||
switch (a?.type) {
|
||||
case 'projekt':
|
||||
return { ...anfangszustand({ projektId: a.projektId }), days: s.days, memo: s.memo }
|
||||
case 'art':
|
||||
return {
|
||||
...s,
|
||||
art: a.art,
|
||||
auftragId: '',
|
||||
zeilentexte: {},
|
||||
zeitraum: s.zeitraumGeaendert ? s.zeitraum : '',
|
||||
}
|
||||
case 'auftrag':
|
||||
return { ...s, auftragId: a.auftragId, anzahlung: {} }
|
||||
case 'position': {
|
||||
if (!LISTEN.includes(a.liste) || !FELDER.includes(a.feld)) return s
|
||||
return { ...s, [a.liste]: s[a.liste].map((z) => (z.uid === a.uid ? { ...z, [a.feld]: a.wert } : z)) }
|
||||
}
|
||||
case 'position+': {
|
||||
if (!LISTEN.includes(a.liste) || s[a.liste].length >= INVOICE_LIMITS.maxItems) return s
|
||||
return { ...s, [a.liste]: [...s[a.liste], leereZeile(neueUid(s))] }
|
||||
}
|
||||
case 'position-': {
|
||||
if (!LISTEN.includes(a.liste) || s[a.liste].length <= MINDESTENS[a.liste]) return s
|
||||
return { ...s, [a.liste]: s[a.liste].filter((z) => z.uid !== a.uid) }
|
||||
}
|
||||
case 'blatt':
|
||||
return { ...s, abgewaehlt: s.abgewaehlt.includes(a.id) ? s.abgewaehlt.filter((id) => id !== a.id) : [...s.abgewaehlt, a.id] }
|
||||
case 'ticket+':
|
||||
return s.weitereTickets.some((t) => t.ticketId === a.ticket.ticketId) ? s : { ...s, weitereTickets: [...s.weitereTickets, a.ticket] }
|
||||
case 'ticket-': {
|
||||
const weg = Array.isArray(a.blattIds) ? a.blattIds : []
|
||||
return {
|
||||
...s,
|
||||
weitereTickets: s.weitereTickets.filter((t) => t.ticketId !== a.ticketId),
|
||||
abgewaehlt: s.abgewaehlt.filter((id) => !weg.includes(id)),
|
||||
}
|
||||
}
|
||||
case 'anzahlung':
|
||||
return { ...s, anzahlung: { ...s.anzahlung, ...a.patch } }
|
||||
case 'zeilentext': {
|
||||
const zeilentexte = { ...s.zeilentexte }
|
||||
if (!String(a.text ?? '').trim() || a.text === a.original) delete zeilentexte[a.schluessel]
|
||||
else zeilentexte[a.schluessel] = a.text
|
||||
return { ...s, zeilentexte }
|
||||
}
|
||||
case 'zeitraum':
|
||||
return { ...s, zeitraum: a.text, zeitraumGeaendert: true }
|
||||
case 'zeitraum-vorschlag':
|
||||
if (s.zeitraumGeaendert || s.zeitraum === a.text) return s
|
||||
return { ...s, zeitraum: a.text }
|
||||
case 'tage':
|
||||
return { ...s, days: a.wert }
|
||||
case 'notiz':
|
||||
return { ...s, memo: a.wert }
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Dialoglogik der Rechnung „Rechnung schreiben“ (rein, ohne React und Appwrite; unter Node testbar).
|
||||
// Aufgaben: Projektwahl, waehlbare Rechnungsarten, gültige Auftragsbestätigungen (AB), Vorgaben für Anzahlung und Standardart,
|
||||
// Arbeitsblätter (Auswahl, Summen, Umwandlung aus Appwrite-Dokumenten) und die Ticketliste für „Weiteres Ticket hinzunehmen“.
|
||||
import { euro, dateDe, ymdBerlin } from './financeFormat'
|
||||
import { worksheetMinutes } from './support'
|
||||
import { germanDateToIso } from './ticketForm'
|
||||
import { ticketsText } from './projektkontoView'
|
||||
|
||||
export const ART_OPTION_LABEL = {
|
||||
frei: 'Freie Positionen',
|
||||
anzahlung: 'Anzahlung',
|
||||
schluss: 'Schlussrechnung',
|
||||
aufwand: 'Nach Aufwand',
|
||||
festbetrag: 'Festbetrag',
|
||||
}
|
||||
export const AB_HINWEIS = 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.'
|
||||
export const ENTWURF_HINWEIS = 'Zu dieser Auftragsbestätigung gibt es noch einen Entwurf. Erst senden oder löschen.'
|
||||
export const OHNE_PROJEKT = 'Ohne Projekt'
|
||||
|
||||
const GUELTIG = ['versendet', 'bestaetigt']
|
||||
const FINALISIERT = ['offen', 'ueberfaellig', 'bezahlt', 'uneinbringlich']
|
||||
|
||||
// --- Projektwahl --------------------------------------------------------------------------------------------
|
||||
|
||||
/** Vorauswahl der Aufrufer: undefined = keine (null), null = „Ohne Projekt“ (''), Text = dieses Projekt. */
|
||||
export function startProjekt(propProjektId) {
|
||||
if (propProjektId === undefined) return null
|
||||
if (propProjektId === null) return ''
|
||||
return String(propProjektId)
|
||||
}
|
||||
|
||||
/** Auswahlliste: Projekte mit Ticketnummer, am Ende „Ohne Projekt“. */
|
||||
export function projektOptionen(konten) {
|
||||
const liste = Array.isArray(konten) ? konten : []
|
||||
const optionen = liste
|
||||
.filter((k) => k && String(k.projektId ?? '') !== '')
|
||||
.map((k) => {
|
||||
const name = String(k.name ?? '').trim() || 'Projekt'
|
||||
const tickets = ticketsText(k.tickets)
|
||||
return { value: String(k.projektId), label: tickets ? `${name} · ${tickets}` : name }
|
||||
})
|
||||
optionen.push({ value: '', label: OHNE_PROJEKT })
|
||||
return optionen
|
||||
}
|
||||
|
||||
/** null = noch zu wählen, '' = Ohne Projekt. Ein Kunde ohne Projekte hat nur „Ohne Projekt“. */
|
||||
export function wirksamesProjekt(wahl, konten) {
|
||||
if (wahl === '') return ''
|
||||
if (typeof wahl === 'string' && wahl) {
|
||||
if (!Array.isArray(konten)) return wahl
|
||||
return konten.some((k) => k?.projektId === wahl) ? wahl : null
|
||||
}
|
||||
return Array.isArray(konten) && konten.length === 0 ? '' : null
|
||||
}
|
||||
|
||||
// --- Auftragsbestätigungen ----------------------------------------------------------------------------------
|
||||
|
||||
function neuesteZuerst(liste) {
|
||||
const zeit = (a) => {
|
||||
const t = Date.parse(a?.gesendetAm)
|
||||
return Number.isFinite(t) ? t : 0
|
||||
}
|
||||
return [...liste].sort((a, b) => zeit(b) - zeit(a) || String(b?.nummer ?? '').localeCompare(String(a?.nummer ?? '')))
|
||||
}
|
||||
|
||||
function gueltige(auftraege, art) {
|
||||
if (!Array.isArray(auftraege)) return []
|
||||
return neuesteZuerst(auftraege.filter((a) => a && a.art === art && GUELTIG.includes(a.status)))
|
||||
}
|
||||
|
||||
/** Gültige Festpreis-ABs (versendet oder bestätigt), neueste zuerst. */
|
||||
export function gueltigeFestpreisAbs(auftraege) {
|
||||
return gueltige(auftraege, 'festpreis')
|
||||
}
|
||||
|
||||
/** Jüngste gültige Aufwands-AB oder null. */
|
||||
export function aufwandAuftrag(auftraege) {
|
||||
return gueltige(auftraege, 'aufwand')[0] ?? null
|
||||
}
|
||||
|
||||
/** 'AB-2026-0002 vom 06.10.2026 · 800,00 €' bzw. '… · nach Aufwand'. */
|
||||
export function abLabel(auftrag) {
|
||||
let text = String(auftrag?.nummer ?? '')
|
||||
const datum = dateDe(auftrag?.gesendetAm)
|
||||
if (datum !== '–') text += ` vom ${datum}`
|
||||
text += auftrag?.art === 'aufwand' ? ' · nach Aufwand' : ` · ${euro(auftrag?.summeCents)}`
|
||||
return text
|
||||
}
|
||||
|
||||
/** Summe der finalisierten Rechnungen zu dieser AB (Cent). */
|
||||
export function berechnetFuerAuftrag(rechnungen, auftragId) {
|
||||
if (!Array.isArray(rechnungen)) return 0
|
||||
return rechnungen
|
||||
.filter((r) => r && r.auftragId === auftragId && FINALISIERT.includes(r.status))
|
||||
.reduce((summe, r) => summe + (Number(r.totalCents) || 0), 0)
|
||||
}
|
||||
|
||||
/** Gibt es zu dieser AB einen noch nicht gesendeten Entwurf? */
|
||||
export function hatEntwurfZurAuftrag(rechnungen, auftragId) {
|
||||
return Array.isArray(rechnungen) && rechnungen.some((r) => r && r.auftragId === auftragId && r.status === 'entwurf')
|
||||
}
|
||||
|
||||
// --- Rechnungsarten -----------------------------------------------------------------------------------------
|
||||
|
||||
/** Wählbare Arten mit Sperrgrund: [{ key, label, enabled, hint }]. */
|
||||
export function artOptionen({ projektId, auftraege, rechnungen, auftraegeAktiv }) {
|
||||
if (projektId === null || projektId === undefined) return []
|
||||
const option = (key, enabled, hint = '') => ({ key, label: ART_OPTION_LABEL[key], enabled, hint: enabled ? '' : hint })
|
||||
if (projektId === '') return [option('frei', true)]
|
||||
const abs = auftraegeAktiv ? gueltigeFestpreisAbs(auftraege) : []
|
||||
const freie = abs.filter((a) => !hatEntwurfZurAuftrag(rechnungen, a.id))
|
||||
const schlussHinweis = abs.length === 0 ? AB_HINWEIS : ENTWURF_HINWEIS
|
||||
return [
|
||||
option('anzahlung', abs.length > 0, AB_HINWEIS),
|
||||
option('schluss', freie.length > 0, schlussHinweis),
|
||||
option('aufwand', true),
|
||||
option('festbetrag', true),
|
||||
]
|
||||
}
|
||||
|
||||
/** Vorbelegte Art: Anzahlung, Schlussrechnung, Aufwand oder Festbetrag je nach Lage ('' ohne Wahl). */
|
||||
export function standardArt(optionen, { auftraege, rechnungen, offeneMinuten }) {
|
||||
const liste = Array.isArray(optionen) ? optionen : []
|
||||
if (liste.length === 0) return ''
|
||||
if (liste.length === 1 && liste[0].key === 'frei') return 'frei'
|
||||
const waehlbar = (key) => liste.some((o) => o.key === key && o.enabled)
|
||||
const jung = gueltigeFestpreisAbs(auftraege)[0]
|
||||
if (
|
||||
waehlbar('anzahlung') &&
|
||||
jung?.zahlungsplan?.art === 'anzahlung' &&
|
||||
berechnetFuerAuftrag(rechnungen, jung.id) === 0 &&
|
||||
!hatEntwurfZurAuftrag(rechnungen, jung.id)
|
||||
) {
|
||||
return 'anzahlung'
|
||||
}
|
||||
if (waehlbar('schluss')) return 'schluss'
|
||||
return Number(offeneMinuten) > 0 ? 'aufwand' : 'festbetrag'
|
||||
}
|
||||
|
||||
/** Vorbelegte AB: jüngste passende; bei Schlussrechnung ohne offenen Entwurf; sonst ''. */
|
||||
export function standardAuftrag(art, auftraege, rechnungen = []) {
|
||||
const abs = gueltigeFestpreisAbs(auftraege)
|
||||
if (art === 'anzahlung') return abs[0]?.id ?? ''
|
||||
if (art === 'schluss') return abs.find((a) => !hatEntwurfZurAuftrag(rechnungen, a.id))?.id ?? ''
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Vorgabe der Anzahlung: Prozent aus dem Zahlungsplan, sonst 50. */
|
||||
export function anzahlungStandard(auftrag) {
|
||||
const plan = auftrag?.zahlungsplan
|
||||
const prozent = plan?.art === 'anzahlung' && Number(plan.prozent) > 0 ? Number(plan.prozent) : 50
|
||||
return { modus: 'prozent', wert: String(prozent).replace('.', ','), bezeichnung: 'Anzahlung' }
|
||||
}
|
||||
|
||||
// --- Arbeitsblätter -----------------------------------------------------------------------------------------
|
||||
|
||||
/** Kennungen der gewählten Blätter (alle außer den abgewählten) in Reihenfolge der Liste. */
|
||||
export function ausgewaehlteIds(blaetter, abgewaehlt) {
|
||||
if (!Array.isArray(blaetter)) return []
|
||||
const weg = Array.isArray(abgewaehlt) ? abgewaehlt : []
|
||||
return blaetter.map((b) => b.id).filter((id) => !weg.includes(id))
|
||||
}
|
||||
|
||||
/** Anzahl und Minutensumme der Blätter mit diesen Kennungen. */
|
||||
export function blaetterSumme(blaetter, ids) {
|
||||
const liste = (Array.isArray(blaetter) ? blaetter : []).filter((b) => Array.isArray(ids) && ids.includes(b.id))
|
||||
return { anzahl: liste.length, minuten: liste.reduce((s, b) => s + (Number(b.minuten) || 0), 0) }
|
||||
}
|
||||
|
||||
/** Appwrite-Arbeitsblatt -> Blatt der Portal-Form; null, wenn es nicht abrechenbar ist oder schon abgerechnet wurde. */
|
||||
export function blattAusAppwrite(ws, abgerechnet) {
|
||||
if (!ws || !ws.$id) return null
|
||||
if (ws.isComment || ws.countsToPlan) return null
|
||||
if (abgerechnet && Object.prototype.hasOwnProperty.call(abgerechnet, ws.$id)) return null
|
||||
const roh = worksheetMinutes(ws)
|
||||
if (!(roh > 0)) return null
|
||||
const zeile = String(ws.details ?? '')
|
||||
.split('\n')
|
||||
.map((z) => z.trim())
|
||||
.find(Boolean)
|
||||
return {
|
||||
id: ws.$id,
|
||||
ticketId: ws.workorderId,
|
||||
woid: ws.woid,
|
||||
datum: germanDateToIso(ws.startDate || ws.endDate) || ymdBerlin(ws.$createdAt) || '',
|
||||
minuten: Math.ceil(roh / 15) * 15,
|
||||
text: (zeile ?? '').slice(0, 120),
|
||||
aboZeit: false,
|
||||
kommentar: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Tickets des Kunden mit Nummer, ohne die schon gewählten. */
|
||||
export function ticketOptionen(tickets, { ausgeschlossen = [] } = {}) {
|
||||
if (!Array.isArray(tickets)) return []
|
||||
return tickets
|
||||
.filter((t) => t && t.$id && t.woid && !ausgeschlossen.includes(t.$id))
|
||||
.map((t) => ({
|
||||
value: t.$id,
|
||||
ticketId: t.$id,
|
||||
woid: t.woid,
|
||||
label: `#${t.woid} · ${String(t.topic || t.title || '').trim() || 'Kein Betreff'}`,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const {
|
||||
STRIPE_HINWEIS, KEINE_PROJEKTE_HINWEIS,
|
||||
stundenDe, satzText, ticketsText, stripeHinweis, vereinbartText, kontoZeile, ohneProjektZeile,
|
||||
artLabel, projektSpalte, abgerechnetText, abgerechnetWarnung, ticketIdsOf,
|
||||
} = await loadModule('src/lib/projektkontoView.js')
|
||||
|
||||
const konto = (over = {}) => ({
|
||||
projektId: 'p1',
|
||||
name: 'Website Relaunch',
|
||||
projektTyp: 'Website',
|
||||
tickets: [{ id: 't1', woid: '100131' }],
|
||||
zeitquelle: ['t1'],
|
||||
vereinbart: { festpreisCents: 120000, aufwand: null },
|
||||
berechnetCents: 50000,
|
||||
bezahltCents: 20000,
|
||||
offenCents: 70000,
|
||||
zeit: { minuten: 750, aboMinuten: 0, offeneMinuten: 750, satzCents: 10000, wertCents: 125000, offenerWertCents: 125000 },
|
||||
...over,
|
||||
})
|
||||
const AUFWAND = { festpreisCents: null, aufwand: { stundensatzCents: 10000, schaetzungMinuten: 600 } }
|
||||
|
||||
test('stundenDe: Dezimalstunden mit Komma, ohne überflüssige Nullen', () => {
|
||||
assert.equal(stundenDe(750), '12,5 Std.')
|
||||
assert.equal(stundenDe(135), '2,25 Std.')
|
||||
assert.equal(stundenDe(60), '1 Std.')
|
||||
assert.equal(stundenDe(20), '0,33 Std.')
|
||||
assert.equal(stundenDe(0), '0 Std.')
|
||||
assert.equal(stundenDe(null), '0 Std.')
|
||||
assert.equal(stundenDe('abc'), '0 Std.')
|
||||
})
|
||||
|
||||
test('satzText: ganze Euro ohne Cent, sonst mit Cent', () => {
|
||||
assert.equal(satzText(10000), '100 €/Std.')
|
||||
assert.equal(satzText(8550), '85,50 €/Std.')
|
||||
assert.equal(satzText(null), '–')
|
||||
})
|
||||
|
||||
test('ticketsText: Ticketnummern mit Raute, ohne Nummer fällt der Eintrag weg', () => {
|
||||
assert.equal(ticketsText([{ id: 'a', woid: '100131' }]), '#100131')
|
||||
assert.equal(ticketsText([{ id: 'a', woid: '100131' }, { id: 'b', woid: '100140' }]), '#100131, #100140')
|
||||
assert.equal(ticketsText([{ id: 'a' }, null]), '')
|
||||
assert.equal(ticketsText(undefined), '')
|
||||
})
|
||||
|
||||
test('vereinbartText: Festpreis, Aufwand mit Schätzung, beides, keine AB', () => {
|
||||
assert.equal(vereinbartText({ festpreisCents: 120000, aufwand: null }), '1.200,00 €')
|
||||
assert.equal(vereinbartText(AUFWAND), 'nach Aufwand, 100 €/Std., Schätzung 10 Std.')
|
||||
assert.equal(vereinbartText({ festpreisCents: null, aufwand: { stundensatzCents: 8550, schaetzungMinuten: null } }), 'nach Aufwand, 85,50 €/Std.')
|
||||
assert.equal(vereinbartText({ festpreisCents: 120000, aufwand: { stundensatzCents: 10000, schaetzungMinuten: null } }), '1.200,00 € und nach Aufwand, 100 €/Std.')
|
||||
assert.equal(vereinbartText({ festpreisCents: null, aufwand: null }), 'keine AB')
|
||||
assert.equal(vereinbartText(undefined), 'keine AB')
|
||||
})
|
||||
|
||||
test('kontoZeile: Festpreis-AB mit Berechnet, bezahlt, Noch offen und Zeit', () => {
|
||||
assert.deepEqual(kontoZeile(konto()), {
|
||||
key: 'p1',
|
||||
projektId: 'p1',
|
||||
name: 'Website Relaunch',
|
||||
meta: 'Website · #100131',
|
||||
vereinbart: '1.200,00 €',
|
||||
berechnet: '500,00 €',
|
||||
bezahlt: 'bezahlt 200,00 €',
|
||||
offen: '700,00 €',
|
||||
zeit: { stunden: '12,5 Std.', wert: 'Wert 1.250,00 €', abo: '' },
|
||||
})
|
||||
// ohne Art und ohne Ticket bleibt nur der Name
|
||||
assert.equal(kontoZeile(konto({ projektTyp: '', tickets: [] })).meta, '')
|
||||
})
|
||||
|
||||
test('kontoZeile: Aufwand-AB (Noch offen = Wert der offenen Zeit) und Abo-Zeit getrennt', () => {
|
||||
const z = kontoZeile(konto({
|
||||
vereinbart: AUFWAND,
|
||||
offenCents: null,
|
||||
zeit: { minuten: 300, aboMinuten: 90, offeneMinuten: 180, satzCents: 10000, wertCents: 50000, offenerWertCents: 30000 },
|
||||
}))
|
||||
assert.equal(z.vereinbart, 'nach Aufwand, 100 €/Std., Schätzung 10 Std.')
|
||||
assert.equal(z.offen, '300,00 €')
|
||||
assert.deepEqual(z.zeit, { stunden: '5 Std.', wert: 'Wert 500,00 €', abo: 'über Abo: 1,5 Std.' })
|
||||
})
|
||||
|
||||
test('kontoZeile: ohne AB steht „keine AB“ und Noch offen ist „–“', () => {
|
||||
const z = kontoZeile(konto({ vereinbart: { festpreisCents: null, aufwand: null }, offenCents: null }))
|
||||
assert.equal(z.vereinbart, 'keine AB')
|
||||
assert.equal(z.offen, '–')
|
||||
})
|
||||
|
||||
test('kontoZeile: Stripe nicht erreichbar zeigt „–“ statt Nullen, Zeit und Vereinbartes bleiben', () => {
|
||||
const z = kontoZeile(konto({ berechnetCents: 0, bezahltCents: 0, offenCents: null }), { stripeOk: false })
|
||||
assert.deepEqual([z.vereinbart, z.berechnet, z.bezahlt, z.offen], ['1.200,00 €', '–', '', '–'])
|
||||
assert.equal(z.zeit.wert, 'Wert 1.250,00 €')
|
||||
// Aufwand: der Wert der offenen Zeit braucht kein Stripe
|
||||
const a = kontoZeile(konto({ vereinbart: AUFWAND, offenCents: null }), { stripeOk: false })
|
||||
assert.equal(a.offen, '1.250,00 €')
|
||||
assert.equal(stripeHinweis(false), STRIPE_HINWEIS)
|
||||
assert.equal(stripeHinweis(true), '')
|
||||
assert.equal(stripeHinweis(undefined), '')
|
||||
assert.equal(STRIPE_HINWEIS, 'Stripe ist gerade nicht erreichbar. Berechnete und bezahlte Beträge sind deshalb nicht verfügbar.')
|
||||
})
|
||||
|
||||
test('ohneProjektZeile: Rechnungen ohne Projekt, ohne Vereinbart und Zeit', () => {
|
||||
assert.deepEqual(ohneProjektZeile({ berechnetCents: 30000, bezahltCents: 10000, anzahl: 3 }), {
|
||||
key: 'ohne-projekt',
|
||||
projektId: '',
|
||||
name: 'Ohne Projekt',
|
||||
meta: '3 Rechnungen',
|
||||
vereinbart: '–',
|
||||
berechnet: '300,00 €',
|
||||
bezahlt: 'bezahlt 100,00 €',
|
||||
offen: '–',
|
||||
zeit: { stunden: '–', wert: '', abo: '' },
|
||||
})
|
||||
assert.equal(ohneProjektZeile({ berechnetCents: 0, bezahltCents: 0, anzahl: 1 }).meta, '1 Rechnung')
|
||||
assert.equal(ohneProjektZeile({ berechnetCents: 0, bezahltCents: 0, anzahl: 0 }).meta, 'keine Rechnungen')
|
||||
assert.equal(ohneProjektZeile(undefined).meta, 'keine Rechnungen')
|
||||
const s = ohneProjektZeile({ berechnetCents: 30000, bezahltCents: 10000, anzahl: 3 }, { stripeOk: false })
|
||||
assert.deepEqual([s.berechnet, s.bezahlt], ['–', ''])
|
||||
})
|
||||
|
||||
test('Hinweis für Kunden ohne Projekte', () => {
|
||||
assert.equal(KEINE_PROJEKTE_HINWEIS, 'Dieser Kunde hat noch keine Projekte. Rechnungen entstehen ohne Projektbezug.')
|
||||
})
|
||||
|
||||
test('artLabel: Spalte Art der Rechnungstabelle', () => {
|
||||
assert.deepEqual(
|
||||
['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss', 'abo'].map((a) => artLabel(a)),
|
||||
['Ohne Projekt', 'Festbetrag', 'Aufwand', 'Anzahlung', 'Schlussrechnung', 'Abo']
|
||||
)
|
||||
assert.equal(artLabel(undefined), '–')
|
||||
assert.equal(artLabel('unbekannt'), '–')
|
||||
assert.equal(artLabel('constructor'), '–')
|
||||
})
|
||||
|
||||
test('projektSpalte: Projektname der Rechnung oder „–“', () => {
|
||||
assert.equal(projektSpalte({ projektName: ' Website Relaunch ' }), 'Website Relaunch')
|
||||
assert.equal(projektSpalte({ projektName: '' }), '–')
|
||||
assert.equal(projektSpalte({}), '–')
|
||||
assert.equal(projektSpalte(null), '–')
|
||||
})
|
||||
|
||||
test('abgerechnetText und abgerechnetWarnung: Rechnungsnummer oder Entwurf', () => {
|
||||
const map = { w1: 'WEBKLAR-0009', w2: 'Entwurf' }
|
||||
assert.equal(abgerechnetText(map, 'w1'), 'abgerechnet (WEBKLAR-0009)')
|
||||
assert.equal(abgerechnetText(map, 'w2'), 'abgerechnet (Entwurf)')
|
||||
assert.equal(abgerechnetText(map, 'w3'), null)
|
||||
assert.equal(abgerechnetText(map, 'constructor'), null)
|
||||
assert.equal(abgerechnetText(null, 'w1'), null)
|
||||
assert.equal(abgerechnetWarnung(map, 'w1'), 'Dieses Arbeitsblatt ist abgerechnet (WEBKLAR-0009). Eine Änderung ändert die Rechnung nicht.')
|
||||
assert.equal(abgerechnetWarnung(map, 'w2'), 'Dieses Arbeitsblatt ist abgerechnet (Entwurf). Eine Änderung ändert die Rechnung nicht.')
|
||||
assert.equal(abgerechnetWarnung(map, 'w3'), null)
|
||||
})
|
||||
|
||||
test('ticketIdsOf: eindeutige Ticket-Kennungen der Arbeitsblätter, höchstens fünf', () => {
|
||||
assert.deepEqual(ticketIdsOf([{ workorderId: 't1' }, { workorderId: 't1' }, { workorderId: 't2' }, {}, null]), ['t1', 't2'])
|
||||
assert.deepEqual(ticketIdsOf(undefined), [])
|
||||
assert.equal(ticketIdsOf(Array.from({ length: 7 }, (_, i) => ({ workorderId: `t${i}` }))).length, 5)
|
||||
})
|
||||
@@ -0,0 +1,281 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const {
|
||||
LEISTUNGSZEITRAUM_MAX, BEZEICHNUNG_MAX,
|
||||
vorschauAnfrage, rechnungAnfrage, vorschauSchluessel, vorschauStatus, darfSpeichern,
|
||||
zeileTeilen, zeileBearbeitbar, maxVorn, createSubmitter,
|
||||
} = await loadModule('src/lib/rechnungAnfrage.js')
|
||||
const { ADDRESS_INCOMPLETE_TEXT } = await loadModule('src/lib/financeView.js')
|
||||
|
||||
const kunde = { $id: 'c1', name: 'Anelia Wolf', email: 'anelia@example.de', street: 'Hauptstr. 1', postalCode: '67655', city: 'Kaiserslautern', customerStatus: 'customer' }
|
||||
const zeile = (uid, description, quantity, price) => ({ uid, description, quantity, price })
|
||||
const bl = (id, minuten) => ({ id, ticketId: 't1', woid: '100131', datum: '2026-10-06', minuten, text: 'Startseite', aboZeit: false, kommentar: false })
|
||||
const B = [bl('b1', 60), bl('b2', 90), bl('b3', 45)]
|
||||
const basis = (over = {}) => ({
|
||||
kunde, art: 'festbetrag', projektId: 'p1', auftragId: '',
|
||||
items: [zeile(1, 'Erstellung der Website, Pauschalpreis laut Absprache', '1', '500,00')], zusatz: [],
|
||||
blaetter: [], abgewaehlt: [], anzahlung: { modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' },
|
||||
zeilentexte: {}, zeitraum: '', zeitraumGeaendert: false, days: '14', memo: '', ...over,
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Festbetrag ohne AB (500 € laut Absprache)', () => {
|
||||
const r = rechnungAnfrage(basis())
|
||||
assert.equal(r.ok, true)
|
||||
assert.deepEqual(r.body, {
|
||||
customerId: 'c1', art: 'festbetrag', projektId: 'p1', daysUntilDue: 14,
|
||||
items: [{ description: 'Erstellung der Website, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }],
|
||||
})
|
||||
assert.equal(r.totalCents, 50000)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: ohne Projekt ist die Art frei und der Körper trägt kein projektId', () => {
|
||||
const r = rechnungAnfrage(basis({ art: 'frei', projektId: '' }))
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.body.art, 'frei')
|
||||
assert.equal('projektId' in r.body, false)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Notiz und selbst geänderter Leistungszeitraum gehen mit, ein Vorschlag nicht', () => {
|
||||
assert.equal(LEISTUNGSZEITRAUM_MAX, 140)
|
||||
const r = rechnungAnfrage(basis({ memo: ' Danke ', zeitraum: ' Oktober 2026 ', zeitraumGeaendert: true }))
|
||||
assert.equal(r.body.memo, 'Danke')
|
||||
assert.equal(r.body.leistungszeitraum, 'Oktober 2026')
|
||||
assert.equal('leistungszeitraum' in rechnungAnfrage(basis({ zeitraum: '01.10.–20.10.2026', zeitraumGeaendert: false })).body, false)
|
||||
assert.equal('leistungszeitraum' in rechnungAnfrage(basis({ zeitraum: ' ', zeitraumGeaendert: true })).body, false)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Festbetrag prüft Kunde, Projekt, Positionen, Zahlungsziel und Zeitraum', () => {
|
||||
assert.deepEqual(rechnungAnfrage(basis({ projektId: '' })), { ok: false, error: 'Bitte ein Projekt wählen.' })
|
||||
assert.equal(rechnungAnfrage(basis({ kunde: null })).error, 'Bitte einen Kunden wählen.')
|
||||
assert.equal(rechnungAnfrage(basis({ items: [zeile(1, '', '1', '10,00')] })).error, 'Position 1: Bitte eine Beschreibung eingeben.')
|
||||
assert.equal(rechnungAnfrage(basis({ days: 'abc' })).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.')
|
||||
assert.equal(
|
||||
rechnungAnfrage(basis({ zeitraum: 'x'.repeat(141), zeitraumGeaendert: true })).error,
|
||||
'Der Leistungszeitraum darf höchstens 140 Zeichen lang sein.'
|
||||
)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Aufwand sendet nur die gewählten Arbeitsblätter, Zusatzpositionen und Zeilentexte', () => {
|
||||
const r = rechnungAnfrage(basis({
|
||||
art: 'aufwand', blaetter: B, abgewaehlt: ['b2'],
|
||||
zeilentexte: { '2026-10-06': '06.10.2026: Startseite', leer: ' ' },
|
||||
zusatz: [zeile(2, 'Domain-Registrierung', '1', '12,00'), zeile(3, '', '1', '')],
|
||||
}))
|
||||
assert.equal(r.ok, true)
|
||||
assert.deepEqual(r.body, {
|
||||
customerId: 'c1', art: 'aufwand', projektId: 'p1', daysUntilDue: 14,
|
||||
arbeitsblattIds: ['b1', 'b3'],
|
||||
items: [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1200 }],
|
||||
zeilentexte: { '2026-10-06': '06.10.2026: Startseite' },
|
||||
})
|
||||
assert.equal(r.totalCents, null)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Aufwand ohne Arbeitsblatt und unvollständige Zusatzpositionen', () => {
|
||||
assert.deepEqual(
|
||||
rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, abgewaehlt: ['b1', 'b2', 'b3'] })),
|
||||
{ ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
|
||||
)
|
||||
assert.equal(
|
||||
rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, 'Domain', '1', '')] })).error,
|
||||
'Zusatzposition 1: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).'
|
||||
)
|
||||
assert.equal(
|
||||
rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, '', '1', '5,00')] })).error,
|
||||
'Zusatzposition 1: Bitte eine Beschreibung eingeben.'
|
||||
)
|
||||
// eine ganz leere Zeile zählt nicht
|
||||
const r = rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, '', '1', '')] }))
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal('items' in r.body, false)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Anzahlung in Prozent oder in Euro mit Bezeichnung', () => {
|
||||
const prozent = rechnungAnfrage(basis({ art: 'anzahlung', auftragId: 'a1', anzahlung: { modus: 'prozent', wert: '33,5', bezeichnung: ' Teilrechnung ' } }))
|
||||
assert.deepEqual(prozent.body, {
|
||||
customerId: 'c1', art: 'anzahlung', projektId: 'p1', auftragId: 'a1', daysUntilDue: 14,
|
||||
anzahlung: { prozent: 33.5, bezeichnung: 'Teilrechnung' },
|
||||
})
|
||||
const betrag = rechnungAnfrage(basis({ art: 'anzahlung', auftragId: 'a1', anzahlung: { modus: 'betrag', wert: '400,00', bezeichnung: 'Anzahlung' } }))
|
||||
assert.deepEqual(betrag.body.anzahlung, { betragCents: 40000, bezeichnung: 'Anzahlung' })
|
||||
assert.equal(BEZEICHNUNG_MAX, 60)
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Anzahlung prüft AB, Prozent, Betrag und Bezeichnung', () => {
|
||||
const an = (anzahlung, auftragId = 'a1') => rechnungAnfrage(basis({ art: 'anzahlung', auftragId, anzahlung }))
|
||||
assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }, '').error, 'Bitte eine Auftragsbestätigung wählen.')
|
||||
for (const wert of ['0', '0,00', '101', 'abc', '']) {
|
||||
assert.equal(an({ modus: 'prozent', wert, bezeichnung: 'Anzahlung' }).error, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.', wert)
|
||||
}
|
||||
for (const wert of ['0,5', '100']) assert.equal(an({ modus: 'prozent', wert, bezeichnung: 'Anzahlung' }).ok, true, wert)
|
||||
assert.equal(an({ modus: 'betrag', wert: 'abc', bezeichnung: 'Anzahlung' }).error, 'Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.')
|
||||
assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: ' ' }).error, 'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).')
|
||||
assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: 'x'.repeat(61) }).error, 'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).')
|
||||
})
|
||||
|
||||
test('rechnungAnfrage: Schlussrechnung mit Nachträgen; die Vorschau trägt weder Zahlungsziel noch Notiz', () => {
|
||||
const e = basis({ art: 'schluss', auftragId: 'a1', zusatz: [zeile(2, 'Nachtrag Logo', '1', '150,00')], memo: 'Danke' })
|
||||
const items = [{ description: 'Nachtrag Logo', quantity: 1, unitAmountCents: 15000 }]
|
||||
assert.deepEqual(rechnungAnfrage(e).body, {
|
||||
customerId: 'c1', art: 'schluss', projektId: 'p1', auftragId: 'a1', items, daysUntilDue: 14, memo: 'Danke',
|
||||
})
|
||||
assert.deepEqual(vorschauAnfrage(e), { ok: true, body: { customerId: 'c1', art: 'schluss', projektId: 'p1', auftragId: 'a1', items } })
|
||||
assert.deepEqual(rechnungAnfrage(basis({ art: 'schluss', auftragId: '' })), { ok: false, error: 'Bitte eine Auftragsbestätigung wählen.' })
|
||||
})
|
||||
|
||||
test('vorschauAnfrage: Festbetrag hat keine Vorschau; Zahlungsziel und Notiz blockieren sie nie', () => {
|
||||
assert.deepEqual(vorschauAnfrage(basis()), { ok: false, error: '' })
|
||||
assert.deepEqual(vorschauAnfrage(basis({ art: 'frei', projektId: '' })), { ok: false, error: '' })
|
||||
const e = basis({ art: 'schluss', auftragId: 'a1', days: 'abc', memo: 'x'.repeat(1001) })
|
||||
assert.equal(vorschauAnfrage(e).ok, true)
|
||||
assert.equal(rechnungAnfrage(e).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.')
|
||||
assert.deepEqual(vorschauAnfrage(basis({ art: 'aufwand', kunde: null })), { ok: false, error: 'Bitte einen Kunden wählen.' })
|
||||
})
|
||||
|
||||
test('vorschauSchluessel: gleiche Auswahl in anderer Reihenfolge ist derselbe Schlüssel, Texte zählen nicht', () => {
|
||||
const key = (over) => vorschauSchluessel(vorschauAnfrage(basis({ art: 'aufwand', blaetter: B, ...over })).body)
|
||||
const k1 = key({})
|
||||
assert.equal(key({ blaetter: [B[2], B[0], B[1]] }), k1)
|
||||
assert.notEqual(key({ abgewaehlt: ['b2'] }), k1)
|
||||
assert.equal(key({ zeilentexte: { '2026-10-06': 'x' }, zeitraum: 'Oktober', zeitraumGeaendert: true }), k1)
|
||||
assert.notEqual(key({ zusatz: [zeile(2, 'Domain', '1', '12,00')] }), k1)
|
||||
})
|
||||
|
||||
test('vorschauStatus: nicht nötig, ungültig, lädt, veraltet, Fehler, bereit', () => {
|
||||
const ok = { ok: true, body: {} }
|
||||
const fertig = { key: 'k1', status: 'ok', daten: { summeCents: 30000, positionen: [] } }
|
||||
const status = (args) => vorschauStatus({ art: 'aufwand', anfrage: ok, vorschau: fertig, schluessel: 'k1', ...args })
|
||||
assert.deepEqual(
|
||||
vorschauStatus({ art: 'festbetrag', anfrage: { ok: false, error: '' }, vorschau: null, schluessel: '' }),
|
||||
{ state: 'nicht-noetig', message: '' }
|
||||
)
|
||||
assert.deepEqual(
|
||||
status({ anfrage: { ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }, schluessel: '' }),
|
||||
{ state: 'ungueltig', message: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
|
||||
)
|
||||
assert.deepEqual(status({ vorschau: null }), { state: 'laedt', message: '' })
|
||||
assert.equal(status({ vorschau: { key: 'k1', status: 'laedt' } }).state, 'laedt')
|
||||
assert.equal(status({ schluessel: 'k2' }).state, 'laedt') // veraltet: die Eingabe hat sich geändert
|
||||
assert.deepEqual(
|
||||
status({ vorschau: { key: 'k1', status: 'fehler', message: 'Arbeitsblatt bereits abgerechnet' } }),
|
||||
{ state: 'fehler', message: 'Arbeitsblatt bereits abgerechnet' }
|
||||
)
|
||||
assert.deepEqual(
|
||||
status({ vorschau: { key: 'k1', status: 'ok', daten: { summeCents: 0, positionen: [] } } }),
|
||||
{ state: 'fehler', message: 'Nichts mehr offen.' }
|
||||
)
|
||||
assert.deepEqual(status({}), { state: 'bereit', message: '' })
|
||||
assert.equal(darfSpeichern({ state: 'bereit' }), true)
|
||||
assert.equal(darfSpeichern({ state: 'nicht-noetig' }), true)
|
||||
for (const state of ['laedt', 'ungueltig', 'fehler']) assert.equal(darfSpeichern({ state }), false, state)
|
||||
})
|
||||
|
||||
test('Abwahl eines Arbeitsblatts macht die Vorschau veraltet und sperrt Entwurf und Senden', () => {
|
||||
const e1 = basis({ art: 'aufwand', blaetter: B })
|
||||
const a1 = vorschauAnfrage(e1)
|
||||
const k1 = vorschauSchluessel(a1.body)
|
||||
const vorschau = { key: k1, status: 'ok', daten: { summeCents: 32500, positionen: [] } }
|
||||
assert.equal(darfSpeichern(vorschauStatus({ art: 'aufwand', anfrage: a1, vorschau, schluessel: k1 })), true)
|
||||
const a2 = vorschauAnfrage({ ...e1, abgewaehlt: ['b2'] })
|
||||
const k2 = vorschauSchluessel(a2.body)
|
||||
assert.deepEqual(a2.body.arbeitsblattIds, ['b1', 'b3'])
|
||||
assert.notEqual(k2, k1)
|
||||
assert.equal(darfSpeichern(vorschauStatus({ art: 'aufwand', anfrage: a2, vorschau, schluessel: k2 })), false)
|
||||
})
|
||||
|
||||
test('zeileTeilen, zeileBearbeitbar und maxVorn: nur Aufwandszeilen sind bearbeitbar, geteilt wird am letzten Gedankenstrich', () => {
|
||||
const tag = '06.10.2026: Startseite, Kontaktformular – 2,25 Std. à 100,00 €'
|
||||
assert.deepEqual(zeileTeilen(tag), { vorn: '06.10.2026: Startseite, Kontaktformular', hinten: ' – 2,25 Std. à 100,00 €' })
|
||||
assert.deepEqual(zeileTeilen('KW 41/2026: A – B – 6,5 Std. à 100,00 €'), { vorn: 'KW 41/2026: A – B', hinten: ' – 6,5 Std. à 100,00 €' })
|
||||
assert.deepEqual(zeileTeilen('Domain-Registrierung'), { vorn: 'Domain-Registrierung', hinten: '' })
|
||||
const pos = (schluessel, beschreibung = tag) => ({ schluessel, beschreibung })
|
||||
assert.equal(zeileBearbeitbar(pos('2026-10-06')), true)
|
||||
assert.equal(zeileBearbeitbar(pos('KW-2026-41')), true)
|
||||
for (const schluessel of ['ab-1', 'zusatz-1', 'abzug-in_123', 'anzahlung']) assert.equal(zeileBearbeitbar(pos(schluessel)), false, schluessel)
|
||||
assert.equal(zeileBearbeitbar(pos('2026-10-06', 'ohne Strich')), false)
|
||||
const hinten = ' – 2,25 Std. à 100,00 €'
|
||||
assert.equal(maxVorn(hinten), 200 - hinten.length)
|
||||
assert.equal(maxVorn('x'.repeat(250)), 1)
|
||||
})
|
||||
|
||||
const okAntwort = { rechnung: { id: 'in_1', number: 'WEBKLAR-0009', status: 'offen', customerEmail: 'anelia@example.de' } }
|
||||
const anfrage = rechnungAnfrage(basis())
|
||||
|
||||
test('createSubmitter: Entwurf fragt nicht nach und sendet senden=false', async () => {
|
||||
const gesendet = []
|
||||
const submit = createSubmitter({
|
||||
create: async (body) => (gesendet.push(body), okAntwort),
|
||||
confirm: () => assert.fail('beim Entwurf gibt es keine Rückfrage'),
|
||||
})
|
||||
const r = await submit({ anfrage, senden: false, kunde, totalCents: 50000 })
|
||||
assert.deepEqual(r, { status: 'ok', rechnung: okAntwort.rechnung, senden: false })
|
||||
assert.equal(gesendet[0].senden, false)
|
||||
assert.equal(gesendet[0].art, 'festbetrag')
|
||||
})
|
||||
|
||||
test('createSubmitter: Senden fragt vorher nach, bei „Abbrechen“ geht nichts raus', async () => {
|
||||
let frage = ''
|
||||
let create = 0
|
||||
const submit = createSubmitter({ create: async () => { create += 1; return okAntwort }, confirm: (text) => { frage = text; return false } })
|
||||
assert.deepEqual(await submit({ anfrage, senden: true, kunde, totalCents: 50000 }), { status: 'abgebrochen' })
|
||||
assert.equal(create, 0)
|
||||
assert.equal(frage, 'Rechnung für Anelia Wolf über 500,00 € jetzt festschreiben und an anelia@example.de senden? Danach lässt sie sich nur noch stornieren.')
|
||||
})
|
||||
|
||||
test('createSubmitter: Adress-Sperre über 250 € ohne Anschrift gilt auch hier', async () => {
|
||||
let create = 0
|
||||
const submit = createSubmitter({ create: async () => { create += 1; return okAntwort }, confirm: () => true })
|
||||
const ohneAdresse = { ...kunde, street: '' }
|
||||
assert.deepEqual(await submit({ anfrage, senden: true, kunde: ohneAdresse, totalCents: 25001 }), { status: 'fehler', message: ADDRESS_INCOMPLETE_TEXT })
|
||||
assert.equal(create, 0)
|
||||
assert.equal((await submit({ anfrage, senden: true, kunde: ohneAdresse, totalCents: 25000 })).status, 'ok') // Kleinbetrag
|
||||
assert.equal((await submit({ anfrage, senden: false, kunde: ohneAdresse, totalCents: 25001 })).status, 'ok') // Entwurf bleibt möglich
|
||||
})
|
||||
|
||||
test('createSubmitter: Doppelklick auf Senden legt nur eine Rechnung an', async () => {
|
||||
let create = 0
|
||||
let fertig
|
||||
const submit = createSubmitter({
|
||||
create: () => { create += 1; return new Promise((resolve) => { fertig = () => resolve(okAntwort) }) },
|
||||
confirm: () => true,
|
||||
})
|
||||
const erster = submit({ anfrage, senden: true, kunde, totalCents: 50000 })
|
||||
const zweiter = await submit({ anfrage, senden: true, kunde, totalCents: 50000 })
|
||||
assert.deepEqual(zweiter, { status: 'laeuft' })
|
||||
fertig()
|
||||
assert.equal((await erster).status, 'ok')
|
||||
assert.equal(create, 1)
|
||||
// danach geht wieder ein Aufruf (nach einem Fehler muss der Nutzer es erneut versuchen können)
|
||||
const dritter = submit({ anfrage, senden: false, kunde, totalCents: 50000 })
|
||||
fertig()
|
||||
assert.equal((await dritter).status, 'ok')
|
||||
assert.equal(create, 2)
|
||||
})
|
||||
|
||||
test('createSubmitter: Fehler des Servers kommen als Meldung zurück, auch 409 und „festgeschrieben“', async () => {
|
||||
const wirft = (err) => createSubmitter({ create: async () => { throw err }, confirm: () => true })
|
||||
const konflikt = Object.assign(new Error('Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).'), { status: 409 })
|
||||
assert.deepEqual(await wirft(konflikt)({ anfrage, senden: false, kunde, totalCents: 1 }), {
|
||||
status: 'fehler', message: 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).', httpStatus: 409,
|
||||
})
|
||||
const ohneStatus = new Error('Irgendwas ist schiefgegangen.')
|
||||
assert.deepEqual(await wirft(ohneStatus)({ anfrage, senden: false, kunde, totalCents: 1 }), { status: 'fehler', message: 'Irgendwas ist schiefgegangen.' })
|
||||
const festgeschrieben = Object.assign(new Error('Rechnung WEBKLAR-0009 wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.'), {
|
||||
status: 502, invoiceId: 'in_9', invoiceNumber: 'WEBKLAR-0009',
|
||||
})
|
||||
assert.deepEqual(await wirft(festgeschrieben)({ anfrage, senden: true, kunde, totalCents: 50000 }), {
|
||||
status: 'festgeschrieben',
|
||||
notice: 'Rechnung WEBKLAR-0009 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.',
|
||||
finalizedFilter: 'offen',
|
||||
})
|
||||
})
|
||||
|
||||
test('createSubmitter: eine ungültige Anfrage geht nie an den Server', async () => {
|
||||
let create = 0
|
||||
const submit = createSubmitter({ create: async () => { create += 1 }, confirm: () => true })
|
||||
const r = await submit({ anfrage: { ok: false, error: 'Bitte einen Kunden wählen.' }, senden: false, kunde, totalCents: 0 })
|
||||
assert.deepEqual(r, { status: 'fehler', message: 'Bitte einen Kunden wählen.' })
|
||||
assert.equal(create, 0)
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { anfangszustand, reduce } = await loadModule('src/lib/rechnungDialogState.js')
|
||||
|
||||
test('anfangszustand: nichts gewählt, eine leere Position, Zahlungsziel 14', () => {
|
||||
assert.deepEqual(anfangszustand({ projektId: 'p1' }), {
|
||||
projektId: 'p1', art: '', auftragId: '',
|
||||
items: [{ uid: 1, description: '', quantity: '1', price: '' }], zusatz: [],
|
||||
abgewaehlt: [], weitereTickets: [], anzahlung: {}, zeilentexte: {},
|
||||
zeitraum: '', zeitraumGeaendert: false, days: '14', memo: '',
|
||||
})
|
||||
assert.equal(anfangszustand().projektId, null)
|
||||
assert.equal(anfangszustand({ projektId: '' }).projektId, '')
|
||||
})
|
||||
|
||||
test('projekt: setzt alles zurück, behält aber Zahlungsziel und Notiz', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
s = reduce(s, { type: 'tage', wert: '30' })
|
||||
s = reduce(s, { type: 'notiz', wert: 'Danke' })
|
||||
s = reduce(s, { type: 'art', art: 'aufwand' })
|
||||
s = reduce(s, { type: 'blatt', id: 'b1' })
|
||||
s = reduce(s, { type: 'zeilentext', schluessel: '2026-10-06', text: 'Neu', original: 'Alt' })
|
||||
s = reduce(s, { type: 'projekt', projektId: 'p2' })
|
||||
assert.equal(s.projektId, 'p2')
|
||||
assert.equal(s.art, '')
|
||||
assert.deepEqual(s.abgewaehlt, [])
|
||||
assert.deepEqual(s.zeilentexte, {})
|
||||
assert.equal(s.days, '30')
|
||||
assert.equal(s.memo, 'Danke')
|
||||
assert.equal(reduce(s, { type: 'projekt', projektId: null }).projektId, null)
|
||||
})
|
||||
|
||||
test('art: Positionen für Festbetrag und Zusatzpositionen bleiben getrennt und erhalten', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
s = reduce(s, { type: 'position', liste: 'items', uid: 1, feld: 'description', wert: 'Pauschale' })
|
||||
s = reduce(s, { type: 'position+', liste: 'zusatz' })
|
||||
s = reduce(s, { type: 'position', liste: 'zusatz', uid: 2, feld: 'price', wert: '12,00' })
|
||||
s = reduce(s, { type: 'art', art: 'aufwand' })
|
||||
assert.equal(s.art, 'aufwand')
|
||||
assert.equal(s.items[0].description, 'Pauschale')
|
||||
assert.deepEqual(s.zusatz, [{ uid: 2, description: '', quantity: '1', price: '12,00' }])
|
||||
// eine unbekannte Liste oder ein unbekanntes Feld ändert nichts
|
||||
assert.equal(reduce(s, { type: 'position', liste: 'x', uid: 1, feld: 'description', wert: 'y' }), s)
|
||||
assert.equal(reduce(s, { type: 'position', liste: 'items', uid: 1, feld: 'uid', wert: 9 }), s)
|
||||
})
|
||||
|
||||
test('position+ und position-: Positionen mindestens 1, Zusatzpositionen mindestens 0, höchstens 20', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
assert.equal(reduce(s, { type: 'position-', liste: 'items', uid: 1 }), s)
|
||||
s = reduce(s, { type: 'position+', liste: 'zusatz' })
|
||||
assert.equal(s.zusatz[0].uid, 2)
|
||||
s = reduce(s, { type: 'position-', liste: 'zusatz', uid: 2 })
|
||||
assert.deepEqual(s.zusatz, [])
|
||||
for (let i = 0; i < 25; i += 1) s = reduce(s, { type: 'position+', liste: 'zusatz' })
|
||||
assert.equal(s.zusatz.length, 20)
|
||||
})
|
||||
|
||||
test('blatt: Abwählen und Wiederanwählen', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
s = reduce(s, { type: 'blatt', id: 'b2' })
|
||||
assert.deepEqual(s.abgewaehlt, ['b2'])
|
||||
s = reduce(s, { type: 'blatt', id: 'b2' })
|
||||
assert.deepEqual(s.abgewaehlt, [])
|
||||
})
|
||||
|
||||
test('ticket+ und ticket-: ein Ticket nur einmal, beim Entfernen verschwindet die Abwahl seiner Blätter', () => {
|
||||
const ticket = { ticketId: 't2', woid: '100140', titel: 'Kontaktformular' }
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
s = reduce(s, { type: 'ticket+', ticket })
|
||||
s = reduce(s, { type: 'ticket+', ticket })
|
||||
assert.deepEqual(s.weitereTickets, [ticket])
|
||||
s = reduce(s, { type: 'blatt', id: 'w1' })
|
||||
s = reduce(s, { type: 'blatt', id: 'b9' })
|
||||
s = reduce(s, { type: 'ticket-', ticketId: 't2', blattIds: ['w1'] })
|
||||
assert.deepEqual(s.weitereTickets, [])
|
||||
assert.deepEqual(s.abgewaehlt, ['b9'])
|
||||
})
|
||||
|
||||
test('zeitraum-vorschlag gilt nur, solange der Nutzer das Feld nicht selbst geändert hat', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
s = reduce(s, { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' })
|
||||
assert.deepEqual([s.zeitraum, s.zeitraumGeaendert], ['01.10.–20.10.2026', false])
|
||||
assert.equal(reduce(s, { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' }), s) // gleicher Text: kein neuer Zustand
|
||||
s = reduce(s, { type: 'zeitraum', text: 'Oktober 2026' })
|
||||
assert.deepEqual([s.zeitraum, s.zeitraumGeaendert], ['Oktober 2026', true])
|
||||
assert.equal(reduce(s, { type: 'zeitraum-vorschlag', text: '02.10.–21.10.2026' }).zeitraum, 'Oktober 2026')
|
||||
// ein Wechsel der Art verwirft einen Vorschlag, nicht aber die Eingabe des Nutzers
|
||||
const vorschlag = reduce(anfangszustand({ projektId: 'p1' }), { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' })
|
||||
assert.equal(reduce(vorschlag, { type: 'art', art: 'festbetrag' }).zeitraum, '')
|
||||
assert.equal(reduce(s, { type: 'art', art: 'festbetrag' }).zeitraum, 'Oktober 2026')
|
||||
})
|
||||
|
||||
test('zeilentext: ein unveränderter oder leerer Text entfernt den Eintrag', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
const setze = (text) => reduce(s, { type: 'zeilentext', schluessel: '2026-10-06', text, original: 'Alt' })
|
||||
s = setze('Neu')
|
||||
assert.deepEqual(s.zeilentexte, { '2026-10-06': 'Neu' })
|
||||
s = setze('Alt')
|
||||
assert.deepEqual(s.zeilentexte, {})
|
||||
s = setze('Neu')
|
||||
s = setze(' ')
|
||||
assert.deepEqual(s.zeilentexte, {})
|
||||
})
|
||||
|
||||
test('anzahlung und auftrag: Eingaben ergänzen sich, eine andere AB setzt sie zurück', () => {
|
||||
let s = anfangszustand({ projektId: 'p1' })
|
||||
s = reduce(s, { type: 'anzahlung', patch: { modus: 'betrag' } })
|
||||
s = reduce(s, { type: 'anzahlung', patch: { wert: '400,00' } })
|
||||
assert.deepEqual(s.anzahlung, { modus: 'betrag', wert: '400,00' })
|
||||
s = reduce(s, { type: 'auftrag', auftragId: 'a2' })
|
||||
assert.deepEqual([s.auftragId, s.anzahlung], ['a2', {}])
|
||||
assert.equal(reduce(s, { type: 'gibtsnicht' }), s)
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const {
|
||||
AB_HINWEIS, ENTWURF_HINWEIS,
|
||||
startProjekt, projektOptionen, wirksamesProjekt,
|
||||
gueltigeFestpreisAbs, aufwandAuftrag, abLabel, berechnetFuerAuftrag, hatEntwurfZurAuftrag,
|
||||
artOptionen, standardArt, standardAuftrag, anzahlungStandard,
|
||||
ausgewaehlteIds, blaetterSumme, blattAusAppwrite, ticketOptionen,
|
||||
} = await loadModule('src/lib/rechnungDialogView.js')
|
||||
|
||||
const ab = (over = {}) => ({
|
||||
id: 'a1', nummer: 'AB-2026-0002', status: 'bestaetigt', art: 'festpreis', summeCents: 80000,
|
||||
gesendetAm: '2026-10-06T08:15:00.000Z', zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, stundensatzCents: null, ...over,
|
||||
})
|
||||
const rk = (over = {}) => ({
|
||||
id: 'in_1', number: 'WEBKLAR-0007', status: 'offen', createdDate: '2026-10-08', totalCents: 40000, paidCents: 0, art: 'anzahlung', auftragId: 'a1', ...over,
|
||||
})
|
||||
const bl = (id, minuten, over = {}) => ({
|
||||
id, ticketId: 't1', woid: '100131', datum: '2026-10-06', minuten, text: 'Startseite', aboZeit: false, kommentar: false, ...over,
|
||||
})
|
||||
const KONTEN = [
|
||||
{ projektId: 'p1', name: 'Website Relaunch', tickets: [{ id: 't1', woid: '100131' }] },
|
||||
{ projektId: 'p2', name: 'Shop', tickets: [] },
|
||||
]
|
||||
const optionen = (args) => artOptionen({ projektId: 'p1', auftraege: [], rechnungen: [], auftraegeAktiv: true, ...args })
|
||||
const zustand = (o) => o.map((x) => [x.key, x.enabled])
|
||||
|
||||
test('startProjekt: Vorauswahl der Aufrufer (undefined = keine, null = Ohne Projekt, Text = Projekt)', () => {
|
||||
assert.equal(startProjekt(undefined), null)
|
||||
assert.equal(startProjekt(null), '')
|
||||
assert.equal(startProjekt('p1'), 'p1')
|
||||
})
|
||||
|
||||
test('projektOptionen: Projekte mit Ticketnummer, am Ende „Ohne Projekt“', () => {
|
||||
assert.deepEqual(projektOptionen(KONTEN), [
|
||||
{ value: 'p1', label: 'Website Relaunch · #100131' },
|
||||
{ value: 'p2', label: 'Shop' },
|
||||
{ value: '', label: 'Ohne Projekt' },
|
||||
])
|
||||
assert.deepEqual(projektOptionen([]), [{ value: '', label: 'Ohne Projekt' }])
|
||||
assert.deepEqual(projektOptionen(undefined), [{ value: '', label: 'Ohne Projekt' }])
|
||||
})
|
||||
|
||||
test('wirksamesProjekt: null = noch zu wählen, „“ = Ohne Projekt, Kunde ohne Projekte = Ohne Projekt', () => {
|
||||
assert.equal(wirksamesProjekt('', KONTEN), '')
|
||||
assert.equal(wirksamesProjekt('p2', KONTEN), 'p2')
|
||||
assert.equal(wirksamesProjekt('gibtsnicht', KONTEN), null)
|
||||
assert.equal(wirksamesProjekt('p1', undefined), 'p1') // Liste noch nicht geladen: die Vorauswahl bleibt
|
||||
assert.equal(wirksamesProjekt(null, KONTEN), null)
|
||||
assert.equal(wirksamesProjekt(null, []), '') // Kunde ohne Projekte: gleich „Ohne Projekt“
|
||||
assert.equal(wirksamesProjekt(null, undefined), null)
|
||||
})
|
||||
|
||||
test('gueltigeFestpreisAbs: nur Festpreis mit Status versendet oder bestätigt, neueste zuerst', () => {
|
||||
const list = [
|
||||
ab({ id: 'a1' }),
|
||||
ab({ id: 'a2', status: 'entwurf' }),
|
||||
ab({ id: 'a3', art: 'aufwand' }),
|
||||
ab({ id: 'a4', status: 'abgerechnet' }),
|
||||
ab({ id: 'a5', status: 'versendet', gesendetAm: '2026-10-07T08:00:00.000Z' }),
|
||||
ab({ id: 'a6', status: 'storniert' }),
|
||||
]
|
||||
assert.deepEqual(gueltigeFestpreisAbs(list).map((a) => a.id), ['a5', 'a1'])
|
||||
assert.deepEqual(gueltigeFestpreisAbs(undefined), [])
|
||||
})
|
||||
|
||||
test('aufwandAuftrag: jüngste gültige Aufwands-AB', () => {
|
||||
const list = [
|
||||
ab({ id: 'x1', art: 'aufwand', status: 'bestaetigt', gesendetAm: '2026-10-01T08:00:00.000Z' }),
|
||||
ab({ id: 'x2', art: 'aufwand', status: 'versendet', gesendetAm: '2026-10-05T08:00:00.000Z' }),
|
||||
ab({ id: 'x3', art: 'aufwand', status: 'storniert', gesendetAm: '2026-10-09T08:00:00.000Z' }),
|
||||
ab({ id: 'a1' }),
|
||||
]
|
||||
assert.equal(aufwandAuftrag(list).id, 'x2')
|
||||
assert.equal(aufwandAuftrag([ab()]), null)
|
||||
assert.equal(aufwandAuftrag(undefined), null)
|
||||
})
|
||||
|
||||
test('abLabel: Nummer, Datum des Sendens und Summe bzw. „nach Aufwand“', () => {
|
||||
assert.equal(abLabel(ab()), 'AB-2026-0002 vom 06.10.2026 · 800,00 €')
|
||||
assert.equal(abLabel(ab({ art: 'aufwand', summeCents: null })), 'AB-2026-0002 vom 06.10.2026 · nach Aufwand')
|
||||
})
|
||||
|
||||
test('berechnetFuerAuftrag und hatEntwurfZurAuftrag: nur finalisierte zählen, ein Entwurf blockiert', () => {
|
||||
const list = [
|
||||
rk(),
|
||||
rk({ id: 'in_2', status: 'bezahlt', totalCents: 10000 }),
|
||||
rk({ id: 'in_3', status: 'entwurf', totalCents: 5000 }),
|
||||
rk({ id: 'in_4', status: 'storniert', totalCents: 7000 }),
|
||||
rk({ id: 'in_5', auftragId: 'a2', totalCents: 9000 }),
|
||||
]
|
||||
assert.equal(berechnetFuerAuftrag(list, 'a1'), 50000)
|
||||
assert.equal(berechnetFuerAuftrag(list, 'a2'), 9000)
|
||||
assert.equal(berechnetFuerAuftrag(undefined, 'a1'), 0)
|
||||
assert.equal(hatEntwurfZurAuftrag(list, 'a1'), true)
|
||||
assert.equal(hatEntwurfZurAuftrag(list, 'a2'), false)
|
||||
})
|
||||
|
||||
test('artOptionen: mit gültiger Festpreis-AB sind alle vier Arten wählbar', () => {
|
||||
const o = optionen({ auftraege: [ab()] })
|
||||
assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', true], ['aufwand', true], ['festbetrag', true]])
|
||||
assert.deepEqual(o.map((x) => x.label), ['Anzahlung', 'Schlussrechnung', 'Nach Aufwand', 'Festbetrag'])
|
||||
})
|
||||
|
||||
test('artOptionen: ohne AB oder bei ausgeschaltetem Schalter erklärt ein Hinweis die Sperre', () => {
|
||||
for (const args of [{ auftraege: [] }, { auftraege: [ab()], auftraegeAktiv: false }, { auftraege: [ab({ status: 'entwurf' })] }]) {
|
||||
const o = optionen(args)
|
||||
assert.deepEqual(zustand(o), [['anzahlung', false], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
|
||||
assert.equal(o[0].hint, AB_HINWEIS)
|
||||
assert.equal(o[1].hint, AB_HINWEIS)
|
||||
}
|
||||
assert.equal(AB_HINWEIS, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
|
||||
})
|
||||
|
||||
test('artOptionen: ein offener Entwurf zur AB sperrt die Schlussrechnung, nicht die Anzahlung', () => {
|
||||
const o = optionen({ auftraege: [ab()], rechnungen: [rk({ status: 'entwurf' })] })
|
||||
assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
|
||||
assert.equal(o[1].hint, ENTWURF_HINWEIS)
|
||||
assert.equal(ENTWURF_HINWEIS, 'Zu dieser Auftragsbestätigung gibt es noch einen Entwurf. Erst senden oder löschen.')
|
||||
// zwei ABs: die Schlussrechnung bleibt wählbar, solange eine AB ohne Entwurf da ist
|
||||
const zwei = optionen({ auftraege: [ab(), ab({ id: 'a2', nummer: 'AB-2026-0003' })], rechnungen: [rk({ status: 'entwurf' })] })
|
||||
assert.equal(zwei[1].enabled, true)
|
||||
})
|
||||
|
||||
test('artOptionen: ohne Wahl keine Arten, ohne Projekt nur die freie Rechnung', () => {
|
||||
assert.deepEqual(artOptionen({ projektId: null, auftraege: [ab()], rechnungen: [], auftraegeAktiv: true }), [])
|
||||
const frei = artOptionen({ projektId: '', auftraege: [ab()], rechnungen: [], auftraegeAktiv: true })
|
||||
assert.deepEqual(zustand(frei), [['frei', true]])
|
||||
})
|
||||
|
||||
test('standardArt: Anzahlung, Schlussrechnung, Aufwand oder Festbetrag je nach Lage', () => {
|
||||
const lage = (args, extra = {}) => {
|
||||
const eingabe = { auftraege: [], rechnungen: [], offeneMinuten: 0, ...args, ...extra }
|
||||
return standardArt(optionen({ auftraege: eingabe.auftraege, rechnungen: eingabe.rechnungen }), eingabe)
|
||||
}
|
||||
assert.equal(lage({ auftraege: [ab()] }), 'anzahlung') // Zahlungsplan Anzahlung, noch nichts berechnet
|
||||
assert.equal(lage({ auftraege: [ab()], rechnungen: [rk()] }), 'schluss') // Anzahlung schon berechnet
|
||||
const nachAbnahme = ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })
|
||||
assert.equal(lage({ auftraege: [nachAbnahme] }), 'schluss')
|
||||
assert.equal(lage({}, { offeneMinuten: 90 }), 'aufwand')
|
||||
assert.equal(lage({}), 'festbetrag')
|
||||
const ohneProjekt = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: true })
|
||||
assert.equal(standardArt(ohneProjekt, { auftraege: [], rechnungen: [], offeneMinuten: 0 }), 'frei')
|
||||
assert.equal(standardArt([], { auftraege: [], rechnungen: [], offeneMinuten: 0 }), '')
|
||||
})
|
||||
|
||||
test('standardAuftrag: die jüngste passende AB, bei Schlussrechnung ohne offenen Entwurf', () => {
|
||||
const neu = ab({ id: 'a5', gesendetAm: '2026-10-07T08:00:00.000Z' })
|
||||
assert.equal(standardAuftrag('anzahlung', [ab(), neu]), 'a5')
|
||||
assert.equal(standardAuftrag('schluss', [ab(), neu]), 'a5')
|
||||
assert.equal(standardAuftrag('schluss', [ab(), neu], [rk({ auftragId: 'a5', status: 'entwurf' })]), 'a1')
|
||||
assert.equal(standardAuftrag('anzahlung', []), '')
|
||||
assert.equal(standardAuftrag('aufwand', [ab({ id: 'x2', art: 'aufwand' })]), '') // die Aufwands-AB ordnet der Server zu
|
||||
assert.equal(standardAuftrag('festbetrag', [ab()]), '')
|
||||
})
|
||||
|
||||
test('anzahlungStandard: Prozent aus dem Zahlungsplan, sonst 50', () => {
|
||||
const vorgabe = { modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }
|
||||
assert.deepEqual(anzahlungStandard(ab()), vorgabe)
|
||||
assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'anzahlung', prozent: 33.5, text: '' } })), { ...vorgabe, wert: '33,5' })
|
||||
assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })), vorgabe)
|
||||
assert.deepEqual(anzahlungStandard(null), vorgabe)
|
||||
})
|
||||
|
||||
test('ausgewaehlteIds und blaetterSumme: alle sind vorausgewählt, abgewählte fallen heraus', () => {
|
||||
const B = [bl('b1', 60), bl('b2', 90), bl('b3', 45)]
|
||||
assert.deepEqual(ausgewaehlteIds(B, []), ['b1', 'b2', 'b3'])
|
||||
assert.deepEqual(ausgewaehlteIds(B, ['b2']), ['b1', 'b3'])
|
||||
assert.deepEqual(ausgewaehlteIds(undefined, []), [])
|
||||
assert.deepEqual(blaetterSumme(B, ['b1', 'b3']), { anzahl: 2, minuten: 105 })
|
||||
assert.deepEqual(blaetterSumme(B, []), { anzahl: 0, minuten: 0 })
|
||||
})
|
||||
|
||||
const ws = (over = {}) => ({
|
||||
$id: 'w1', woid: '100140', workorderId: 't2', totalTime: 60, supporters: '', isComment: false, countsToPlan: false,
|
||||
startDate: '06.10.2026', endDate: '06.10.2026', details: 'Kontaktformular gebaut', $createdAt: '2026-10-06T07:00:00.000Z', ...over,
|
||||
})
|
||||
|
||||
test('blattAusAppwrite: abrechenbares Blatt mit Minuten (inkl. Unterstützung), Datum und kurzem Text', () => {
|
||||
assert.deepEqual(blattAusAppwrite(ws(), {}), {
|
||||
id: 'w1', ticketId: 't2', woid: '100140', datum: '2026-10-06', minuten: 60, text: 'Kontaktformular gebaut', aboZeit: false, kommentar: false,
|
||||
})
|
||||
// Unterstützung zählt mit: 60 + 1 × 30
|
||||
const mit = ws({ supporters: JSON.stringify([{ employeeId: 'e1', name: 'A', task: 'Review' }]) })
|
||||
assert.equal(blattAusAppwrite(mit, {}).minuten, 90)
|
||||
// ein Altwert außerhalb des 15-Minuten-Rasters wird aufgerundet
|
||||
assert.equal(blattAusAppwrite(ws({ totalTime: 70 }), {}).minuten, 75)
|
||||
// ohne gültiges Datum zählt der Erstellungstag, der Text ist die erste Zeile, höchstens 120 Zeichen
|
||||
assert.equal(blattAusAppwrite(ws({ startDate: '', endDate: '' }), {}).datum, '2026-10-06')
|
||||
assert.equal(blattAusAppwrite(ws({ details: 'Zeile 1\nZeile 2' }), {}).text, 'Zeile 1')
|
||||
assert.equal(blattAusAppwrite(ws({ details: 'a'.repeat(300) }), {}).text.length, 120)
|
||||
})
|
||||
|
||||
test('blattAusAppwrite: Kommentar, Abo-Zeit, 0 Minuten und schon Abgerechnetes sind null', () => {
|
||||
assert.equal(blattAusAppwrite(ws({ isComment: true }), {}), null)
|
||||
assert.equal(blattAusAppwrite(ws({ countsToPlan: true }), {}), null)
|
||||
assert.equal(blattAusAppwrite(ws({ totalTime: 0 }), {}), null)
|
||||
assert.equal(blattAusAppwrite(ws(), { w1: 'WEBKLAR-0009' }), null)
|
||||
assert.equal(blattAusAppwrite(ws(), { w1: 'Entwurf' }), null)
|
||||
assert.equal(blattAusAppwrite({}, {}), null)
|
||||
})
|
||||
|
||||
test('ticketOptionen: Tickets des Kunden mit Nummer, ohne die schon gewählten', () => {
|
||||
const T = [
|
||||
{ $id: 't1', woid: '100131', topic: 'Relaunch' },
|
||||
{ $id: 't2', woid: '100140', topic: 'Kontaktformular' },
|
||||
{ $id: 't3', woid: '', topic: '', title: 'Alt' },
|
||||
{ $id: 't4', woid: '100150', topic: '', title: '' },
|
||||
]
|
||||
assert.deepEqual(ticketOptionen(T, { ausgeschlossen: ['t1'] }), [
|
||||
{ value: 't2', ticketId: 't2', woid: '100140', label: '#100140 · Kontaktformular' },
|
||||
{ value: 't4', ticketId: 't4', woid: '100150', label: '#100150 · Kein Betreff' },
|
||||
])
|
||||
assert.deepEqual(ticketOptionen(undefined), [])
|
||||
})
|
||||
Reference in new issue
Block a user