Projektkonto: Anzeigelogik für Zeilen, Rechnungsspalten und Abzeichen „abgerechnet“ mit Tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:29:30 +00:00
1 parent 99ab4bd47b
commit 241f7cdec6
2 files changed
+314

No files matched your search

+154
View File
@@ -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.`
}
/** [{ woid: '100131' }, …] -> '#100131, #100140'; 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
}