Der Server (rechnungsbau.js, baueAbRechnung) nimmt Anzahlung und Schlussrechnung ohne Projekt an. Unter 'Ohne Projekt' lädt der Dialog die ABs des Kunden ohne Projekt und ihre Rechnungen; frei bleibt die Vorgabe. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
255 lines
12 KiB
JavaScript
255 lines
12 KiB
JavaScript
// 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 { INVOICE_LIMITS } from './financeView'
|
||
import { steuernummerFehlt } from './kundenFelder'
|
||
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'
|
||
export const STRIPE_UNBEKANNT = 'Stripe nicht erreichbar – Beträge unbekannt'
|
||
export const STEUERNUMMER_RECHNUNG_HINWEIS =
|
||
'Die Steuernummer fehlt noch – sie gehört auf jede Rechnung (§ 34a UStDV). Eintragen unter Finanzen → Einstellungen.'
|
||
|
||
/** Hinweis im Rechnungsdialog, solange die Steuernummer fehlt; ohne Einstellungen (lädt, Ladefehler) kein Hinweis. */
|
||
export function steuernummerHinweis(einstellungen) {
|
||
return steuernummerFehlt(einstellungen) ? STEUERNUMMER_RECHNUNG_HINWEIS : ''
|
||
}
|
||
|
||
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')
|
||
}
|
||
|
||
/** Zeile unter der AB-Wahl. Ist Stripe nicht erreichbar, sind die berechneten Beträge unbekannt (nicht 0,00 €). */
|
||
export function abSummenText({ ab, rechnungen, stripeOk }) {
|
||
const summe = `Auftragssumme ${euro(ab?.summeCents)}`
|
||
if (stripeOk === false) return `${summe} · ${STRIPE_UNBEKANNT}`
|
||
const berechnet = berechnetFuerAuftrag(rechnungen, ab?.id)
|
||
return `${summe} · bereits berechnet ${euro(berechnet)} · noch nicht berechnet ${euro(Math.max(0, (Number(ab?.summeCents) || 0) - berechnet))}`
|
||
}
|
||
|
||
/**
|
||
* Aufträge und Rechnungen für „Ohne Projekt“ aus der Auftragsliste des Kunden ({ auftraege }) und seiner Rechnungsliste
|
||
* ({ rechnungen }): nur ABs ohne Projekt und die Rechnungen, die zu ihnen gehören. -> { auftraege, rechnungen }
|
||
*/
|
||
export function ohneProjektAbDaten(auftragsAntwort, rechnungsAntwort) {
|
||
const abs = Array.isArray(auftragsAntwort?.auftraege) ? auftragsAntwort.auftraege.filter((a) => a && !a.projektId) : []
|
||
const ids = new Set(abs.map((a) => a.id))
|
||
const alle = Array.isArray(rechnungsAntwort?.rechnungen) ? rechnungsAntwort.rechnungen : []
|
||
return { auftraege: abs, rechnungen: alle.filter((r) => r && r.auftragId && ids.has(r.auftragId)) }
|
||
}
|
||
|
||
// --- 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 === '') {
|
||
// Eine gültige Festpreis-AB ohne Projekt erlaubt Anzahlung und Schlussrechnung (der Server nimmt sie ohne Projekt an)
|
||
const ohne = auftraegeAktiv ? gueltigeFestpreisAbs(Array.isArray(auftraege) ? auftraege.filter((a) => a && !a.projektId) : []) : []
|
||
if (ohne.length === 0) return [option('frei', true)]
|
||
const ohneEntwurf = ohne.filter((a) => !hatEntwurfZurAuftrag(rechnungen, a.id))
|
||
return [option('frei', true), option('anzahlung', true), option('schluss', ohneEntwurf.length > 0, ENTWURF_HINWEIS)]
|
||
}
|
||
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, auch bei Stripe-Ausfall). */
|
||
export function standardArt(optionen, { auftraege, rechnungen, offeneMinuten, stripeOk = true }) {
|
||
const liste = Array.isArray(optionen) ? optionen : []
|
||
if (liste.length === 0) return ''
|
||
if (liste.some((o) => o.key === 'frei' && o.enabled)) return 'frei' // ohne Projekt
|
||
// Ist Stripe nicht erreichbar, sind die berechneten Beträge unbekannt: keine Art vorschlagen
|
||
if (stripeOk === false) return ''
|
||
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 ''
|
||
}
|
||
|
||
/**
|
||
* Vorbelegung „Zahlungsziel in Tagen“: bei Anzahlung und Schlussrechnung das Ziel der gewählten AB, bei Aufwand das der
|
||
* jüngsten gültigen Aufwands-AB (`auftraege`), sonst 14. Nur ganze Zahlen von 0 bis 60 zählen. -> Text
|
||
*/
|
||
export function zahlungszielVorschlag({ art, ab = null, auftraege = [] } = {}) {
|
||
const standard = String(INVOICE_LIMITS.defaultDays)
|
||
let quelle = null
|
||
if (art === 'anzahlung' || art === 'schluss') quelle = ab
|
||
else if (art === 'aufwand') quelle = aufwandAuftrag(auftraege)
|
||
const tage = quelle?.zahlungszielTage
|
||
return Number.isSafeInteger(tage) && tage >= 0 && tage <= INVOICE_LIMITS.maxDays ? String(tage) : standard
|
||
}
|
||
|
||
/** 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'}`,
|
||
}))
|
||
}
|