diff --git a/src/lib/projektkontoView.js b/src/lib/projektkontoView.js new file mode 100644 index 0000000..b882ae9 --- /dev/null +++ b/src/lib/projektkontoView.js @@ -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 +} diff --git a/tests/unit/projektkontoView.test.mjs b/tests/unit/projektkontoView.test.mjs new file mode 100644 index 0000000..4eb367e --- /dev/null +++ b/tests/unit/projektkontoView.test.mjs @@ -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) +})