841 lines
27 KiB
TypeScript
841 lines
27 KiB
TypeScript
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<ComparisonOffersDialogProps> = ({
|
|
open,
|
|
onClose,
|
|
paId,
|
|
paKey,
|
|
costPositionIndex,
|
|
costPositionName,
|
|
costPositionAmount,
|
|
attachments,
|
|
isAdmin = false,
|
|
readOnly = false,
|
|
}) => {
|
|
const [offers, setOffers] = useState<ComparisonOffer[]>([]);
|
|
const [noOffersRequired, setNoOffersRequired] = useState(false);
|
|
const [justification, setJustification] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [newOffer, setNewOffer] = useState<ComparisonOffer>({
|
|
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 (
|
|
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
|
<DialogTitle>
|
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
|
<Box>
|
|
<Typography variant="h6">Vergleichsangebote</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{costPositionName} - {costPositionAmount.toFixed(2)} €
|
|
</Typography>
|
|
</Box>
|
|
<IconButton size="small" onClick={onClose}>
|
|
<Close />
|
|
</IconButton>
|
|
</Box>
|
|
</DialogTitle>
|
|
|
|
<DialogContent dividers>
|
|
{error && (
|
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
{loading ? (
|
|
<Box display="flex" justifyContent="center" py={3}>
|
|
<CircularProgress />
|
|
</Box>
|
|
) : (
|
|
<>
|
|
{/* Checkbox for no offers required */}
|
|
{!readOnly && offers.length === 0 && (
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={noOffersRequired}
|
|
onChange={(e) => setNoOffersRequired(e.target.checked)}
|
|
/>
|
|
}
|
|
label="Für diese Kostenposition sind keine Vergleichsangebote erforderlich"
|
|
sx={{ mb: 2 }}
|
|
/>
|
|
)}
|
|
|
|
{/* Justification field */}
|
|
{noOffersRequired && (
|
|
<TextField
|
|
fullWidth
|
|
multiline
|
|
rows={4}
|
|
label="Begründung *"
|
|
value={justification}
|
|
onChange={(e) => 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 && (
|
|
<>
|
|
<Typography
|
|
variant="subtitle1"
|
|
gutterBottom
|
|
color="text.primary"
|
|
>
|
|
Vergleichsangebote ({offers.length}/3)
|
|
{offers.length < 3 && (
|
|
<Chip
|
|
size="small"
|
|
icon={<Warning />}
|
|
label="Mindestens 3 Angebote erforderlich"
|
|
color="warning"
|
|
sx={{ ml: 1 }}
|
|
/>
|
|
)}
|
|
</Typography>
|
|
|
|
{offers.length > 0 ? (
|
|
<>
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
sx={{ mb: 1 }}
|
|
>
|
|
Wählen Sie ein bevorzugtes Angebot durch Klick auf den
|
|
Stern:
|
|
</Typography>
|
|
<List>
|
|
{offers.map((offer, index) => (
|
|
<ListItem
|
|
key={offer.id || index}
|
|
disablePadding
|
|
sx={{ mb: 1 }}
|
|
>
|
|
<Paper
|
|
variant="outlined"
|
|
sx={{ width: "100%", p: 2 }}
|
|
>
|
|
<Box display="flex" justifyContent="space-between">
|
|
<Box
|
|
display="flex"
|
|
alignItems="flex-start"
|
|
gap={1}
|
|
>
|
|
<Radio
|
|
checked={offer.is_preferred === true}
|
|
onChange={() => handleSetPreferred(offer.id)}
|
|
value={offer.id}
|
|
disabled={readOnly || saving}
|
|
size="small"
|
|
icon={<StarBorder />}
|
|
checkedIcon={<Star />}
|
|
sx={{ mt: -0.5 }}
|
|
/>
|
|
<Box flex={1}>
|
|
<Typography
|
|
variant="subtitle2"
|
|
color="text.primary"
|
|
>
|
|
{offer.supplier_name}
|
|
</Typography>
|
|
<Typography variant="body1" color="primary">
|
|
{offer.amount.toFixed(2)} €
|
|
</Typography>
|
|
{offer.description && (
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
>
|
|
{offer.description}
|
|
</Typography>
|
|
)}
|
|
{offer.url && (
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
mt: 1,
|
|
gap: 0.5,
|
|
}}
|
|
>
|
|
<Link fontSize="small" color="primary" />
|
|
<Typography
|
|
variant="body2"
|
|
color="primary"
|
|
component="a"
|
|
href={offer.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
sx={{
|
|
textDecoration: "none",
|
|
"&:hover": {
|
|
textDecoration: "underline",
|
|
},
|
|
}}
|
|
>
|
|
{offer.url}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
{offer.attachment_id && (
|
|
<Chip
|
|
size="small"
|
|
icon={<AttachFile />}
|
|
label={
|
|
getAttachmentFilename(
|
|
offer.attachment_id,
|
|
) || "Anhang"
|
|
}
|
|
sx={{ mt: 1 }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
{!readOnly && (
|
|
<Box>
|
|
<IconButton
|
|
color="error"
|
|
onClick={() =>
|
|
offer.id && handleDeleteOffer(offer.id)
|
|
}
|
|
disabled={saving}
|
|
>
|
|
<Delete />
|
|
</IconButton>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Paper>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
</>
|
|
) : (
|
|
<Typography
|
|
color="text.secondary"
|
|
align="center"
|
|
sx={{ py: 2 }}
|
|
>
|
|
Noch keine Angebote hinzugefügt
|
|
</Typography>
|
|
)}
|
|
|
|
{/* Add new offer form */}
|
|
{!readOnly && offers.length < 5 && (
|
|
<Paper variant="outlined" sx={{ p: 2, mt: 2 }}>
|
|
<Typography
|
|
variant="subtitle2"
|
|
gutterBottom
|
|
color="text.primary"
|
|
>
|
|
Neues Angebot hinzufügen
|
|
</Typography>
|
|
<Box display="flex" flexDirection="column" gap={2}>
|
|
<TextField
|
|
label="Anbieter / Name *"
|
|
value={newOffer.supplier_name}
|
|
onChange={(e) =>
|
|
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(", ")}`
|
|
: ""
|
|
}
|
|
/>
|
|
<TextField
|
|
label="Betrag *"
|
|
type="text"
|
|
value={amountInput}
|
|
onChange={(e) => handleAmountChange(e.target.value)}
|
|
size="small"
|
|
fullWidth
|
|
InputProps={{
|
|
endAdornment: (
|
|
<InputAdornment position="end">€</InputAdornment>
|
|
),
|
|
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}
|
|
/>
|
|
<TextField
|
|
label="Beschreibung (optional)"
|
|
value={newOffer.description}
|
|
onChange={(e) =>
|
|
setNewOffer({
|
|
...newOffer,
|
|
description: e.target.value,
|
|
})
|
|
}
|
|
size="small"
|
|
fullWidth
|
|
multiline
|
|
rows={2}
|
|
/>
|
|
<TextField
|
|
label="URL (Pflicht wenn kein Anhang)"
|
|
value={newOffer.url}
|
|
onChange={(e) =>
|
|
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("."))
|
|
}
|
|
/>
|
|
<FormControl size="small" fullWidth>
|
|
<InputLabel>
|
|
Anhang verknüpfen (Pflicht wenn keine URL)
|
|
</InputLabel>
|
|
<Select
|
|
value={newOffer.attachment_id || ""}
|
|
onChange={(e) =>
|
|
setNewOffer({
|
|
...newOffer,
|
|
attachment_id: e.target.value as number,
|
|
})
|
|
}
|
|
label="Anhang verknüpfen (Pflicht wenn keine URL)"
|
|
disabled={attachments.length === 0}
|
|
>
|
|
<MenuItem value="">
|
|
{attachments.length === 0
|
|
? "Keine Anhänge vorhanden"
|
|
: "Kein Anhang"}
|
|
</MenuItem>
|
|
{attachments.map((att) => (
|
|
<MenuItem key={att.id} value={att.id}>
|
|
{att.filename}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
{attachments.length === 0 && (
|
|
<FormHelperText>
|
|
Laden Sie zuerst Anhänge hoch, um sie mit Angeboten
|
|
zu verknüpfen
|
|
</FormHelperText>
|
|
)}
|
|
</FormControl>
|
|
<Button
|
|
variant="contained"
|
|
startIcon={<Add />}
|
|
onClick={handleAddOffer}
|
|
disabled={
|
|
saving ||
|
|
!newOffer.supplier_name ||
|
|
!newOffer.amount ||
|
|
(!newOffer.url && !newOffer.attachment_id)
|
|
}
|
|
>
|
|
Angebot hinzufügen
|
|
</Button>
|
|
</Box>
|
|
</Paper>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
|
|
<DialogActions>
|
|
<Button onClick={onClose} disabled={saving}>
|
|
Abbrechen
|
|
</Button>
|
|
{!readOnly && (
|
|
<Button
|
|
variant="contained"
|
|
onClick={
|
|
offers.length > 0
|
|
? handleSavePreferredOffer
|
|
: handleUpdateJustification
|
|
}
|
|
disabled={
|
|
saving || (offers.length > 0 ? !hasPreferredOffer : !isValid)
|
|
}
|
|
startIcon={
|
|
(offers.length > 0 ? hasPreferredOffer : isValid) ? (
|
|
<CheckCircle />
|
|
) : (
|
|
<Warning />
|
|
)
|
|
}
|
|
>
|
|
Speichern
|
|
</Button>
|
|
)}
|
|
</DialogActions>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export default ComparisonOffersDialog;
|