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 ( +
+ +
+
+ onChange(e.target.value)} + className={inputCls} + /> + +
+
+ + ct +
+
+
+ ); +}