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({}), '')
+})