From dce7c3e53a6e6249dedcb0b9d9a7125aba006312 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:45:39 +0000 Subject: [PATCH] Stundenkonto und Vorschuss am Einrichtungs-Ticket und beim Kunden Co-Authored-By: Claude Sonnet 5.5 --- src/components/customer/AboStatusCard.jsx | 7 ++- .../customer/StundenkontoLeiste.jsx | 18 +++++++ src/hooks/useStundenkonto.js | 29 +++++++++++ src/lib/stundenkontoView.js | 51 +++++++++++++++++++ src/lib/teamApi.js | 5 ++ src/pages/TicketPage.jsx | 17 ++++++- tests/unit/stundenkontoView.test.mjs | 29 +++++++++++ 7 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/components/customer/StundenkontoLeiste.jsx create mode 100644 src/hooks/useStundenkonto.js create mode 100644 src/lib/stundenkontoView.js create mode 100644 tests/unit/stundenkontoView.test.mjs diff --git a/src/components/customer/AboStatusCard.jsx b/src/components/customer/AboStatusCard.jsx index a4b6328..c8274aa 100644 --- a/src/components/customer/AboStatusCard.jsx +++ b/src/components/customer/AboStatusCard.jsx @@ -3,6 +3,7 @@ import { Card, Badge } from '../ui' import { getCustomerAboStatus } from '../../lib/teamApi' import { fmtDate, productLabelOf } from '../../lib/format' import { storageLabel } from '../../lib/storageFormat' +import StundenkontoLeiste from './StundenkontoLeiste' // Abo-Status eines Kunden (Reiter "Abo & Angebote"): Appwrite-Spiegel des Stripe-Abos, fuer alle Mitarbeiter export default function AboStatusCard({ customerId }) { @@ -19,13 +20,16 @@ export default function AboStatusCard({ customerId }) { return () => { alive = false } }, [customerId]) + // Nur ein aktives Wartungs-Abo hat ein Stundenkonto (das Hosting-Abo nicht) + const mitKonto = Boolean(abo && abo.productKey === 'wartung' && abo.active) + return ( {error &&
{error}
} {!abo && !error &&
Wird geladen …
} {abo && !abo.status &&

kein Abo

} {abo && abo.status && ( -
+
Abo
{productLabelOf(abo.productKey)} @@ -37,6 +41,7 @@ export default function AboStatusCard({ customerId }) {
Zusatzspeicher
{storageLabel(abo.storageUnits)}
)} + {mitKonto && } ) } diff --git a/src/components/customer/StundenkontoLeiste.jsx b/src/components/customer/StundenkontoLeiste.jsx new file mode 100644 index 0000000..b27fed7 --- /dev/null +++ b/src/components/customer/StundenkontoLeiste.jsx @@ -0,0 +1,18 @@ +import { Stat } from '../ui' +import { useStundenkonto } from '../../hooks/useStundenkonto' +import { stundenkontoAnsicht } from '../../lib/stundenkontoView' + +// Stundenkonto eines Kunden als Kennzahlen: Guthaben, Vorschuss für die Einrichtung, Mehraufwand, nächster Verfall. +// Hosting-Abo und fehlendes Abo bekommen nur den Hinweis. Ohne brauchbare Antwort (Fehler, altes Portal) steht nichts da. +export default function StundenkontoLeiste({ customerId, reloadKey = '' }) { + const { antwort, loading } = useStundenkonto(customerId, { reloadKey }) + if (loading) return

Wird geladen …

+ const ansicht = stundenkontoAnsicht(antwort) + if (!ansicht) return null + if (ansicht.art !== 'konto') return

{ansicht.hinweis}

