Stundenkonto und Vorschuss am Einrichtungs-Ticket und beim Kunden

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 13:45:39 +00:00
1 parent f0d39f2eee
commit dce7c3e53a
7 files changed
+154 -2

No files matched your search

+6 -1
View File
@@ -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 (
<Card title="Abo">
{error && <div className="msg err" role="alert">{error}</div>}
{!abo && !error && <div className="loading">Wird geladen …</div>}
{abo && !abo.status && <p className="muted">kein Abo</p>}
{abo && abo.status && (
<dl className="kv">
<dl className={mitKonto ? 'kv mb-2' : 'kv'}>
<dt>Abo</dt>
<dd className="flex items-center gap-2 wrap">
{productLabelOf(abo.productKey)}
@@ -37,6 +41,7 @@ export default function AboStatusCard({ customerId }) {
<dt>Zusatzspeicher</dt><dd>{storageLabel(abo.storageUnits)}</dd>
</dl>
)}
{mitKonto && <StundenkontoLeiste customerId={customerId} />}
</Card>
)
}
@@ -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 <p className="muted">Wird geladen …</p>
const ansicht = stundenkontoAnsicht(antwort)
if (!ansicht) return null
if (ansicht.art !== 'konto') return <p className="muted">{ansicht.hinweis}</p>
return (
<div className="stats">
{ansicht.eintraege.map((e) => <Stat key={e.key} label={e.label} value={e.wert} sub={e.sub} tone={e.ton} />)}
</div>
)
}
+29
View File
@@ -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,
}
}
+51
View File
@@ -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 ''
}
+5
View File
@@ -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`)
+16 -1
View File
@@ -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 && (
<>
<p className="muted mb-2">{einrichtungText(ticket)}</p>
{einrichtung === 'abo' && (
<StundenkontoLeiste customerId={ticket.customerId} reloadKey={`${worksheets.length}:${worksheetSums(worksheets).aboMinutes}`} />
)}
</>
)}
{tab === 'details' && (
<>
<Card title="Ticket-Beschreibung">
+29
View File
@@ -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({}), '')
})