Vertragsansicht: Kunden-Schnellansicht-Modal + Cent/Euro-Input
Feature 1 – Kunden-Schnellansicht: Info-Icon neben dem Kundenlink im Vertragsdetail oeffnet ein Modal mit den wichtigsten Kundendaten (Firma, Name, Geburtsdatum/-ort, Gruendungsdatum, Adresse, Telefon, Mobil, E-Mail, Portal-E-Mail, Steuer-/Handelsregisternr). Jedes Feld hat einen Copy-Button. Lazy-Fetch via customerApi.getById, staleTime 30s. Feature 2 – Cent/Euro-Doppel-Input: Neben dem €/kWh-Arbeitspreis-Feld jetzt ein zweites ct/kWh-Feld. Bidirektional gekoppelt – Tippen in € aktualisiert ct (×100), Tippen in ct aktualisiert € (÷100). Backend speichert weiterhin nur den Euro-Wert; Cent ist reine UI-Hilfe. Float-Rausch-Schutz verhindert "0.25 → 25.0000000000004". Greift fuer unitPrice und unitPriceNt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
)}
|
||||
</div>
|
||||
{c.customer && (
|
||||
<p className="text-gray-500 ml-10">
|
||||
<p className="text-gray-500 ml-10 flex items-center gap-1">
|
||||
Kunde:{' '}
|
||||
<Link to={`/customers/${c.customer.id}`} state={pushHistory(currentPath, location.state)} className="text-blue-600 hover:underline">
|
||||
{c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCustomerInfo(true)}
|
||||
title="Wichtige Kundendaten anzeigen (Schnellansicht mit Copy-Buttons)"
|
||||
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||
aria-label="Kundendaten anzeigen"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -3130,6 +3143,15 @@ export default function ContractDetail() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Kunden-Schnellansicht */}
|
||||
{c.customer && (
|
||||
<CustomerInfoModal
|
||||
customerId={c.customer.id}
|
||||
open={showCustomerInfo}
|
||||
onClose={() => setShowCustomerInfo(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Folgevertrag Bestätigung */}
|
||||
<Modal
|
||||
isOpen={showFollowUpConfirm}
|
||||
|
||||
@@ -991,18 +991,16 @@ export default function ContractForm() {
|
||||
/>
|
||||
)}
|
||||
<Input label="Grundpreis (€/Monat)" type="number" step="any" {...register('basePrice')} />
|
||||
<Input
|
||||
<EuroCentInput
|
||||
label={selectedMeter?.tariffModel === 'DUAL' ? 'HT-Arbeitspreis (€/kWh)' : 'Arbeitspreis (€/kWh)'}
|
||||
type="number"
|
||||
step="any"
|
||||
{...register('unitPrice')}
|
||||
value={watch('unitPrice')}
|
||||
onChange={(v) => setValue('unitPrice', v, { shouldDirty: true })}
|
||||
/>
|
||||
{selectedMeter?.tariffModel === 'DUAL' && (
|
||||
<Input
|
||||
<EuroCentInput
|
||||
label="NT-Arbeitspreis (€/kWh)"
|
||||
type="number"
|
||||
step="any"
|
||||
{...register('unitPriceNt')}
|
||||
value={watch('unitPriceNt')}
|
||||
onChange={(v) => setValue('unitPriceNt', v, { shouldDirty: true })}
|
||||
/>
|
||||
)}
|
||||
<Input label="Sofort-Bonus (€)" type="number" step="0.01" {...register('instantBonus')} />
|
||||
@@ -1508,3 +1506,81 @@ export default function ContractForm() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={euroStr}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
<span className="absolute right-3 top-2 text-gray-400 text-sm pointer-events-none">€</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={centDisplay}
|
||||
onChange={handleCentChange}
|
||||
className={inputCls}
|
||||
/>
|
||||
<span className="absolute right-3 top-2 text-gray-400 text-sm pointer-events-none">ct</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user