Stundenkonto und Vorschuss am Einrichtungs-Ticket und beim Kunden
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f0d39f2eee
commit
dce7c3e53a
7 files changed
+154
-2
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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`)
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user