stupa-pdf-api/frontend/src/components/ComparisonOffers/ComparisonOffersDialog.tsx

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;