Files
tickte-system/src/lib/rechnungDialogView.js
T
ClaudeandClaude Sonnet 5.5 4b184f744f Kontrolle: AB ohne Projekt im Rechnungsdialog für Anzahlung und Schluss wählbar
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>
2026-10-07 15:19:35 +00:00

255 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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'}`,
}))
}