Let superadmins adjust contract price and terms

New PATCH /admin/contracts/:id (permission contracts.edit, superadmin
only) recalculates the price snapshot server-side and can change term
end, renewal and notice period. A reason is required and before/after
values go to the audit log. Changing the term end re-arms the renewal
reminder. The contract page gets a matching edit form.

Also fixes the domain test after replacing the KCS order number with
the order date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-10-01 12:02:26 +02:00
parent d00f1a4cec
commit 466bd83b7e
4 changed files with 104 additions and 10 deletions

View file

@ -1,21 +1,34 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, ContractStatusBadge, fmt, INTERVAL, type Price, PriceText } from '@/components/ui';
import { Alert, ContractStatusBadge, Field, fmt, INTERVAL, type Price, PriceText } from '@/components/ui';
interface D { id: string; number: string; orgName: string; status: string; productName: string; startedAt: string | null; termEnd: string | null; renewal: string; renewalTermMonths: number; noticeDays: number; cancelRequestedAt: string | null; cancelEffectiveAt: string | null; cancelledAt: string | null; resourceId: string | null; canCancel: boolean;
price: Price & { customerType: string; name: string; sku: string; version: number; terms: { termMonths: number } } }
interface D { id: string; number: string; orgName: string; status: string; productName: string; startedAt: string | null; termEnd: string | null; renewal: string; renewalTermMonths: number; noticeDays: number; cancelRequestedAt: string | null; cancelEffectiveAt: string | null; cancelledAt: string | null; resourceId: string | null; canCancel: boolean; canEdit?: boolean;
price: Price & { customerType: string; name: string; sku: string; version: number; terms: { termMonths: number }; basis: 'net' | 'gross'; unitRecurringCents: number; unitSetupCents: number; adjusted?: { at: string; reason: string } } }
const toCents = (v: string): number => { const t = v.trim(); const n = Number(t.includes(',') ? t.replace(/\./g, '').replace(',', '.') : t); return Number.isFinite(n) ? Math.round(n * 100) : NaN; };
const centsIn = (c: number): string => (c / 100).toFixed(2).replace('.', ',');
export default function Vertrag() {
const { id } = useParams<{ id: string }>(); const { can } = useSession();
const [d, setD] = useState<D | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [confirm, setConfirm] = useState<'cancel' | 'immediate' | null>(null);
const [d, setD] = useState<D | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [confirm, setConfirm] = useState<'cancel' | 'immediate' | null>(null); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false);
const load = useCallback(() => api<D>('GET', `/contracts/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
useEffect(() => { void load(); }, [load]);
if (!d) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
async function cancel(immediate: boolean) { try { const r = await api<{ cancelEffectiveAt: string }>('POST', `/contracts/${id}/cancel`, { immediate }); setConfirm(null); setMsg({ k: 'ok', t: `Die Kündigung wurde vorgemerkt und wird wirksam am ${fmt(r.cancelEffectiveAt)}.` }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
async function revoke() { try { await api('POST', `/contracts/${id}/cancel/revoke`); setMsg({ k: 'ok', t: 'Die Kündigung wurde zurückgenommen.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
async function save(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const g = (k: string) => String(f.get(k) ?? '');
const recurringCents = toCents(g('recurring')); const setupCents = toCents(g('setup'));
if (Number.isNaN(recurringCents) || Number.isNaN(setupCents)) { setMsg({ k: 'err', t: 'Bitte die Preise als Zahl angeben, z. B. 12,50.' }); return; }
setSaving(true);
try {
await api('PATCH', `/admin/contracts/${id}`, { recurringCents, setupCents, basis: g('basis'), interval: g('interval'), discountBp: Math.round(Number(g('discount').replace(',', '.')) * 100),
termEnd: g('termEnd') || null, renewal: g('renewal'), renewalTermMonths: Number(g('renewalTermMonths')), noticeDays: Number(g('noticeDays')), reason: g('reason') });
setEditing(false); setMsg({ k: 'ok', t: 'Der Vertrag wurde angepasst.' }); void load();
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setSaving(false); }
}
const priv = d.price.customerType === 'private'; const pending = !!d.cancelEffectiveAt && ['active', 'suspended'].includes(d.status);
return (<>
<p><Link href="/vertraege">← Alle Verträge</Link></p>
@ -30,8 +43,26 @@ export default function Vertrag() {
<div><dt className="muted small">Verlängerung</dt><dd style={{ margin: 0 }}>{d.renewal === 'auto' ? `automatisch um ${d.renewalTermMonths} Monat(e)` : 'keine'}</dd></div>
<div><dt className="muted small">Kündigungsfrist</dt><dd style={{ margin: 0 }}>{d.noticeDays} Tage</dd></div>
<div><dt className="muted small">Abrechnung</dt><dd style={{ margin: 0 }}>{INTERVAL[d.price.interval]}</dd></div></dl>
<p className="small muted" style={{ marginTop: 16 }}>Preisstand bei Bestellung (Produkt {d.price.sku}, Version {d.price.version}). Spätere Preisänderungen gelten nicht rückwirkend.</p>
<p className="small muted" style={{ marginTop: 16 }}>{d.price.adjusted ? `Preis zuletzt angepasst am ${fmt(d.price.adjusted.at)}.` : `Preisstand bei Bestellung (Produkt ${d.price.sku}, Version ${d.price.version}). Spätere Preisänderungen gelten nicht rückwirkend.`}</p>
{d.resourceId && <p><Link href={`/ressourcen/${d.resourceId}`}>Zugehörige Ressource ansehen</Link></p>}</div>
{d.canEdit && <div className="card"><h2>Vertrag anpassen</h2>
{!editing ? <><p className="muted small">Nur für Superadmins. Preis und Konditionen werden mit Begründung im Audit-Protokoll festgehalten.</p><button className="btn" onClick={() => setEditing(true)}>Anpassen</button></> : (
<form onSubmit={save}>
<div className="cols">
<Field id="ce-rec" label="Preis je Abrechnung (€)" hint={`Bisher: ${centsIn(d.price.unitRecurringCents)}`}><input id="ce-rec" name="recurring" inputMode="decimal" required defaultValue={centsIn(d.price.unitRecurringCents)} /></Field>
<Field id="ce-basis" label="Preis ist"><select id="ce-basis" name="basis" defaultValue={d.price.basis}><option value="net">netto (zzgl. USt)</option><option value="gross">brutto (inkl. USt)</option></select></Field>
<Field id="ce-int" label="Abrechnung"><select id="ce-int" name="interval" defaultValue={d.price.interval}>{Object.entries(INTERVAL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>
<Field id="ce-setup" label="Einrichtung einmalig (€)"><input id="ce-setup" name="setup" inputMode="decimal" required defaultValue={centsIn(d.price.unitSetupCents)} /></Field>
<Field id="ce-disc" label="Rabatt (%)"><input id="ce-disc" name="discount" inputMode="decimal" required defaultValue={String(d.price.discountBp / 100).replace('.', ',')} /></Field>
<Field id="ce-end" label="Laufzeit bis" hint="Leer = unbefristet"><input id="ce-end" name="termEnd" type="date" defaultValue={d.termEnd ? new Date(d.termEnd).toLocaleDateString('sv-SE') : ''} /></Field>
<Field id="ce-ren" label="Verlängerung"><select id="ce-ren" name="renewal" defaultValue={d.renewal}><option value="auto">automatisch</option><option value="none">keine</option></select></Field>
<Field id="ce-rtm" label="Verlängerung um (Monate)"><input id="ce-rtm" name="renewalTermMonths" type="number" min={0} max={120} required defaultValue={d.renewalTermMonths} /></Field>
<Field id="ce-nd" label="Kündigungsfrist (Tage)"><input id="ce-nd" name="noticeDays" type="number" min={0} max={365} required defaultValue={d.noticeDays} /></Field>
</div>
<Field id="ce-reason" label="Grund der Anpassung"><input id="ce-reason" name="reason" required minLength={3} maxLength={300} placeholder="z. B. Unkostenpreis vereinbart" /></Field>
<div className="row"><button className="btn primary" type="submit" disabled={saving}>{saving ? 'Wird gespeichert …' : 'Speichern'}</button><button className="btn" type="button" onClick={() => setEditing(false)}>Abbrechen</button></div>
</form>)}
</div>}
{(d.canCancel || pending) && <div className="card"><h2>Kündigung</h2>
{confirm ? (<div role="alertdialog" aria-labelledby="cc"><p id="cc"><strong>{confirm === 'immediate' ? 'Vertrag sofort beenden?' : 'Vertrag kündigen?'}</strong> {confirm === 'immediate' ? 'Die Ressource wird gesperrt.' : 'Die Kündigung wird zum nächstmöglichen Termin wirksam; bis dahin läuft der Vertrag weiter.'} Die Aktion wird protokolliert.</p>
<div className="row"><button className="btn danger" onClick={() => cancel(confirm === 'immediate')}>Ja, kündigen</button><button className="btn" onClick={() => setConfirm(null)}>Abbrechen</button></div></div>)