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>
This commit is contained in:
1 parent
c96aa870a9
commit
b369a0edf9
2 files changed
+161
-139
No files matched your search
@@ -1,14 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { FaSpinner, FaArrowUpRightFromSquare } from 'react-icons/fa6'
|
import { Card, Badge, Button, Field } from '../ui'
|
||||||
import { Card, Badge, Button } from '../ui'
|
|
||||||
import KundenansichtButton from '../customer/KundenansichtButton'
|
import KundenansichtButton from '../customer/KundenansichtButton'
|
||||||
import { applyCredit, calcCredit, decideCredit, getCredit } from '../../lib/teamApi'
|
import { applyCredit, calcCredit, decideCredit, getCredit } from '../../lib/teamApi'
|
||||||
import { applyMessage, BILLING_MONTHS, calcLine, creditStatus, decisionConfirm, decisionError, decisionMessage, fmtBytes, historyLabel } from '../../lib/creditFormat'
|
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 { fmtDate, fmtDateTime, formatCents, productLabelOf } from '../../lib/format'
|
||||||
import { storageLabel } from '../../lib/storageFormat'
|
import { storageLabel } from '../../lib/storageFormat'
|
||||||
import '../../styles/admin-areas.css'
|
|
||||||
|
|
||||||
const CHECK_TONE = { ok: 'ok', warn: 'warn', info: 'info' }
|
|
||||||
const staffOf = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${by}` : '')
|
const staffOf = (by) => (by && by !== 'customer' && by !== 'system' ? ` · ${by}` : '')
|
||||||
const toCents = (value) => Math.round(Number(value) * 100)
|
const toCents = (value) => Math.round(Number(value) * 100)
|
||||||
|
|
||||||
@@ -24,6 +21,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
|||||||
const [calcText, setCalcText] = useState('')
|
const [calcText, setCalcText] = useState('')
|
||||||
const [actionError, setActionError] = useState('')
|
const [actionError, setActionError] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [busyAction, setBusyAction] = useState('')
|
||||||
const busyRef = useRef(false)
|
const busyRef = useRef(false)
|
||||||
const amountTouched = useRef(false)
|
const amountTouched = useRef(false)
|
||||||
const calcTimer = useRef(null)
|
const calcTimer = useRef(null)
|
||||||
@@ -85,6 +83,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
|||||||
if (busyRef.current) return
|
if (busyRef.current) return
|
||||||
busyRef.current = true
|
busyRef.current = true
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
setBusyAction(action)
|
||||||
try {
|
try {
|
||||||
const result = await decideCredit(id, { action, note: form.note.trim(), amountCents, checks: form.checks, ...figures(form) })
|
const result = await decideCredit(id, { action, note: form.note.trim(), amountCents, checks: form.checks, ...figures(form) })
|
||||||
onChanged?.()
|
onChanged?.()
|
||||||
@@ -94,6 +93,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
|||||||
} finally {
|
} finally {
|
||||||
busyRef.current = false
|
busyRef.current = false
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
|
setBusyAction('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +101,7 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
|||||||
if (busyRef.current) return
|
if (busyRef.current) return
|
||||||
busyRef.current = true
|
busyRef.current = true
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
setBusyAction('apply')
|
||||||
try {
|
try {
|
||||||
const result = await applyCredit(id)
|
const result = await applyCredit(id)
|
||||||
onChanged?.()
|
onChanged?.()
|
||||||
@@ -110,11 +111,12 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
|||||||
} finally {
|
} finally {
|
||||||
busyRef.current = false
|
busyRef.current = false
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
|
setBusyAction('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) return <Card><div className="area-error">{error}</div><Button variant="ghost" onClick={onClose}>Schließen</Button></Card>
|
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 <Card><FaSpinner className="spinner" /></Card>
|
if (!d || !form) return <div className="loading">Wird geladen …</div>
|
||||||
|
|
||||||
const r = d.request
|
const r = d.request
|
||||||
const c = d.customer
|
const c = d.customer
|
||||||
@@ -124,160 +126,176 @@ export default function CreditDetail({ id, onClose, onChanged, onOpen }) {
|
|||||||
const fileNames = new Map(r.files.map((f) => [f.id, f.name]))
|
const fileNames = new Map(r.files.map((f) => [f.id, f.name]))
|
||||||
const allChecked = Object.values(form.checks).every(Boolean)
|
const allChecked = Object.values(form.checks).every(Boolean)
|
||||||
const canApprove = allChecked && Number(form.amount) > 0 && !busy
|
const canApprove = allChecked && Number(form.amount) > 0 && !busy
|
||||||
|
const errorLine = actionError ? <p className="msg err" role="alert">{actionError}</p> : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card title={`Antrag ${r.ref} · ${c.name || c.companyName}`} actions={<Button onClick={onClose}>Schließen</Button>}>
|
||||||
<div className="area-inline" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
|
<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>
|
<div>
|
||||||
<h3 style={{ margin: 0 }}>Antrag {r.ref} · {c.name || c.companyName}</h3>
|
<Card title="Antrag">
|
||||||
<span className="area-muted">eingegangen {fmtDateTime(r.createdAt)}</span> <Badge tone={st.tone}>{st.label}</Badge>
|
<dl className="kv">
|
||||||
</div>
|
<dt>Leistung</dt><dd>{r.services}</dd>
|
||||||
<Button variant="ghost" onClick={onClose}>Schließen</Button>
|
<dt>Bisher bei</dt><dd>{r.provider}</dd>
|
||||||
</div>
|
<dt>Domain</dt><dd>{r.domain ? <a className="link" href={`https://${r.domain}`} target="_blank" rel="noreferrer">{r.domain}</a> : '–'}</dd>
|
||||||
{message && <div className="area-message">{message}</div>}
|
<dt>Rechnung</dt><dd>{formatCents(r.invoiceAmountCents)} für {r.billingMonths} Monat{r.billingMonths === 1 ? '' : 'e'}</dd>
|
||||||
<div className="credit-cols">
|
<dt>Bezahlt bis</dt><dd>{fmtDate(r.paidUntil)}</dd>
|
||||||
<div>
|
<dt>Antwort an</dt><dd>{r.email}</dd>
|
||||||
<h4>Antrag</h4>
|
<dt>Bestätigt</dt><dd>Angaben und Bedingungen (Stand {r.conditionsVersion || '–'}) am {fmtDateTime(r.consentAt)}</dd>
|
||||||
<dl className="area-kv">
|
</dl>
|
||||||
<dt>Leistung</dt><dd>{r.services}</dd>
|
</Card>
|
||||||
<dt>Bisher bei</dt><dd>{r.provider}</dd>
|
<Card title="Grund / Nachricht">
|
||||||
<dt>Domain</dt><dd>{r.domain ? <a href={`https://${r.domain}`} target="_blank" rel="noreferrer">{r.domain}</a> : '–'}</dd>
|
<p className="prose">{r.message}</p>
|
||||||
<dt>Rechnung</dt><dd>{formatCents(r.invoiceAmountCents)} für {r.billingMonths} Monat{r.billingMonths === 1 ? '' : 'e'}</dd>
|
</Card>
|
||||||
<dt>Bezahlt bis</dt><dd>{fmtDate(r.paidUntil)}</dd>
|
<Card title="Nachweise">
|
||||||
<dt>Antwort an</dt><dd>{r.email}</dd>
|
{r.files.length ? (
|
||||||
<dt>Bestätigt</dt><dd>Angaben und Bedingungen (Stand {r.conditionsVersion || '–'}) am {fmtDateTime(r.consentAt)}</dd>
|
r.files.map((f) => (
|
||||||
</dl>
|
<div key={f.id} className="list-row">
|
||||||
<h4>Grund / Nachricht</h4>
|
<div className="list-row-text">
|
||||||
<p style={{ whiteSpace: 'pre-wrap' }}>{r.message}</p>
|
<a className="link" href={f.url} target="_blank" rel="noreferrer">{f.name}</a> <span className="muted">({fmtBytes(f.size)}, {fmtDate(f.uploadedAt)})</span>
|
||||||
<h4>Nachweise</h4>
|
</div>
|
||||||
{r.files.length ? (
|
</div>
|
||||||
<ul className="credit-files">
|
))
|
||||||
{r.files.map((f) => (
|
) : <p className="muted">Keine Nachweise (mehr) vorhanden.</p>}
|
||||||
<li key={f.id}>
|
</Card>
|
||||||
<a href={f.url} target="_blank" rel="noreferrer">{f.name}</a> <span className="area-muted">({fmtBytes(f.size)}, {fmtDate(f.uploadedAt)})</span>
|
<Card title="Verlauf">
|
||||||
</li>
|
{r.history.map((h, i) => (
|
||||||
))}
|
<div key={i} className="list-row">
|
||||||
</ul>
|
<div className="list-row-text">
|
||||||
) : <p className="area-muted">Keine Nachweise (mehr) vorhanden.</p>}
|
<strong>{historyLabel(h.type)}</strong> <span className="muted">{fmtDateTime(h.at)}{staffOf(h.by)}</span>
|
||||||
<h4>Verlauf</h4>
|
{h.text && <p className="prose">{h.text}</p>}
|
||||||
{r.history.map((h, i) => (
|
{(h.files || []).length > 0 && <div className="cell-sub">Dateien: {h.files.map((fid) => fileNames.get(fid) || 'gelöscht').join(', ')}</div>}
|
||||||
<div key={i} className="credit-history">
|
</div>
|
||||||
<strong>{historyLabel(h.type)}</strong> <span className="area-muted">{fmtDateTime(h.at)}{staffOf(h.by)}</span>
|
</div>
|
||||||
{h.text && <div style={{ whiteSpace: 'pre-wrap' }}>{h.text}</div>}
|
|
||||||
{(h.files || []).length > 0 && <div className="area-muted">Dateien: {h.files.map((fid) => fileNames.get(fid) || 'gelöscht').join(', ')}</div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4>Kunde & Vertrag</h4>
|
|
||||||
<dl className="area-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><br /><span className="area-muted">Zusatzspeicher: {storageLabel(s.storageUnits)}</span></> : <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="area-inline" style={{ margin: '12px 0' }}>
|
|
||||||
<KundenansichtButton customer={{ $id: c.id }} checkAccess={false} variant="ghost" />
|
|
||||||
{s?.stripeUrl && <Button as="a" href={s.stripeUrl} target="_blank" rel="noreferrer" variant="ghost"><FaArrowUpRightFromSquare /> In Stripe öffnen</Button>}
|
|
||||||
</div>
|
|
||||||
{d.otherRequests.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h4>Weitere Anträge des Kunden</h4>
|
|
||||||
<ul className="credit-files">
|
|
||||||
{d.otherRequests.map((o) => {
|
|
||||||
const ost = creditStatus(o.status)
|
|
||||||
return (
|
|
||||||
<li key={o.id}>
|
|
||||||
<a href="#" onClick={(e) => { e.preventDefault(); onOpen?.(o.id) }}>{o.ref}</a> · {o.services} · {fmtDate(o.createdAt)} <Badge tone={ost.tone}>{ost.label}</Badge>{o.amountCents ? ` ${formatCents(o.amountCents)}` : ''}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<h4>Automatische Prüfung</h4>
|
|
||||||
<ul className="credit-checks">
|
|
||||||
{d.autoChecks.map((k) => (
|
|
||||||
<li key={k.key}><Badge tone={CHECK_TONE[k.status] || 'muted'}>{k.status === 'ok' ? '✓' : k.status === 'warn' ? '!' : 'i'}</Badge> {k.label}{k.detail && <><br /><small className="area-muted">{k.detail}</small></>}</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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 ? (
|
{pending ? (
|
||||||
<>
|
<>
|
||||||
<h4>Prüfung durch dich</h4>
|
<Card title="Prüfung durch dich">
|
||||||
<div className="credit-manual">
|
<div className="field">
|
||||||
{d.requiredChecks.map((k) => (
|
{d.requiredChecks.map((k) => (
|
||||||
<label key={k.key}>
|
<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}
|
<input type="checkbox" checked={form.checks[k.key]} onChange={(e) => setForm({ ...form, checks: { ...form.checks, [k.key]: e.target.checked } })} /> {k.label}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<h4>Berechnung</h4>
|
</Card>
|
||||||
<div className="credit-calc">
|
<Card title="Berechnung">
|
||||||
<label>Rechnungsbetrag (€)<input id="calc-amount" className="form-control" type="number" step="0.01" min="0.01" value={form.invoiceAmount} onChange={(e) => setFigure('invoiceAmount', e.target.value)} /></label>
|
<div className="form-stack">
|
||||||
<label>Rechnung gilt für
|
<div className="field-row">
|
||||||
<select id="calc-months" className="form-control" value={form.billingMonths} onChange={(e) => setFigure('billingMonths', Number(e.target.value))}>
|
<Field label="Rechnungsbetrag (€)" htmlFor="calc-amount">
|
||||||
{BILLING_MONTHS.map((m) => <option key={m} value={m}>{m} Monat{m === 1 ? '' : 'e'}</option>)}
|
<input id="calc-amount" className="input" type="number" step="0.01" min="0.01" value={form.invoiceAmount} onChange={(e) => setFigure('invoiceAmount', e.target.value)} />
|
||||||
</select>
|
</Field>
|
||||||
</label>
|
<Field label="Rechnung gilt für" htmlFor="calc-months">
|
||||||
<label>Bezahlt bis<input id="calc-paid-until" className="form-control" type="date" value={form.paidUntil} onChange={(e) => setFigure('paidUntil', e.target.value)} /></label>
|
<select id="calc-months" className="select" value={form.billingMonths} onChange={(e) => setFigure('billingMonths', Number(e.target.value))}>
|
||||||
<label>Übernahme ab<input id="calc-from" className="form-control" type="date" value={form.takeoverFrom} onChange={(e) => setFigure('takeoverFrom', e.target.value)} /></label>
|
{BILLING_MONTHS.map((m) => <option key={m} value={m}>{m} Monat{m === 1 ? '' : 'e'}</option>)}
|
||||||
</div>
|
</select>
|
||||||
<div className="area-summary">{calcText}</div>
|
</Field>
|
||||||
<p className="area-muted">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>
|
||||||
<label className="credit-field">Gutschrift (€)
|
<div className="field-row">
|
||||||
<input id="credit-amount" className="form-control" type="number" step="0.01" min="0.01" value={form.amount} style={{ maxWidth: 180 }}
|
<Field label="Bezahlt bis" htmlFor="calc-paid-until">
|
||||||
onChange={(e) => { amountTouched.current = true; setForm({ ...form, amount: e.target.value }) }} />
|
<input id="calc-paid-until" className="input" type="date" value={form.paidUntil} onChange={(e) => setFigure('paidUntil', e.target.value)} />
|
||||||
</label>
|
</Field>
|
||||||
<label className="credit-field">Nachricht an den Kunden (bei Rückfrage und Ablehnung Pflicht, beim Genehmigen optional)
|
<Field label="Übernahme ab" htmlFor="calc-from">
|
||||||
<textarea id="credit-note" className="form-control" maxLength={4000} rows={4} value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
|
<input id="calc-from" className="input" type="date" value={form.takeoverFrom} onChange={(e) => setFigure('takeoverFrom', e.target.value)} />
|
||||||
</label>
|
</Field>
|
||||||
<div className="area-inline">
|
</div>
|
||||||
<Button variant="primary" onClick={() => decide('approve')} disabled={!canApprove}>Genehmigen & gutschreiben</Button>
|
<div>
|
||||||
<Button variant="ghost" onClick={() => decide('question')} disabled={busy}>Rückfrage senden</Button>
|
<p className="strong">{calcText}</p>
|
||||||
<Button variant="danger" onClick={() => decide('reject')} disabled={busy}>Ablehnen</Button>
|
<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>
|
||||||
{busy && <FaSpinner className="spinner" />}
|
</div>
|
||||||
</div>
|
<Field label="Gutschrift (€)" htmlFor="credit-amount">
|
||||||
<p className="area-muted">
|
<input id="credit-amount" className="input" type="number" step="0.01" min="0.01" value={form.amount}
|
||||||
Genehmigen geht, sobald alle Punkte abgehakt sind. Der Kunde bekommt jede Entscheidung per E-Mail.
|
onChange={(e) => { amountTouched.current = true; setForm({ ...form, amount: e.target.value }) }} />
|
||||||
{!s?.active && <strong> Achtung: Der Kunde hat kein laufendes Abo.</strong>}
|
</Field>
|
||||||
</p>
|
<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">
|
||||||
<h4>Entscheidung</h4>
|
|
||||||
{r.status === 'genehmigt' ? (
|
{r.status === 'genehmigt' ? (
|
||||||
<>
|
<>
|
||||||
<dl className="area-kv">
|
<dl className="kv">
|
||||||
<dt>Genehmigt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
|
<dt>Genehmigt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
|
||||||
<dt>Gutschrift</dt><dd><strong>{formatCents(r.amountCents)}</strong></dd>
|
<dt>Gutschrift</dt><dd><strong>{formatCents(r.amountCents)}</strong></dd>
|
||||||
<dt>Berechnung</dt><dd>{r.calculation}</dd>
|
<dt>Berechnung</dt><dd>{r.calculation}</dd>
|
||||||
<dt>Geprüft</dt><dd>{r.checks ? `alle Punkte abgehakt (${r.checks.by || ''})` : '–'}</dd>
|
<dt>Geprüft</dt><dd>{r.checks ? `alle Punkte abgehakt (${r.checks.by || ''})` : '–'}</dd>
|
||||||
{r.decisionNote && <><dt>Nachricht</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{r.decisionNote}</dd></>}
|
{r.decisionNote && <><dt>Nachricht</dt><dd className="prose">{r.decisionNote}</dd></>}
|
||||||
<dt>Verrechnung</dt>
|
<dt>Verrechnung</dt>
|
||||||
<dd>{r.appliedAt ? <>Stripe-Guthaben gebucht am {fmtDateTime(r.appliedAt)} <span className="area-muted">({r.stripeBalanceTransactionId})</span></> : <Badge tone="warn">noch nicht in Stripe gebucht</Badge>}</dd>
|
<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>
|
</dl>
|
||||||
{!r.appliedAt && <div className="area-inline" style={{ marginTop: 12 }}><Button variant="primary" onClick={apply} disabled={busy}>Jetzt in Stripe gutschreiben</Button></div>}
|
{!r.appliedAt && <div className="toolbar"><Button variant="primary" onClick={apply} disabled={busy}>{busyAction === 'apply' ? 'Speichert …' : 'Jetzt in Stripe gutschreiben'}</Button></div>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<dl className="area-kv">
|
<dl className="kv">
|
||||||
<dt>Abgelehnt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
|
<dt>Abgelehnt</dt><dd>{fmtDateTime(r.decidedAt)} · {r.decidedBy}</dd>
|
||||||
<dt>Begründung</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{r.decisionNote}</dd>
|
<dt>Begründung</dt><dd className="prose">{r.decisionNote}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
</>
|
{errorLine}
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
{actionError && <div className="area-error">{actionError}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1212,6 +1212,10 @@ div.cell-actions { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 6p
|
|||||||
.dialog-foot .btn { white-space: normal; height: auto; min-height: 32px; padding-top: 6px; padding-bottom: 6px; text-align: center; }
|
.dialog-foot .btn { white-space: normal; height: auto; min-height: 32px; padding-top: 6px; padding-bottom: 6px; text-align: center; }
|
||||||
/* deckt auch verschachtelte Felder, P7 prueft die Regel */
|
/* deckt auch verschachtelte Felder, P7 prueft die Regel */
|
||||||
.autocomplete .input { width: 100%; }
|
.autocomplete .input { width: 100%; }
|
||||||
|
/* Formularbloecke in einer Karte (Gutschrift: Berechnung) stehen mit 16 px Abstand untereinander, wie im Dialog (.dialog-body). */
|
||||||
|
/* Der Abstand kommt vom Container, nicht vom Altabstand .page .field (KOMPAT, faellt in P10); die zweite Regel nimmt ihn dort weg. */
|
||||||
|
.form-stack { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.page .form-stack > .field { margin-bottom: 0; }
|
||||||
/* ===== ENDE Kunden-Unterseiten (P6) ===== */
|
/* ===== ENDE Kunden-Unterseiten (P6) ===== */
|
||||||
|
|
||||||
/* ===== KOMPAT (bis P10) ===== */
|
/* ===== KOMPAT (bis P10) ===== */
|
||||||
|
|||||||
Reference in new issue
Block a user