diff --git a/docs/todo.md b/docs/todo.md
index f2cc2350..42fa694a 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -120,6 +120,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
- **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf.
+- [x] **🆕 Vertragsansicht: Kunden-Schnellansicht-Modal + Cent/Euro-Doppel-Input**
+ - **Info-Icon neben Kundennamen** öffnet ein Modal mit den
+ wichtigsten Kundendaten (Firma, Name, Geburtsdatum/-ort,
+ Gründungsdatum, primäre Adresse, Telefon, Mobil, E-Mail,
+ Portal-E-Mail, Steuer-/Handelsregister-Nr.). Jedes Feld hat
+ einen Copy-Button (bestehende `CopyableValue`-Komponente).
+ Neue Komponente: `CustomerInfoModal.tsx`. Lazy-Fetch via
+ `customerApi.getById`, staleTime 30s.
+ - **Cent/Euro-Doppel-Input** für Arbeitspreise (Strom + Gas):
+ Neben dem €/kWh-Feld jetzt ein zweites Feld ct/kWh. Bidirektional
+ verkoppelt – Tippen in € aktualisiert ct (×100), Tippen in ct
+ aktualisiert € (÷100). Im Backend wird unverändert nur der
+ Euro-Wert persistiert; Cent ist reine UI-Hilfe. Float-Rausch-
+ Schutz (Math.round × 1e6) verhindert "0.25 → 25.0000000000…".
+ Greift für `unitPrice` und (bei DUAL-Zählern) `unitPriceNt`.
+
- [x] **🆕 Bonus-Feld aufgeteilt: Sofort-Bonus + Neukunden-Bonus (Strom/Gas)**
- Bisher gab es ein einzelnes `bonus`-Feld auf `EnergyContractDetails`.
Jetzt zwei Felder `instantBonus` (Sofort) und `newCustomerBonus`
diff --git a/frontend/src/components/contracts/CustomerInfoModal.tsx b/frontend/src/components/contracts/CustomerInfoModal.tsx
new file mode 100644
index 00000000..70f05b79
--- /dev/null
+++ b/frontend/src/components/contracts/CustomerInfoModal.tsx
@@ -0,0 +1,122 @@
+import { useQuery } from '@tanstack/react-query';
+import { X, RefreshCw, User } from 'lucide-react';
+import { customerApi } from '../../services/api';
+import { CopyableValue } from '../ui/CopyButton';
+import Button from '../ui/Button';
+
+interface CustomerInfoModalProps {
+ customerId: number;
+ open: boolean;
+ onClose: () => void;
+}
+
+/**
+ * Schnellansicht der wichtigsten Kundendaten – wird aus der
+ * Vertragsdetail-Seite per Info-Icon neben dem Kundennamen geöffnet.
+ * Jedes Feld hat einen Copy-Button rechts. Modal-only, schreibt nichts.
+ */
+export default function CustomerInfoModal({ customerId, open, onClose }: CustomerInfoModalProps) {
+ const { data, isLoading } = useQuery({
+ queryKey: ['customer-info-modal', customerId],
+ queryFn: () => customerApi.getById(customerId),
+ enabled: open,
+ staleTime: 30_000,
+ });
+
+ if (!open) return null;
+
+ const c = data?.data;
+
+ const fullName = c
+ ? [c.salutation, c.firstName, c.lastName].filter(Boolean).join(' ')
+ : '';
+
+ const primaryAddress = c?.addresses?.find((a) => a.isDefault) || c?.addresses?.[0];
+ const addressString = primaryAddress
+ ? `${primaryAddress.street} ${primaryAddress.houseNumber ?? ''}, ${primaryAddress.postalCode} ${primaryAddress.city}`.trim()
+ : '';
+
+ const formatDate = (iso?: string) => {
+ if (!iso) return '';
+ try {
+ return new Date(iso).toLocaleDateString('de-DE', {
+ day: '2-digit', month: '2-digit', year: 'numeric',
+ });
+ } catch {
+ return iso;
+ }
+ };
+
+ return (
+
+
+
+
+
+ Kundendaten
+ {c && (
+
+ · {c.customerNumber}
+
+ )}
+
+
+
+
+
+ {isLoading && (
+
+
+ Lade Kundendaten…
+
+ )}
+ {c && (
+
+ {c.type === 'BUSINESS' && c.companyName && (
+
+ )}
+ {fullName && }
+ {c.birthDate && (
+
+ )}
+ {c.birthPlace && }
+ {c.foundingDate && (
+
+ )}
+ {addressString && (
+
+ )}
+ {c.phone && }
+ {c.mobile && }
+ {c.email && }
+ {c.portalEmail && c.portalEmail !== c.email && (
+
+ )}
+ {c.taxNumber && (
+
+ )}
+ {c.commercialRegisterNumber && (
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/contracts/ContractDetail.tsx b/frontend/src/pages/contracts/ContractDetail.tsx
index 5a45f2c5..16f42128 100644
--- a/frontend/src/pages/contracts/ContractDetail.tsx
+++ b/frontend/src/pages/contracts/ContractDetail.tsx
@@ -6,6 +6,7 @@ import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi, gdpr
import { ContractEmailsSection } from '../../components/email';
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
import InvoicesSection from '../../components/contracts/InvoicesSection';
+import CustomerInfoModal from '../../components/contracts/CustomerInfoModal';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
@@ -1492,6 +1493,9 @@ export default function ContractDetail() {
() => new Date().toISOString().split('T')[0]
);
+ // Kunden-Schnellansicht-Modal
+ const [showCustomerInfo, setShowCustomerInfo] = useState(false);
+
const { data, isLoading } = useQuery({
queryKey: ['contract', id],
queryFn: () => contractApi.getById(contractId),
@@ -1771,11 +1775,20 @@ export default function ContractDetail() {
)}
{c.customer && (
-
+
Kunde:{' '}
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
+
)}
@@ -3130,6 +3143,15 @@ export default function ContractDetail() {
/>
)}
+ {/* Kunden-Schnellansicht */}
+ {c.customer && (
+ setShowCustomerInfo(false)}
+ />
+ )}
+
{/* Folgevertrag Bestätigung */}
)}
- setValue('unitPrice', v, { shouldDirty: true })}
/>
{selectedMeter?.tariffModel === 'DUAL' && (
- setValue('unitPriceNt', v, { shouldDirty: true })}
/>
)}
@@ -1508,3 +1506,81 @@ export default function ContractForm() {
);
}
+
+/**
+ * Doppel-Input für Arbeitspreise: links €/kWh, rechts ct/kWh.
+ * Beide bidirektional verkoppelt – Tippen im Cent-Feld setzt das
+ * darunterliegende Euro-Feld (÷ 100), und umgekehrt. Persistiert
+ * wird ausschließlich der Euro-Wert (Backend-Format unverändert).
+ * Cent-Anzeige wird live aus dem Euro-Wert abgeleitet.
+ */
+function EuroCentInput({
+ label,
+ value,
+ onChange,
+}: {
+ label: string;
+ value: string | number | undefined | null;
+ onChange: (v: string) => void;
+}) {
+ // Euro-Wert als String wie die anderen Inputs (react-hook-form merged
+ // numeric / string-Werte). Wir trimmen trailing zeros bei der
+ // Cent-Anzeige, damit "0.25 €" sauber als "25 ct" angezeigt wird –
+ // ohne "25.000000".
+ const euroStr = value == null ? '' : String(value);
+ const centDisplay = (() => {
+ if (euroStr === '') return '';
+ const n = parseFloat(euroStr);
+ if (!Number.isFinite(n)) return '';
+ // × 100 mit Float-Rauschen-Schutz (0.25 * 100 = 25.000000000000004)
+ const c = Math.round(n * 100 * 1_000_000) / 1_000_000;
+ return String(c);
+ })();
+
+ const handleCentChange = (e: React.ChangeEvent) => {
+ const raw = e.target.value;
+ if (raw === '') {
+ onChange('');
+ return;
+ }
+ const num = parseFloat(raw);
+ if (!Number.isFinite(num)) {
+ onChange('');
+ return;
+ }
+ // cent / 100, Float-Rauschen-Schutz analog.
+ const euro = Math.round((num / 100) * 1_000_000) / 1_000_000;
+ onChange(String(euro));
+ };
+
+ const inputCls =
+ 'block w-full px-3 py-2 pr-8 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
+
+ return (
+
+ );
+}