import React, { useState, useEffect } from "react"; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography, TextField, IconButton, List, ListItem, Checkbox, FormControlLabel, Alert, CircularProgress, Chip, Paper, InputAdornment, Select, MenuItem, FormControl, InputLabel, FormHelperText, } from "@mui/material"; import { Add, Delete, AttachFile, Warning, CheckCircle, Close, Link, Star, StarBorder, } from "@mui/icons-material"; import Radio from "@mui/material/Radio"; import RadioGroup from "@mui/material/RadioGroup"; import Tooltip from "@mui/material/Tooltip"; import { useApplicationStore } from "../../store/applicationStore"; interface ComparisonOffer { id?: number; supplier_name: string; amount: number; description?: string; url?: string; attachment_id?: number; is_preferred: boolean; } interface ComparisonOffersDialogProps { open: boolean; onClose: () => void; paId: string; paKey?: string; costPositionIndex: number; costPositionName: string; costPositionAmount: number; attachments: Array<{ id: number; filename: string; }>; isAdmin?: boolean; readOnly?: boolean; } const ComparisonOffersDialog: React.FC = ({ open, onClose, paId, paKey, costPositionIndex, costPositionName, costPositionAmount, attachments, isAdmin = false, readOnly = false, }) => { const [offers, setOffers] = useState([]); const [noOffersRequired, setNoOffersRequired] = useState(false); const [justification, setJustification] = useState(""); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [newOffer, setNewOffer] = useState({ supplier_name: "", amount: 0, description: "", url: "", is_preferred: false, }); const [amountInput, setAmountInput] = useState(""); const { masterKey } = useApplicationStore(); // Load existing offers and justification useEffect(() => { if (open) { loadOffers(); // Reset form when dialog opens setNewOffer({ supplier_name: "", amount: 0, description: "", url: "", is_preferred: false, }); setAmountInput(""); } }, [open, costPositionIndex]); const loadOffers = async () => { setLoading(true); setError(null); try { const headers: HeadersInit = {}; if (isAdmin && masterKey) { headers["X-MASTER-KEY"] = masterKey; } else if (paKey) { headers["X-PA-KEY"] = paKey; } const response = await fetch( `/api/applications/${paId}/costs/${costPositionIndex}/offers`, { headers }, ); if (!response.ok) { if (response.status === 404) { // Cost position might not exist in backend data console.warn( `Cost position ${costPositionIndex} not found in backend`, ); setOffers([]); setNoOffersRequired(false); setJustification(""); return; } throw new Error("Failed to load offers"); } const data = await response.json(); setOffers( data.offers.map((offer: any) => ({ ...offer, is_preferred: offer.is_preferred || false, })) || [], ); setNoOffersRequired(data.no_offers_required || false); setJustification(data.justification || ""); } catch (err) { setError("Fehler beim Laden der Angebote"); console.error("Error loading offers:", err); } finally { setLoading(false); } }; const handleAddOffer = async () => { if (!newOffer.supplier_name || newOffer.amount <= 0) { setError("Bitte geben Sie einen Anbieter und einen gültigen Betrag ein"); return; } // Check for duplicate supplier const duplicateSupplier = offers.find( (offer) => offer.supplier_name.toLowerCase() === newOffer.supplier_name.toLowerCase(), ); if (duplicateSupplier) { setError( `Ein Angebot von "${newOffer.supplier_name}" existiert bereits für diese Kostenposition`, ); return; } // Auto-prepend https:// if URL doesn't have protocol let finalUrl = newOffer.url; if ( finalUrl && !finalUrl.startsWith("http://") && !finalUrl.startsWith("https://") ) { finalUrl = "https://" + finalUrl; } // Validate URL format if provided if (finalUrl && !isValidUrl(finalUrl)) { setError( "Bitte geben Sie eine gültige URL ein (z.B. beispiel.de oder https://beispiel.de)", ); return; } // Validate that either URL or attachment is provided if (!newOffer.url && !newOffer.attachment_id) { setError( "Bitte geben Sie entweder eine URL oder verknüpfen Sie einen Anhang", ); return; } setSaving(true); setError(null); try { const headers: HeadersInit = { "Content-Type": "application/json", }; if (isAdmin && masterKey) { headers["X-MASTER-KEY"] = masterKey; } else if (paKey) { headers["X-PA-KEY"] = paKey; } const response = await fetch( `/api/applications/${paId}/costs/${costPositionIndex}/offers`, { method: "POST", headers, body: JSON.stringify({ ...newOffer, url: finalUrl || undefined, }), }, ); if (!response.ok) { if (response.status === 409 || response.status === 400) { const errorData = await response.json(); if (errorData.detail?.includes("Duplicate entry")) { setError( `Ein Angebot von "${newOffer.supplier_name}" existiert bereits für diese Kostenposition`, ); } else { setError(errorData.detail || "Fehler beim Hinzufügen des Angebots"); } return; } if (response.status === 404) { setError( "Diese Kostenposition existiert nicht in den gespeicherten Daten. Bitte speichern Sie den Antrag erneut.", ); return; } throw new Error("Failed to add offer"); } await loadOffers(); setNewOffer({ supplier_name: "", amount: 0, description: "", url: "", is_preferred: false, }); setAmountInput(""); } catch (err) { setError("Fehler beim Hinzufügen des Angebots"); console.error("Error adding offer:", err); } finally { setSaving(false); } }; const handleDeleteOffer = async (offerId: number) => { setSaving(true); setError(null); try { const headers: HeadersInit = {}; if (isAdmin && masterKey) { headers["X-MASTER-KEY"] = masterKey; } else if (paKey) { headers["X-PA-KEY"] = paKey; } const response = await fetch( `/api/applications/${paId}/costs/${costPositionIndex}/offers/${offerId}`, { method: "DELETE", headers, }, ); if (!response.ok) { throw new Error("Failed to delete offer"); } await loadOffers(); } catch (err) { setError("Fehler beim Löschen des Angebots"); console.error("Error deleting offer:", err); } finally { setSaving(false); } }; const handleSetPreferred = (offerId: number | undefined) => { // Update local state to mark the preferred offer const updatedOffers = offers.map((offer) => ({ ...offer, is_preferred: offer.id === offerId, })); setOffers(updatedOffers); }; const handleSavePreferredOffer = async () => { setSaving(true); setError(null); try { const headers: HeadersInit = { "Content-Type": "application/json", }; if (isAdmin && masterKey) { headers["X-MASTER-KEY"] = masterKey; } else if (paKey) { headers["X-PA-KEY"] = paKey; } // Save preferred offer selection const preferredOffer = offers.find((o) => o.is_preferred); if (preferredOffer && preferredOffer.id) { const response = await fetch( `/api/applications/${paId}/costs/${costPositionIndex}/offers/${preferredOffer.id}/preferred`, { method: "PUT", headers, }, ); if (!response.ok) { throw new Error("Failed to save preferred offer"); } } onClose(); } catch (err) { setError("Fehler beim Speichern des bevorzugten Angebots"); console.error("Error saving preferred offer:", err); } finally { setSaving(false); } }; const handleUpdateJustification = async () => { if (noOffersRequired && !justification.trim()) { setError("Bitte geben Sie eine Begründung ein"); return; } setSaving(true); setError(null); try { const headers: HeadersInit = { "Content-Type": "application/json", }; if (isAdmin && masterKey) { headers["X-MASTER-KEY"] = masterKey; } else if (paKey) { headers["X-PA-KEY"] = paKey; } const response = await fetch( `/api/applications/${paId}/costs/${costPositionIndex}/justification`, { method: "PUT", headers, body: JSON.stringify({ no_offers_required: noOffersRequired, justification: noOffersRequired ? justification : null, }), }, ); if (!response.ok) { if (response.status === 404) { setError( "Diese Kostenposition existiert nicht in den gespeicherten Daten. Bitte speichern Sie den Antrag erneut.", ); return; } throw new Error("Failed to update justification"); } onClose(); } catch (err) { setError("Fehler beim Speichern der Begründung"); console.error("Error updating justification:", err); } finally { setSaving(false); } }; const isValid = noOffersRequired ? justification.trim().length >= 10 : offers.length >= 3; const hasPreferredOffer = offers.some((o) => o.is_preferred); const getAttachmentFilename = (attachmentId?: number) => { if (!attachmentId) return null; const attachment = attachments.find((a) => a.id === attachmentId); return attachment?.filename || null; }; // URL validation helper const isValidUrl = (url: string): boolean => { try { const urlObj = new URL(url); return urlObj.protocol === "http:" || urlObj.protocol === "https:"; } catch { return false; } }; // Format amount for display const formatAmount = (value: string): string => { // Remove all non-digit and non-comma/period characters const cleaned = value.replace(/[^\d,.-]/g, ""); // Replace comma with period for parsing return cleaned.replace(",", "."); }; // Handle amount input change const handleAmountChange = (value: string) => { // Allow only numbers, comma, period, and minus sign const cleaned = value.replace(/[^\d,.-]/g, ""); setAmountInput(cleaned); const formatted = formatAmount(cleaned); const parsed = parseFloat(formatted); if (!isNaN(parsed) && parsed > 0) { setNewOffer({ ...newOffer, amount: parsed }); } else if (cleaned === "" || cleaned === "0") { setNewOffer({ ...newOffer, amount: 0 }); } }; return ( Vergleichsangebote {costPositionName} - {costPositionAmount.toFixed(2)} € {error && ( setError(null)}> {error} )} {loading ? ( ) : ( <> {/* Checkbox for no offers required */} {!readOnly && offers.length === 0 && ( setNoOffersRequired(e.target.checked)} /> } label="Für diese Kostenposition sind keine Vergleichsangebote erforderlich" sx={{ mb: 2 }} /> )} {/* Justification field */} {noOffersRequired && ( setJustification(e.target.value)} disabled={readOnly} error={noOffersRequired && justification.trim().length < 10} helperText={ noOffersRequired && justification.trim().length < 10 ? "Bitte geben Sie eine ausführliche Begründung ein (mind. 10 Zeichen)" : "" } sx={{ mb: 3 }} /> )} {/* Offers list */} {!noOffersRequired && ( <> Vergleichsangebote ({offers.length}/3) {offers.length < 3 && ( } label="Mindestens 3 Angebote erforderlich" color="warning" sx={{ ml: 1 }} /> )} {offers.length > 0 ? ( <> Wählen Sie ein bevorzugtes Angebot durch Klick auf den Stern: {offers.map((offer, index) => ( handleSetPreferred(offer.id)} value={offer.id} disabled={readOnly || saving} size="small" icon={} checkedIcon={} sx={{ mt: -0.5 }} /> {offer.supplier_name} {offer.amount.toFixed(2)} € {offer.description && ( {offer.description} )} {offer.url && ( {offer.url} )} {offer.attachment_id && ( } label={ getAttachmentFilename( offer.attachment_id, ) || "Anhang" } sx={{ mt: 1 }} /> )} {!readOnly && ( offer.id && handleDeleteOffer(offer.id) } disabled={saving} > )} ))} ) : ( Noch keine Angebote hinzugefügt )} {/* Add new offer form */} {!readOnly && offers.length < 5 && ( Neues Angebot hinzufügen setNewOffer({ ...newOffer, supplier_name: e.target.value, }) } size="small" fullWidth error={offers.some( (o) => o.supplier_name.toLowerCase() === newOffer.supplier_name.toLowerCase(), )} helperText={ offers.some( (o) => o.supplier_name.toLowerCase() === newOffer.supplier_name.toLowerCase(), ) ? "Dieser Anbieter existiert bereits" : offers.length > 0 ? `Vorhandene Anbieter: ${offers .map((o) => o.supplier_name) .join(", ")}` : "" } /> handleAmountChange(e.target.value)} size="small" fullWidth InputProps={{ endAdornment: ( ), inputProps: { inputMode: "decimal", pattern: "[0-9]*[,.]?[0-9]*", }, }} placeholder="0,00" helperText={ amountInput !== "" && newOffer.amount <= 0 ? "Betrag muss größer als 0 sein" : "Format: 123,45 oder 123.45" } error={amountInput !== "" && newOffer.amount <= 0} /> setNewOffer({ ...newOffer, description: e.target.value, }) } size="small" fullWidth multiline rows={2} /> setNewOffer({ ...newOffer, url: e.target.value, }) } size="small" fullWidth type="url" placeholder="beispiel.de/angebot.pdf" helperText={ !newOffer.url && !newOffer.attachment_id ? "URL oder Anhang erforderlich" : newOffer.url && !newOffer.url.includes(".") ? "Ungültige URL" : "https:// wird automatisch hinzugefügt" } error={ (!newOffer.url && !newOffer.attachment_id) || (newOffer.url !== "" && !newOffer.url.includes(".")) } /> Anhang verknüpfen (Pflicht wenn keine URL) {attachments.length === 0 && ( Laden Sie zuerst Anhänge hoch, um sie mit Angeboten zu verknüpfen )} )} )} )} {!readOnly && ( )} ); }; export default ComparisonOffersDialog;