+ return ( +
+ {ansicht.eintraege.map((e) => )} +
+ ) +} diff --git a/src/hooks/useStundenkonto.js b/src/hooks/useStundenkonto.js new file mode 100644 index 0000000..9f596f6 --- /dev/null +++ b/src/hooks/useStundenkonto.js @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react' +import { getCustomerStundenkonto } from '../lib/teamApi' + +// { antwort, loading }: Stundenkonto eines Kunden (V8), neu geladen, wenn sich customerId oder reloadKey ändern +// (z. B. nach einem neuen Arbeitsblatt). Fehler, auch 404 eines älteren Portals, bleiben still: antwort ist dann null +// und nur die Konsole bekommt eine Warnung. Eine Antwort für einen früheren Schlüssel wird verworfen und nie gezeigt. +// enabled=false: noch nichts laden. +export function useStundenkonto(customerId, { enabled = true, reloadKey = '' } = {}) { + const key = `${customerId}|${reloadKey}` + const [state, setState] = useState({ key: null, antwort: null }) + + useEffect(() => { + if (!customerId || !enabled) return undefined + let cancelled = false + getCustomerStundenkonto(customerId) + .then((antwort) => { if (!cancelled) setState({ key, antwort }) }) + .catch((err) => { + console.warn('Stundenkonto des Kunden konnte nicht geladen werden:', err?.message || err) + if (!cancelled) setState({ key, antwort: null }) + }) + return () => { cancelled = true } + }, [customerId, enabled, key]) + + const aktuell = state.key === key + return { + antwort: aktuell ? state.antwort : null, + loading: Boolean(customerId) && enabled && !aktuell, + } +} diff --git a/src/lib/stundenkontoView.js b/src/lib/stundenkontoView.js new file mode 100644 index 0000000..d003096 --- /dev/null +++ b/src/lib/stundenkontoView.js @@ -0,0 +1,51 @@ +// Anzeige des Stundenkontos eines Kunden (V8) und der Abrechnung einer Einrichtung (V9). Nur Aufbereitung von Text, +// keine Abrufe: die Antwort von GET /api/team/customers/:id/stundenkonto kommt von außen. Alles Unbrauchbare wird still ausgelassen. +import { stepLabel } from './timeSteps' +import { fmtDate } from './format' +import { einrichtungAbrechnung } from './planVorgabe' + +const istObjekt = (x) => x !== null && typeof x === 'object' && !Array.isArray(x) +// Ganze Minuten, nie negativ: Text, NaN, Unendlich und fehlende Werte gelten als 0 +const minuten = (x) => { + const n = Math.round(Number(x)) + return Number.isFinite(n) && n > 0 ? n : 0 +} +const lesbar = (at) => typeof at === 'string' && at !== '' && !Number.isNaN(new Date(at).getTime()) + +const HINWEIS_HOSTING = 'Hosting-Abo: kein Stundenkonto.' +const HINWEIS_KEINS = 'Kein Abo: kein Stundenkonto.' + +// null (keine brauchbare Antwort) | { art: 'konto'|'hosting'|'keins', hinweis, eintraege: [{ key, label, wert, sub, ton }] } +// Reihenfolge: Guthaben, Vorschuss (nur > 0), Mehraufwand (nur > 0), Verfall (nur mit Minuten und lesbarem Datum). +export function stundenkontoAnsicht(antwort) { + if (!istObjekt(antwort)) return null + const ledger = antwort.ledger + if (!istObjekt(ledger)) { + if (antwort.produkt === 'hosting') return { art: 'hosting', hinweis: HINWEIS_HOSTING, eintraege: [] } + // Ein Wartungs-Abo ohne Konto widerspricht dem Vertrag: lieber nichts zeigen als "Kein Abo" zu behaupten + if (antwort.produkt === 'wartung') return null + return { art: 'keins', hinweis: HINWEIS_KEINS, eintraege: [] } + } + const eintraege = [{ key: 'guthaben', label: 'Guthaben', wert: stepLabel(minuten(ledger.balanceMinutes)), sub: '', ton: '' }] + const vorschuss = minuten(ledger.vorschussMinutes) + if (vorschuss > 0) { + eintraege.push({ key: 'vorschuss', label: 'Vorschuss (Einrichtung)', wert: stepLabel(vorschuss), sub: 'wird mit den nächsten Gutschriften verrechnet', ton: 'warn' }) + } + const mehraufwand = minuten(ledger.overageMinutes) + if (mehraufwand > 0) { + eintraege.push({ key: 'mehraufwand', label: 'Mehraufwand', wert: stepLabel(mehraufwand), sub: '', ton: 'warn' }) + } + const verfall = istObjekt(ledger.nextExpiry) ? ledger.nextExpiry : null + if (verfall && minuten(verfall.minutes) > 0 && lesbar(verfall.at)) { + eintraege.push({ key: 'verfall', label: 'Nächster Verfall', wert: stepLabel(minuten(verfall.minutes)), sub: `am ${fmtDate(verfall.at)}`, ton: '' }) + } + return { art: 'konto', hinweis: '', eintraege } +} + +// Satz zur Abrechnung der Einrichtung an einem Einrichtungs-Ticket; '' ohne Marker am Ticket +export function einrichtungText(ticket) { + const art = einrichtungAbrechnung(ticket) + if (art === 'abo') return 'Einrichtung aus dem Abo: Die Arbeitszeit geht aufs Stundenkonto des Kunden, auch wenn das Guthaben nicht reicht.' + if (art === 'projekt') return 'Einrichtung im Projekt enthalten: Die Arbeitszeit zählt nicht aufs Abo-Stundenkonto.' + return '' +} diff --git a/src/lib/teamApi.js b/src/lib/teamApi.js index e8cc91a..1e9ea2f 100644 --- a/src/lib/teamApi.js +++ b/src/lib/teamApi.js @@ -136,3 +136,8 @@ export const getAbgerechnet = (ticketId) => request(abgerechnetPfad(ticketId)) /** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */ export const getCustomerAboStatus = (customerId) => request(`/customers/${enc(customerId)}/abo`) + +// --- Stundenkonto (alle Mitarbeiter) --- + +/** { produkt: 'hosting' | 'wartung' | null, ledger: { balanceMinutes, vorschussMinutes, overageMinutes, nextExpiry: { minutes, at } | null } | null } (Aufbereitung: stundenkontoView) */ +export const getCustomerStundenkonto = (customerId) => request(`/customers/${enc(customerId)}/stundenkonto`) diff --git a/src/pages/TicketPage.jsx b/src/pages/TicketPage.jsx index 26fe539..0df6f9a 100644 --- a/src/pages/TicketPage.jsx +++ b/src/pages/TicketPage.jsx @@ -4,6 +4,7 @@ import { PageHeader, Tabs, Card, EmptyState, Button } from '../components/ui' import UrgentToggle from '../components/UrgentToggle' import WorksheetSummary from '../components/WorksheetSummary' import WorksheetList from '../components/WorksheetList' +import StundenkontoLeiste from '../components/customer/StundenkontoLeiste' import ProjectContextPanel from '../components/ProjectContextPanel' import TicketProjects from '../components/TicketProjects' import CreateWorksheetModal from '../components/CreateWorksheetModal' @@ -18,7 +19,9 @@ import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite' import { elapsedLabel } from '../lib/elapsed' import { canAddOwnSupport } from '../lib/support' import { canEditWorksheetTimes, serviceTypeLabel } from '../lib/ticketForm' -import { nachtragPlanFelder } from '../lib/planVorgabe' +import { einrichtungAbrechnung, nachtragPlanFelder } from '../lib/planVorgabe' +import { einrichtungText } from '../lib/stundenkontoView' +import { worksheetSums } from '../lib/worksheetView' import { listBackPath, resolveTicketTab, ticketPath, ticketRef } from '../lib/ticketRoutes' import { statusSelectOptions, ticketTypeLabel } from '../lib/ticketStatus' import { appointmentText, assigneeOptions, dueText, ticketNumber } from '../lib/ticketView' @@ -112,6 +115,9 @@ function TicketView({ ticket, updateWorkorder, tab }) { tab === 'arbeitsblaetter' || showCreateWorksheet ) + // Einrichtungs-Ticket: 'abo' | 'projekt' | '' (V9); bei 'abo' zeigt die Seite das Stundenkonto des Kunden + const einrichtung = einrichtungAbrechnung(ticket) + const isPending = (field) => pending[field] !== undefined const shown = (field, current) => (isPending(field) ? pending[field] : current) @@ -254,6 +260,15 @@ function TicketView({ ticket, updateWorkorder, tab }) { onChange={(id) => { if (id !== tab) goToTab(id) }} /> + {einrichtung && ( + <> +

