Files
tickte-system/src/components/credits/CreditDetail.jsx
T
ClaudeandClaude Sonnet 5.5 b369a0edf9 Design P6: Gutschrift-Detail zweispaltig mit Abschnitten und kv-Listen, Verhalten unveraendert
Neuer Zustand busyAction (Speichert … / Sendet …) neben busy; Formularbloecke der Berechnung stehen in .form-stack (16 px) statt auf dem Altabstand .page .field.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:36:45 +00:00

304 lines
15 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react'
import { Card, Badge, Button, Field } from '../ui'
import KundenansichtButton from '../customer/KundenansichtButton'
import { applyCredit, calcCredit, decideCredit, getCredit } from '../../lib/teamApi'
import { applyMessage, BILLING_MONTHS, calcLine, checkGlyph, checkTone, creditStatus, decisionConfirm, decisionError, decisionMessage, fmtBytes, historyLabel } from '../../lib/creditFormat'
import { fmtDate, fmtDateTime, formatCents, productLabelOf } from '../../lib/format'
import { storageLabel } from '../../lib/storageFormat'
const staffOf = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${by}` : '')
const toCents = (value) => Math.round(Number(value) * 100)
/**
* Antrag auf Uebernahmegutschrift: Pruefung und Entscheidung wie im frueheren Admin-Panel.
* Waehrend einer Entscheidung sind alle Knoepfe gesperrt (busyRef verhindert auch Doppelklicks vor dem Neuzeichnen).
*/
export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
const [d, setD] = useState(null)
const [error, setError] = useState(null)
const [message, setMessage] = useState('')
const [form, setForm] = useState(null)
const [calcText, setCalcText] = useState('')
const [actionError, setActionError] = useState('')
const [busy, setBusy] = useState(false)
const [busyAction, setBusyAction] = useState('')
const busyRef = useRef(false)
const amountTouched = useRef(false)
const calcTimer = useRef(null)
const load = useCallback(async (msg = '') => {
setError(null)
try {
const detail = await getCredit(id)
const r = detail.request
setD(detail)
setMessage(msg)
setActionError('')
amountTouched.current = false
setForm({
checks: Object.fromEntries(detail.requiredChecks.map((k) => [k.key, false])),
invoiceAmount: (r.invoiceAmountCents / 100).toFixed(2),
billingMonths: r.billingMonths,
paidUntil: r.paidUntilDate || '',
takeoverFrom: r.takeoverFromDate || '',
amount: ((detail.calc?.suggestedCents || 0) / 100).toFixed(2),
note: '',
})
setCalcText(detail.calc ? calcLine(detail.calc) : 'Angaben unvollständig')
} catch (err) {
setError(err.message || 'Antrag konnte nicht geladen werden.')
}
}, [id])
useEffect(() => {
setD(null)
load()
return () => clearTimeout(calcTimer.current)
}, [load])
const figures = (f) => ({ invoiceAmountCents: toCents(f.invoiceAmount), billingMonths: Number(f.billingMonths), paidUntil: f.paidUntil, takeoverFrom: f.takeoverFrom })
const setFigure = (key, value) => {
const next = { ...form, [key]: value }
setForm(next)
clearTimeout(calcTimer.current)
calcTimer.current = setTimeout(async () => {
try {
const { calc } = await calcCredit(figures(next))
setCalcText(calcLine(calc))
if (!amountTouched.current) setForm((f) => ({ ...f, amount: (calc.suggestedCents / 100).toFixed(2) }))
} catch (err) {
setCalcText(err.message)
}
}, 300)
}
const decide = async (action) => {
if (busyRef.current) return
setActionError('')
const problem = decisionError(action, form.note)
if (problem) { setActionError(problem); return }
const amountCents = toCents(form.amount)
if (!window.confirm(decisionConfirm(action, amountCents, d.customer?.name))) return
if (busyRef.current) return
busyRef.current = true
setBusy(true)
setBusyAction(action)
try {
const result = await decideCredit(id, { action, note: form.note.trim(), amountCents, checks: form.checks, ...figures(form) })
onChanged?.()
await load(decisionMessage(action, result))
} catch (err) {
setActionError(err.message)
} finally {
busyRef.current = false
setBusy(false)
setBusyAction('')
}
}
const apply = async () => {
if (busyRef.current) return
busyRef.current = true
setBusy(true)
setBusyAction('apply')
try {
const result = await applyCredit(id)
onChanged?.()
await load(applyMessage(result))
} catch (err) {
setActionError(err.message)
} finally {
busyRef.current = false
setBusy(false)
setBusyAction('')
}
}
if (error) return <Card><p className="msg err mb-2" role="alert">{error}</p><Button onClick={onClose}>Schließen</Button></Card>
if (!d || !form) return <div className="loading">Wird geladen …</div>
const r = d.request
const c = d.customer
const s = d.subscription
const st = creditStatus(r.status)
const pending = r.status === 'offen' || r.status === 'rueckfrage'
const fileNames = new Map(r.files.map((f) => [f.id, f.name]))
const allChecked = Object.values(form.checks).every(Boolean)
const canApprove = allChecked && Number(form.amount) > 0 && !busy
const errorLine = actionError ? <p className="msg err" role="alert">{actionError}</p> : null
return (
<Card title={`Antrag ${r.ref} · ${c.name || c.companyName}`} actions={<Button onClick={onClose}>Schließen</Button>}>
<p className="muted">eingegangen {fmtDateTime(r.createdAt)} <Badge tone={st.tone}>{st.label}</Badge></p>
{message && <p className="msg ok" role="status">{message}</p>}
<div className="two-col">
<div>
<Card title="Antrag">
<dl className="kv">
<dt>Leistung</dt><dd>{r.services}</dd>
<dt>Bisher bei</dt><dd>{r.provider}</dd>
<dt>Domain</dt><dd>{r.domain ? <a className="link" href={`https://${r.domain}`} target="_blank" rel="noreferrer">{r.domain}</a> : '–'}</dd>
<dt>Rechnung</dt><dd>{formatCents(r.invoiceAmountCents)} für {r.billingMonths} Monat{r.billingMonths === 1 ? '' : 'e'}</dd>
<dt>Bezahlt bis</dt><dd>{fmtDate(r.paidUntil)}</dd>
<dt>Antwort an</dt><dd>{r.email}</dd>
<dt>Bestätigt</dt><dd>Angaben und Bedingungen (Stand {r.conditionsVersion || '–'}) am {fmtDateTime(r.consentAt)}</dd>
</dl>
</Card>
<Card title="Grund / Nachricht">
<p className="prose">{r.message}</p>
</Card>
<Card title="Nachweise">
{r.files.length ? (
r.files.map((f) => (
<div key={f.id} className="list-row">
<div className="list-row-text">
<a className="link" href={f.url} target="_blank" rel="noreferrer">{f.name}</a> <span className="muted">({fmtBytes(f.size)}, {fmtDate(f.uploadedAt)})</span>
</div>
</div>
))
) : <p className="muted">Keine Nachweise (mehr) vorhanden.</p>}
</Card>
<Card title="Verlauf">
{r.history.map((h, i) => (
<div key={i} className="list-row">
<div className="list-row-text">
<strong>{historyLabel(h.type)}</strong> <span className="muted">{fmtDateTime(h.at)}{staffOf(h.by)}</span>
{h.text && <p className="prose">{h.text}</p>}
{(h.files || []).length > 0 && <div className="cell-sub">Dateien: {h.files.map((fid) => fileNames.get(fid) || 'gelöscht').join(', ')}</div>}
</div>
</div>
))}
</Card>
</div>
<div>
<Card title="Kunde & Vertrag">
<dl className="kv">
<dt>Kunde</dt><dd>{c.name || c.companyName}{c.code ? ` · Kd.-Nr. ${c.code}` : ''}</dd>
<dt>E-Mail</dt><dd>{c.email || '–'}</dd>
<dt>Abo</dt>
<dd>
{s ? <>{productLabelOf(s.productKey)} <Badge tone={s.active ? 'ok' : 'danger'}>{s.status}</Badge><div className="cell-sub">Zusatzspeicher: {storageLabel(s.storageUnits)}</div></> : <Badge tone="danger">kein Abo</Badge>}
</dd>
{s && (
<>
<dt>Leistungsbeginn</dt><dd>{fmtDate(s.serviceStart)}</dd>
<dt>Nächste Abbuchung</dt><dd>{s.active ? fmtDate(s.currentPeriodEnd) : '–'}</dd>
{s.cancelAt && <><dt>Gekündigt zum</dt><dd>{fmtDate(s.cancelAt)}</dd></>}
<dt>Stripe-Guthaben</dt><dd>{s.creditBalanceCents == null ? '–' : formatCents(s.creditBalanceCents)}</dd>
</>
)}
</dl>
<div className="toolbar">
<KundenansichtButton customer={{ $id: c.id }} checkAccess={false} />
{s?.stripeUrl && <Button as="a" href={s.stripeUrl} target="_blank" rel="noreferrer">In Stripe öffnen</Button>}
</div>
</Card>
{d.otherRequests.length > 0 && (
<Card title="Weitere Anträge des Kunden">
{d.otherRequests.map((o) => {
const ost = creditStatus(o.status)
return (
<div key={o.id} className="list-row">
<div className="list-row-text">
<button type="button" className="link" onClick={() => onOpen?.(o.id)}>{o.ref}</button> · {o.services} · {fmtDate(o.createdAt)} <Badge tone={ost.tone}>{ost.label}</Badge>{o.amountCents ? ` ${formatCents(o.amountCents)}` : ''}
</div>
</div>
)
})}
</Card>
)}
<Card title="Automatische Prüfung">
{d.autoChecks.map((k) => (
<div key={k.key} className="list-row">
<div className="list-row-text">
<Badge tone={checkTone(k.status)}>{checkGlyph(k.status)}</Badge> {k.label}{k.detail && <div className="cell-sub">{k.detail}</div>}
</div>
</div>
))}
</Card>
{pending ? (
<>
<Card title="Prüfung durch dich">
<div className="field">
{d.requiredChecks.map((k) => (
<label key={k.key} className="check">
<input type="checkbox" checked={form.checks[k.key]} onChange={(e) => setForm({ ...form, checks: { ...form.checks, [k.key]: e.target.checked } })} /> {k.label}
</label>
))}
</div>
</Card>
<Card title="Berechnung">
<div className="form-stack">
<div className="field-row">
<Field label="Rechnungsbetrag (€)" htmlFor="calc-amount">
<input id="calc-amount" className="input" type="number" step="0.01" min="0.01" value={form.invoiceAmount} onChange={(e) => setFigure('invoiceAmount', e.target.value)} />
</Field>
<Field label="Rechnung gilt für" htmlFor="calc-months">
<select id="calc-months" className="select" value={form.billingMonths} onChange={(e) => setFigure('billingMonths', Number(e.target.value))}>
{BILLING_MONTHS.map((m) => <option key={m} value={m}>{m} Monat{m === 1 ? '' : 'e'}</option>)}
</select>
</Field>
</div>
<div className="field-row">
<Field label="Bezahlt bis" htmlFor="calc-paid-until">
<input id="calc-paid-until" className="input" type="date" value={form.paidUntil} onChange={(e) => setFigure('paidUntil', e.target.value)} />
</Field>
<Field label="Übernahme ab" htmlFor="calc-from">
<input id="calc-from" className="input" type="date" value={form.takeoverFrom} onChange={(e) => setFigure('takeoverFrom', e.target.value)} />
</Field>
</div>
<div>
<p className="strong">{calcText}</p>
<p className="muted small">Werte weichen von der Rechnung ab? Hier korrigieren – die Korrektur wird beim Genehmigen im Verlauf vermerkt. „Übernahme ab“ ist der Leistungsbeginn des Abos.</p>
</div>
<Field label="Gutschrift (€)" htmlFor="credit-amount">
<input id="credit-amount" className="input" type="number" step="0.01" min="0.01" value={form.amount}
onChange={(e) => { amountTouched.current = true; setForm({ ...form, amount: e.target.value }) }} />
</Field>
<Field label="Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)" htmlFor="credit-note">
<textarea id="credit-note" className="textarea" maxLength={4000} rows={4} value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
</Field>
<div className="flex items-center gap-3 wrap">
<Button variant="primary" onClick={() => decide('approve')} disabled={!canApprove}>{busyAction === 'approve' ? 'Speichert …' : 'Genehmigen & gutschreiben'}</Button>
<Button onClick={() => decide('question')} disabled={busy}>{busyAction === 'question' ? 'Sendet …' : 'Rückfrage senden'}</Button>
<Button variant="danger" onClick={() => decide('reject')} disabled={busy}>{busyAction === 'reject' ? 'Speichert …' : 'Ablehnen'}</Button>
</div>
<p className="muted small">
Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.
{!s?.active && <strong> Achtung: Der Kunde hat kein laufendes Abo.</strong>}
</p>
{errorLine}
</div>
</Card>
</>
) : (
<Card title="Entscheidung">
{r.status === 'genehmigt' ? (
<>
<dl className="kv">
<dt>Genehmigt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
<dt>Gutschrift</dt><dd><strong>{formatCents(r.amountCents)}</strong></dd>
<dt>Berechnung</dt><dd>{r.calculation}</dd>
<dt>Geprüft</dt><dd>{r.checks ? `alle Punkte abgehakt (${r.checks.by || ''})` : '–'}</dd>
{r.decisionNote && <><dt>Nachricht</dt><dd className="prose">{r.decisionNote}</dd></>}
<dt>Verrechnung</dt>
<dd>{r.appliedAt ? <>Stripe-Guthaben gebucht am {fmtDateTime(r.appliedAt)} <span className="muted">({r.stripeBalanceTransactionId})</span></> : <Badge tone="warn">noch nicht in Stripe gebucht</Badge>}</dd>
</dl>
{!r.appliedAt && <div className="toolbar"><Button variant="primary" onClick={apply} disabled={busy}>{busyAction === 'apply' ? 'Speichert …' : 'Jetzt in Stripe gutschreiben'}</Button></div>}
</>
) : (
<dl className="kv">
<dt>Abgelehnt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
<dt>Begründung</dt><dd className="prose">{r.decisionNote}</dd>
</dl>
)}
{errorLine}
</Card>
)}
</div>
</div>
</Card>
)
}