{einrichtungText(ticket)}

+ {einrichtung === 'abo' && ( + + )} + + )} + {tab === 'details' && ( <> diff --git a/tests/unit/stundenkontoView.test.mjs b/tests/unit/stundenkontoView.test.mjs new file mode 100644 index 0000000..dce57c5 --- /dev/null +++ b/tests/unit/stundenkontoView.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' +const { stundenkontoAnsicht, einrichtungText } = await loadModule('src/lib/stundenkontoView.js') + +test('Vorschuss ohne Guthaben (Beispiel der Spec: 2,5 Stunden)', () => { + assert.deepEqual(stundenkontoAnsicht({ produkt: 'wartung', ledger: { balanceMinutes: 0, vorschussMinutes: 150, overageMinutes: 30, nextExpiry: null } }), { + art: 'konto', hinweis: '', eintraege: [ + { key: 'guthaben', label: 'Guthaben', wert: '0 min', sub: '', ton: '' }, + { key: 'vorschuss', label: 'Vorschuss (Einrichtung)', wert: '2 h 30 min', sub: 'wird mit den nächsten Gutschriften verrechnet', ton: 'warn' }, + { key: 'mehraufwand', label: 'Mehraufwand', wert: '30 min', sub: '', ton: 'warn' }, + ] }) +}) +test('Guthaben mit Verfall; Vorschuss und Mehraufwand fehlen bei 0', () => { + const a = stundenkontoAnsicht({ produkt: 'wartung', ledger: { balanceMinutes: 240, vorschussMinutes: 0, overageMinutes: 0, nextExpiry: { minutes: 60, at: '2027-08-01T12:00:00.000Z' } } }) + assert.deepEqual(a.eintraege.map((e) => [e.key, e.wert, e.sub]), [['guthaben', '4 h', ''], ['verfall', '1 h', 'am 01.08.2027']]) +}) +test('Hosting, kein Abo, unbrauchbare Antworten und Minuten', () => { + assert.deepEqual(stundenkontoAnsicht({ produkt: 'hosting', ledger: null }), { art: 'hosting', hinweis: 'Hosting-Abo: kein Stundenkonto.', eintraege: [] }) + assert.deepEqual(stundenkontoAnsicht({ produkt: null, ledger: null }), { art: 'keins', hinweis: 'Kein Abo: kein Stundenkonto.', eintraege: [] }) + for (const x of [null, undefined, 'x', 5, []]) assert.equal(stundenkontoAnsicht(x), null) + const k = stundenkontoAnsicht({ produkt: 'wartung', ledger: { balanceMinutes: 85, vorschussMinutes: -5, overageMinutes: 'abc', nextExpiry: { minutes: 0, at: 'kaputt' } } }) + assert.deepEqual(k.eintraege.map((e) => [e.key, e.wert]), [['guthaben', '1 h 25 min']]) +}) +test('einrichtungText', () => { + assert.equal(einrichtungText({ einrichtungAbrechnung: 'abo' }), 'Einrichtung aus dem Abo: Die Arbeitszeit geht aufs Stundenkonto des Kunden, auch wenn das Guthaben nicht reicht.') + assert.equal(einrichtungText({ einrichtungAbrechnung: 'projekt' }), 'Einrichtung im Projekt enthalten: Die Arbeitszeit zählt nicht aufs Abo-Stundenkonto.') + assert.equal(einrichtungText({}), '') +})