More or less working

This commit is contained in:
Frederik Beimgraben 2025-09-01 04:29:07 +02:00
parent dd1249fb35
commit e2e63b75fd
24 changed files with 4091 additions and 2835 deletions

View File

@ -0,0 +1,167 @@
# Comparison Offers Feature Documentation
## Overview
The comparison offers feature requires users to provide at least 3 comparison offers for each cost position in their application. This ensures transparency and cost-effectiveness in funding requests. Users can alternatively provide a justification if comparison offers are not applicable for specific cost positions.
## Features
- **3 Offers Requirement**: Each cost position must have at least 3 comparison offers
- **Justification Alternative**: Users can mark that no offers are required and provide a detailed justification
- **Attachment Support**: Each offer can be linked to an uploaded attachment (e.g., quotes, invoices)
- **Visual Indicators**: Cost positions without sufficient offers are highlighted with warning icons
- **Status Tracking**: Real-time status display showing offer count and completion status
## Database Schema
### `comparison_offers` Table
Stores comparison offers for cost positions:
- `id` (INT, PRIMARY KEY) - Unique identifier
- `application_id` (INT) - References the application
- `cost_position_index` (INT) - Index of the cost position in the costs array
- `supplier_name` (VARCHAR 255) - Name of the supplier/vendor
- `amount` (INT) - Offer amount in cents
- `description` (TEXT) - Optional description of the offer
- `url` (VARCHAR 500) - Optional URL to offer document or webpage
- `attachment_id` (INT) - Optional reference to an attachment
- `created_at` (DATETIME) - Creation timestamp
- `updated_at` (DATETIME) - Last update timestamp
### `cost_position_justifications` Table
Stores justifications for positions without offers:
- `id` (INT, PRIMARY KEY) - Unique identifier
- `application_id` (INT) - References the application
- `cost_position_index` (INT) - Index of the cost position
- `no_offers_required` (INT) - Boolean flag (0/1)
- `justification` (TEXT) - Required explanation when no_offers_required is 1
- `created_at` (DATETIME) - Creation timestamp
- `updated_at` (DATETIME) - Last update timestamp
## API Endpoints
### Create Comparison Offer
`POST /api/applications/{pa_id}/costs/{cost_position_index}/offers`
- Request body:
```json
{
"supplier_name": "Supplier GmbH",
"amount": 1500.50,
"description": "Optional description",
"url": "https://beispiel.de/angebot.pdf",
"attachment_id": 123
}
```
- Returns: ComparisonOfferResponse with offer details
### Get Cost Position Offers
`GET /api/applications/{pa_id}/costs/{cost_position_index}/offers`
- Returns: CostPositionOffersResponse with all offers and justification info
### Delete Comparison Offer
`DELETE /api/applications/{pa_id}/costs/{cost_position_index}/offers/{offer_id}`
- Removes a specific offer
### Update Cost Position Justification
`PUT /api/applications/{pa_id}/costs/{cost_position_index}/justification`
- Request body:
```json
{
"no_offers_required": true,
"justification": "Detailed explanation why no offers are needed..."
}
```
## Frontend Components
### ComparisonOffersDialog
Main dialog for managing offers for a cost position:
- Add/remove comparison offers
- Add URL links to online offers
- Link attachments to offers (either URL or attachment required)
- Mark position as not requiring offers
- Provide justification when needed
### CostPositionsList
Enhanced cost positions list with offer status:
- Visual indicators for offer status
- Warning icons for incomplete positions
- Quick access to manage offers
- Real-time status updates
## User Interface
### Status Indicators
- ✅ **Green checkmark**: Position has 3+ offers
- **Blue info icon**: Position marked as not requiring offers (with justification)
- ⚠️ **Yellow warning**: Position needs more offers or justification
- 🔄 **Loading spinner**: Status being loaded
### Validation Rules
1. Each cost position must have either:
- At least 3 comparison offers, OR
- Be marked as "no offers required" with a justification (min. 10 characters)
2. Cost positions without sufficient offers are highlighted in the list
3. Offers must include:
- Supplier name (required, must be unique per cost position)
- Amount (required, must be greater than 0)
- Either URL or attachment (at least one required)
- Description (optional)
4. Duplicate suppliers: Each supplier can only have one offer per cost position
5. URL format: Must be a valid HTTP/HTTPS URL (protocol is auto-added if missing)
6. Amount format: Accepts both comma and period as decimal separator
## Migration
To add the comparison offers tables to your database:
```sql
mysql -u your_user -p your_database < src/migrations/add_comparison_offers_tables.sql
mysql -u your_user -p your_database < src/migrations/add_url_to_comparison_offers.sql
```
## Usage Example
### In Cost Positions List
```tsx
<CostPositionsList
costs={formData.costs}
paId={applicationId}
paKey={applicationKey}
isAdmin={false}
readOnly={false}
attachments={availableAttachments}
/>
```
### Managing Offers
1. Click "Angebote" button next to any cost position
2. In the dialog:
- Add up to 5 comparison offers
- Link existing attachments to offers
- OR check "no offers required" and provide justification
3. Save changes
## Best Practices
1. **Encourage Early Collection**: Remind users to collect offers before starting the application
2. **Clear Instructions**: Provide examples of valid justifications for positions without offers
3. **Attachment Organization**: Name attachment files clearly to easily link them to offers
4. **Regular Validation**: Check offer status before submission
## Error Handling
- **Duplicate Suppliers**: Shows user-friendly error message when trying to add the same supplier twice
- **Invalid URLs**: Automatically prepends https:// if missing, validates format
- **Invalid Amounts**: Real-time validation with support for German number format (comma as decimal)
- **Missing Data**: Clear error messages when required fields are missing
## Future Enhancements
1. **Bulk Import**: Import multiple offers from CSV/Excel
2. **Templates**: Save common suppliers for quick reuse
3. **Price Comparison**: Automatic highlighting of best offer
4. **History Tracking**: Track changes to offers over time
5. **Export Function**: Export all offers as PDF/Excel report
6. **Smart Validation**: Detect duplicate suppliers or suspicious patterns
7. **URL Validation**: Automatic checking of URL availability and validity
8. **Screenshot Capture**: Automatic capture of web-based offers for archival

View File

@ -1,2 +1,42 @@
# stupa-pdf-api # stupa-pdf-api
## Recent Updates
### Dark Mode Loading Screen Fix
- Fixed white background issue in loading screens when using dark mode
- The LoadingSpinner component now properly respects the theme settings
### File Attachments Feature
- Users can now upload up to 30 attachments per application
- Maximum total size: 100MB
- Files are stored securely in the database
- Full CRUD operations with progress tracking
- See [ATTACHMENT_FEATURE.md](ATTACHMENT_FEATURE.md) for details
### Comparison Offers Feature
- Each cost position now requires 3 comparison offers
- Alternative: Users can provide a justification if offers are not applicable
- Visual indicators show completion status
- Offers can be linked to uploaded attachments
- See [COMPARISON_OFFERS_FEATURE.md](COMPARISON_OFFERS_FEATURE.md) for details
### Rate Limiting Configuration
- API rate limiting is now disabled for localhost connections
- Includes localhost (127.0.0.1) and Docker internal IPs (172.x.x.x)
- Production deployments maintain rate limiting for external IPs
## Database Migrations
Run these migrations to add the new features:
```bash
# Add attachment tables
mysql -u your_user -p your_database < src/migrations/add_attachments_tables.sql
# Fix attachment data column if needed
mysql -u your_user -p your_database < src/migrations/alter_attachments_data_column.sql
# Add comparison offers tables
mysql -u your_user -p your_database < src/migrations/add_comparison_offers_tables.sql
```

4
dev.sh
View File

@ -36,8 +36,8 @@ MYSQL_ROOT_PASSWORD=rootsecret
# Application Configuration # Application Configuration
MASTER_KEY=change_me_in_production MASTER_KEY=change_me_in_production
RATE_IP_PER_MIN=60 RATE_IP_PER_MIN=120
RATE_KEY_PER_MIN=30 RATE_KEY_PER_MIN=60
# Timezone # Timezone
TZ=Europe/Berlin TZ=Europe/Berlin

View File

@ -1,92 +1,123 @@
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="STUPA PDF API - Studienantrag Management System" /> <meta
<meta name="theme-color" content="#1976d2" /> name="description"
content="STUPA PDF API - Projektantragsmanagementsystem"
/>
<meta name="theme-color" content="#1976d2" />
<!-- PWA Manifest --> <!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<!-- Preconnect to improve loading performance --> <!-- Preconnect to improve loading performance -->
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Roboto Font for Material-UI --> <!-- Roboto Font for Material-UI -->
<link <link
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700&display=swap"
/> />
<!-- Material-UI Icons --> <!-- Material-UI Icons -->
<link <link
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons" href="https://fonts.googleapis.com/icon?family=Material+Icons"
/> />
<title>STUPA PDF API</title> <title>STUPA PDF API</title>
<style> <style>
/* Reset and base styles */ /* Reset and base styles */
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { /*body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: #fafafa; background-color: #fafafa;
} }*/
/* Rework with optional dark mode */
body {
font-family:
"Roboto",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #fafafa;
}
/* Loading spinner */ body.dark {
.loading-container { background-color: #121212;
display: flex; color: #fff;
justify-content: center; }
align-items: center;
height: 100vh;
flex-direction: column;
gap: 20px;
}
.loading-spinner { @media (prefers-color-scheme: dark) {
width: 50px; body {
height: 50px; background-color: #121212;
border: 4px solid #e3f2fd; color: #fff;
border-top: 4px solid #1976d2; }
border-radius: 50%; }
animation: spin 1s linear infinite;
}
.loading-text { /* Loading spinner */
color: #666; .loading-container {
font-size: 16px; display: flex;
} justify-content: center;
align-items: center;
height: 100vh;
flex-direction: column;
gap: 20px;
}
@keyframes spin { .loading-spinner {
0% { transform: rotate(0deg); } width: 50px;
100% { transform: rotate(360deg); } height: 50px;
} border: 4px solid #e3f2fd;
border-top: 4px solid #1976d2;
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* Hide loading when app is ready */ .loading-text {
#root:not(:empty) + .loading-container { color: #666;
display: none; font-size: 16px;
} }
</style>
</head>
<body>
<div id="root"></div>
<!-- Loading fallback --> @keyframes spin {
<div class="loading-container"> 0% {
<div class="loading-spinner"></div> transform: rotate(0deg);
<div class="loading-text">STUPA PDF API wird geladen...</div> }
</div> 100% {
transform: rotate(360deg);
}
}
<script type="module" src="/src/main.tsx"></script> /* Hide loading when app is ready */
</body> #root:not(:empty) + .loading-container {
display: none;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- Loading fallback -->
<div class="loading-container">
<div class="loading-spinner"></div>
<div class="loading-text">STUPA PDF API wird geladen...</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html> </html>

View File

@ -1,12 +1,13 @@
import "./chunk-KSHCQPUH.js";
import { import {
createSvgIcon createSvgIcon
} from "./chunk-AAWXJNXX.js"; } from "./chunk-UOPBSAPM.js";
import "./chunk-GV7LUFD3.js"; import "./chunk-RBHZELTB.js";
import "./chunk-777DUXNW.js"; import "./chunk-OHENSCKL.js";
import "./chunk-VFR73OBR.js"; import "./chunk-VFR73OBR.js";
import "./chunk-P3ZG2TQF.js"; import "./chunk-AS5ICWL6.js";
import "./chunk-V434JVL2.js";
import "./chunk-J4LPPHPF.js"; import "./chunk-J4LPPHPF.js";
import "./chunk-V434JVL2.js";
import "./chunk-Q7CPF5VB.js"; import "./chunk-Q7CPF5VB.js";
import { import {
require_jsx_runtime require_jsx_runtime

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,13 +1,13 @@
"use client"; "use client";
import { import {
CssBaseline_default CssBaseline_default
} from "./chunk-XJXS7YZH.js"; } from "./chunk-WZBHHRL2.js";
import "./chunk-7JY22UOD.js"; import "./chunk-RS367X47.js";
import "./chunk-GV7LUFD3.js"; import "./chunk-RBHZELTB.js";
import "./chunk-7EG3N7FF.js"; import "./chunk-4EMOJUD5.js";
import "./chunk-P3ZG2TQF.js"; import "./chunk-AS5ICWL6.js";
import "./chunk-V434JVL2.js";
import "./chunk-J4LPPHPF.js"; import "./chunk-J4LPPHPF.js";
import "./chunk-V434JVL2.js";
import "./chunk-Q7CPF5VB.js"; import "./chunk-Q7CPF5VB.js";
import "./chunk-OT5EQO2H.js"; import "./chunk-OT5EQO2H.js";
import "./chunk-OU5AQDZK.js"; import "./chunk-OU5AQDZK.js";

View File

@ -20,10 +20,10 @@ import {
useThemeProps, useThemeProps,
withStyles, withStyles,
withTheme withTheme
} from "./chunk-TGJ54VO5.js"; } from "./chunk-3VXCI53P.js";
import { import {
styled_default styled_default
} from "./chunk-777DUXNW.js"; } from "./chunk-OHENSCKL.js";
import "./chunk-VFR73OBR.js"; import "./chunk-VFR73OBR.js";
import { import {
alpha, alpha,
@ -37,7 +37,7 @@ import {
lighten, lighten,
recomposeColor, recomposeColor,
rgbToHex rgbToHex
} from "./chunk-7EG3N7FF.js"; } from "./chunk-4EMOJUD5.js";
import { import {
StyledEngineProvider, StyledEngineProvider,
createMixins, createMixins,
@ -49,9 +49,9 @@ import {
easing, easing,
identifier_default, identifier_default,
keyframes keyframes
} from "./chunk-P3ZG2TQF.js"; } from "./chunk-AS5ICWL6.js";
import "./chunk-V434JVL2.js";
import "./chunk-J4LPPHPF.js"; import "./chunk-J4LPPHPF.js";
import "./chunk-V434JVL2.js";
import "./chunk-Q7CPF5VB.js"; import "./chunk-Q7CPF5VB.js";
import "./chunk-OT5EQO2H.js"; import "./chunk-OT5EQO2H.js";
import "./chunk-OU5AQDZK.js"; import "./chunk-OU5AQDZK.js";

View File

@ -2,199 +2,214 @@
"hash": "273a9ead", "hash": "273a9ead",
"configHash": "117982b2", "configHash": "117982b2",
"lockfileHash": "c5ca6de2", "lockfileHash": "c5ca6de2",
"browserHash": "91f015c3", "browserHash": "73ff57bb",
"optimized": { "optimized": {
"react": { "react": {
"src": "../../react/index.js", "src": "../../react/index.js",
"file": "react.js", "file": "react.js",
"fileHash": "3de616a3", "fileHash": "10bad303",
"needsInterop": true "needsInterop": true
}, },
"react-dom": { "react-dom": {
"src": "../../react-dom/index.js", "src": "../../react-dom/index.js",
"file": "react-dom.js", "file": "react-dom.js",
"fileHash": "b17586ba", "fileHash": "1d287cdf",
"needsInterop": true "needsInterop": true
}, },
"react/jsx-dev-runtime": { "react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js", "src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js", "file": "react_jsx-dev-runtime.js",
"fileHash": "4dd8a36e", "fileHash": "57fa1fce",
"needsInterop": true "needsInterop": true
}, },
"react/jsx-runtime": { "react/jsx-runtime": {
"src": "../../react/jsx-runtime.js", "src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js", "file": "react_jsx-runtime.js",
"fileHash": "3e8bf7f8", "fileHash": "5e2cb7a3",
"needsInterop": true "needsInterop": true
}, },
"@mui/icons-material": { "@mui/icons-material": {
"src": "../../@mui/icons-material/esm/index.js", "src": "../../@mui/icons-material/esm/index.js",
"file": "@mui_icons-material.js", "file": "@mui_icons-material.js",
"fileHash": "74c54b0e", "fileHash": "2772d121",
"needsInterop": false "needsInterop": false
}, },
"@mui/material": { "@mui/material": {
"src": "../../@mui/material/index.js", "src": "../../@mui/material/index.js",
"file": "@mui_material.js", "file": "@mui_material.js",
"fileHash": "30c75312", "fileHash": "101bd063",
"needsInterop": false "needsInterop": false
}, },
"@mui/material/CssBaseline": { "@mui/material/CssBaseline": {
"src": "../../@mui/material/CssBaseline/index.js", "src": "../../@mui/material/CssBaseline/index.js",
"file": "@mui_material_CssBaseline.js", "file": "@mui_material_CssBaseline.js",
"fileHash": "abd27923", "fileHash": "6bf48544",
"needsInterop": false "needsInterop": false
}, },
"@mui/material/styles": { "@mui/material/styles": {
"src": "../../@mui/material/styles/index.js", "src": "../../@mui/material/styles/index.js",
"file": "@mui_material_styles.js", "file": "@mui_material_styles.js",
"fileHash": "60c02af9", "fileHash": "7afeca8d",
"needsInterop": false "needsInterop": false
}, },
"axios": { "axios": {
"src": "../../axios/index.js", "src": "../../axios/index.js",
"file": "axios.js", "file": "axios.js",
"fileHash": "c71dafe7", "fileHash": "0c218659",
"needsInterop": false "needsInterop": false
}, },
"dayjs": { "dayjs": {
"src": "../../dayjs/dayjs.min.js", "src": "../../dayjs/dayjs.min.js",
"file": "dayjs.js", "file": "dayjs.js",
"fileHash": "d7103ab3", "fileHash": "66b22544",
"needsInterop": true "needsInterop": true
}, },
"notistack": { "notistack": {
"src": "../../notistack/notistack.esm.js", "src": "../../notistack/notistack.esm.js",
"file": "notistack.js", "file": "notistack.js",
"fileHash": "a55ea3bc", "fileHash": "35fcf03f",
"needsInterop": false "needsInterop": false
}, },
"react-dom/client": { "react-dom/client": {
"src": "../../react-dom/client.js", "src": "../../react-dom/client.js",
"file": "react-dom_client.js", "file": "react-dom_client.js",
"fileHash": "5afba54b", "fileHash": "139c4171",
"needsInterop": true "needsInterop": true
}, },
"react-dropzone": { "react-dropzone": {
"src": "../../react-dropzone/dist/es/index.js", "src": "../../react-dropzone/dist/es/index.js",
"file": "react-dropzone.js", "file": "react-dropzone.js",
"fileHash": "69d3e5be", "fileHash": "8baebb70",
"needsInterop": false "needsInterop": false
}, },
"react-query": { "react-query": {
"src": "../../react-query/es/index.js", "src": "../../react-query/es/index.js",
"file": "react-query.js", "file": "react-query.js",
"fileHash": "fdfe1468", "fileHash": "8d74e42c",
"needsInterop": false "needsInterop": false
}, },
"react-query/devtools": { "react-query/devtools": {
"src": "../../react-query/devtools/index.js", "src": "../../react-query/devtools/index.js",
"file": "react-query_devtools.js", "file": "react-query_devtools.js",
"fileHash": "fe78e4ce", "fileHash": "ada752d5",
"needsInterop": true "needsInterop": true
}, },
"react-router-dom": { "react-router-dom": {
"src": "../../react-router-dom/dist/index.js", "src": "../../react-router-dom/dist/index.js",
"file": "react-router-dom.js", "file": "react-router-dom.js",
"fileHash": "5af8cd9b", "fileHash": "f0a6a71c",
"needsInterop": false "needsInterop": false
}, },
"zustand": { "zustand": {
"src": "../../zustand/esm/index.mjs", "src": "../../zustand/esm/index.mjs",
"file": "zustand.js", "file": "zustand.js",
"fileHash": "bad0d582", "fileHash": "114454bc",
"needsInterop": false "needsInterop": false
}, },
"zustand/middleware": { "zustand/middleware": {
"src": "../../zustand/esm/middleware.mjs", "src": "../../zustand/esm/middleware.mjs",
"file": "zustand_middleware.js", "file": "zustand_middleware.js",
"fileHash": "2d4dc727", "fileHash": "9ca25982",
"needsInterop": false "needsInterop": false
}, },
"@mui/x-date-pickers/DatePicker": { "@mui/x-date-pickers/DatePicker": {
"src": "../../@mui/x-date-pickers/DatePicker/index.js", "src": "../../@mui/x-date-pickers/DatePicker/index.js",
"file": "@mui_x-date-pickers_DatePicker.js", "file": "@mui_x-date-pickers_DatePicker.js",
"fileHash": "6852fc7c", "fileHash": "b0101ecd",
"needsInterop": false "needsInterop": false
}, },
"@mui/x-date-pickers/LocalizationProvider": { "@mui/x-date-pickers/LocalizationProvider": {
"src": "../../@mui/x-date-pickers/LocalizationProvider/index.js", "src": "../../@mui/x-date-pickers/LocalizationProvider/index.js",
"file": "@mui_x-date-pickers_LocalizationProvider.js", "file": "@mui_x-date-pickers_LocalizationProvider.js",
"fileHash": "6098f5d9", "fileHash": "1677e2c1",
"needsInterop": false "needsInterop": false
}, },
"@mui/x-date-pickers/AdapterDayjs": { "@mui/x-date-pickers/AdapterDayjs": {
"src": "../../@mui/x-date-pickers/AdapterDayjs/index.js", "src": "../../@mui/x-date-pickers/AdapterDayjs/index.js",
"file": "@mui_x-date-pickers_AdapterDayjs.js", "file": "@mui_x-date-pickers_AdapterDayjs.js",
"fileHash": "ce34b11e", "fileHash": "03554c7f",
"needsInterop": false "needsInterop": false
}, },
"dayjs/locale/de": { "dayjs/locale/de": {
"src": "../../dayjs/locale/de.js", "src": "../../dayjs/locale/de.js",
"file": "dayjs_locale_de.js", "file": "dayjs_locale_de.js",
"fileHash": "7d169904", "fileHash": "2d9cb589",
"needsInterop": true "needsInterop": true
},
"@mui/material/Radio": {
"src": "../../@mui/material/Radio/index.js",
"file": "@mui_material_Radio.js",
"fileHash": "9f27c95b",
"needsInterop": false
} }
}, },
"chunks": { "chunks": {
"chunk-4JMCP6D4": { "chunk-V7IRCJS6": {
"file": "chunk-4JMCP6D4.js" "file": "chunk-V7IRCJS6.js"
},
"chunk-XJXS7YZH": {
"file": "chunk-XJXS7YZH.js"
},
"chunk-G2RL7QGH": {
"file": "chunk-G2RL7QGH.js"
},
"chunk-UTSM4JAU": {
"file": "chunk-UTSM4JAU.js"
},
"chunk-AAWXJNXX": {
"file": "chunk-AAWXJNXX.js"
},
"chunk-7JY22UOD": {
"file": "chunk-7JY22UOD.js"
},
"chunk-GV7LUFD3": {
"file": "chunk-GV7LUFD3.js"
},
"chunk-TQAOEBXN": {
"file": "chunk-TQAOEBXN.js"
},
"chunk-TGJ54VO5": {
"file": "chunk-TGJ54VO5.js"
},
"chunk-777DUXNW": {
"file": "chunk-777DUXNW.js"
},
"chunk-VFR73OBR": {
"file": "chunk-VFR73OBR.js"
},
"chunk-7EG3N7FF": {
"file": "chunk-7EG3N7FF.js"
},
"chunk-P3ZG2TQF": {
"file": "chunk-P3ZG2TQF.js"
},
"chunk-V434JVL2": {
"file": "chunk-V434JVL2.js"
},
"chunk-J4LPPHPF": {
"file": "chunk-J4LPPHPF.js"
}, },
"chunk-RW3DMCEO": { "chunk-RW3DMCEO": {
"file": "chunk-RW3DMCEO.js" "file": "chunk-RW3DMCEO.js"
}, },
"chunk-Q7CPF5VB": {
"file": "chunk-Q7CPF5VB.js"
},
"chunk-4ONYKVE4": { "chunk-4ONYKVE4": {
"file": "chunk-4ONYKVE4.js" "file": "chunk-4ONYKVE4.js"
}, },
"chunk-2XE5AC26": {
"file": "chunk-2XE5AC26.js"
},
"chunk-GNRT2KPH": {
"file": "chunk-GNRT2KPH.js"
},
"chunk-KSHCQPUH": {
"file": "chunk-KSHCQPUH.js"
},
"chunk-JI273UPC": {
"file": "chunk-JI273UPC.js"
},
"chunk-OSTWVXC2": {
"file": "chunk-OSTWVXC2.js"
},
"chunk-UOPBSAPM": {
"file": "chunk-UOPBSAPM.js"
},
"chunk-UTSM4JAU": {
"file": "chunk-UTSM4JAU.js"
},
"chunk-WRD5HZVH": { "chunk-WRD5HZVH": {
"file": "chunk-WRD5HZVH.js" "file": "chunk-WRD5HZVH.js"
}, },
"chunk-WZBHHRL2": {
"file": "chunk-WZBHHRL2.js"
},
"chunk-RS367X47": {
"file": "chunk-RS367X47.js"
},
"chunk-RBHZELTB": {
"file": "chunk-RBHZELTB.js"
},
"chunk-3VXCI53P": {
"file": "chunk-3VXCI53P.js"
},
"chunk-OHENSCKL": {
"file": "chunk-OHENSCKL.js"
},
"chunk-VFR73OBR": {
"file": "chunk-VFR73OBR.js"
},
"chunk-4EMOJUD5": {
"file": "chunk-4EMOJUD5.js"
},
"chunk-AS5ICWL6": {
"file": "chunk-AS5ICWL6.js"
},
"chunk-J4LPPHPF": {
"file": "chunk-J4LPPHPF.js"
},
"chunk-V434JVL2": {
"file": "chunk-V434JVL2.js"
},
"chunk-Q7CPF5VB": {
"file": "chunk-Q7CPF5VB.js"
},
"chunk-OT5EQO2H": { "chunk-OT5EQO2H": {
"file": "chunk-OT5EQO2H.js" "file": "chunk-OT5EQO2H.js"
}, },

View File

@ -29,10 +29,10 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
useQueryErrorResetBoundary useQueryErrorResetBoundary
} from "./chunk-4JMCP6D4.js"; } from "./chunk-2XE5AC26.js";
import "./chunk-UTSM4JAU.js"; import "./chunk-UTSM4JAU.js";
import "./chunk-Q7CPF5VB.js";
import "./chunk-WRD5HZVH.js"; import "./chunk-WRD5HZVH.js";
import "./chunk-Q7CPF5VB.js";
import "./chunk-OU5AQDZK.js"; import "./chunk-OU5AQDZK.js";
import "./chunk-EWTE5DHJ.js"; import "./chunk-EWTE5DHJ.js";
init_es(); init_es();

View File

@ -1,8 +1,9 @@
import { import {
es_exports, es_exports,
init_es init_es
} from "./chunk-4JMCP6D4.js"; } from "./chunk-2XE5AC26.js";
import "./chunk-UTSM4JAU.js"; import "./chunk-UTSM4JAU.js";
import "./chunk-WRD5HZVH.js";
import { import {
require_extends, require_extends,
require_objectWithoutPropertiesLoose require_objectWithoutPropertiesLoose
@ -11,7 +12,6 @@ import {
require_interopRequireDefault require_interopRequireDefault
} from "./chunk-V434JVL2.js"; } from "./chunk-V434JVL2.js";
import "./chunk-Q7CPF5VB.js"; import "./chunk-Q7CPF5VB.js";
import "./chunk-WRD5HZVH.js";
import { import {
require_react require_react
} from "./chunk-OU5AQDZK.js"; } from "./chunk-OU5AQDZK.js";

View File

@ -43,6 +43,7 @@ interface AttachmentManagerProps {
paKey?: string; paKey?: string;
readOnly?: boolean; readOnly?: boolean;
isAdmin?: boolean; isAdmin?: boolean;
onAttachmentsChange?: () => void;
} }
const AttachmentManager: React.FC<AttachmentManagerProps> = ({ const AttachmentManager: React.FC<AttachmentManagerProps> = ({
@ -50,6 +51,7 @@ const AttachmentManager: React.FC<AttachmentManagerProps> = ({
paKey, paKey,
readOnly = false, readOnly = false,
isAdmin = false, isAdmin = false,
onAttachmentsChange,
}) => { }) => {
const [attachments, setAttachments] = useState<Attachment[]>([]); const [attachments, setAttachments] = useState<Attachment[]>([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@ -109,6 +111,9 @@ const AttachmentManager: React.FC<AttachmentManagerProps> = ({
const data = await response.json(); const data = await response.json();
setAttachments(data); setAttachments(data);
if (onAttachmentsChange) {
onAttachmentsChange();
}
} catch (err) { } catch (err) {
setError("Fehler beim Laden der Anhänge"); setError("Fehler beim Laden der Anhänge");
console.error("Error loading attachments:", err); console.error("Error loading attachments:", err);
@ -272,6 +277,9 @@ const AttachmentManager: React.FC<AttachmentManagerProps> = ({
loadAttachments(); loadAttachments();
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setAttachmentToDelete(null); setAttachmentToDelete(null);
if (onAttachmentsChange) {
onAttachmentsChange();
}
} catch (err) { } catch (err) {
setError("Fehler beim Löschen der Datei"); setError("Fehler beim Löschen der Datei");
console.error("Error deleting file:", err); console.error("Error deleting file:", err);

View File

@ -0,0 +1,840 @@
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;

View File

@ -0,0 +1,248 @@
import React, { useState, useEffect } from "react";
import {
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
Box,
Typography,
Tooltip,
CircularProgress,
Button,
} from "@mui/material";
import {
Warning,
CheckCircle,
Receipt,
Info,
DoNotDisturb,
} from "@mui/icons-material";
import ComparisonOffersDialog from "./ComparisonOffersDialog";
import { useApplicationStore } from "../../store/applicationStore";
interface CostPosition {
name: string;
amountEur: number;
}
interface CostPositionOffersInfo {
offerCount: number;
noOffersRequired: boolean;
hasJustification: boolean;
}
interface CostPositionsListProps {
costs: CostPosition[];
paId: string;
paKey?: string;
isAdmin?: boolean;
readOnly?: boolean;
attachments?: Array<{
id: number;
filename: string;
}>;
}
const CostPositionsList: React.FC<CostPositionsListProps> = ({
costs,
paId,
paKey,
isAdmin = false,
readOnly = false,
attachments = [],
}) => {
const [selectedPosition, setSelectedPosition] = useState<number | null>(null);
const [offersInfo, setOffersInfo] = useState<
Record<number, CostPositionOffersInfo>
>({});
const [loading, setLoading] = useState(true);
const { masterKey } = useApplicationStore();
// Load offers info for all cost positions
useEffect(() => {
loadAllOffersInfo();
}, [costs, paId]);
const loadAllOffersInfo = async () => {
setLoading(true);
try {
const headers: HeadersInit = {};
if (isAdmin && masterKey) {
headers["X-MASTER-KEY"] = masterKey;
} else if (paKey) {
headers["X-PA-KEY"] = paKey;
}
const infoPromises = costs.map(async (cost, index) => {
// Only load offers for cost positions with name and amount
if (!cost.name || cost.amountEur <= 0) {
return null;
}
try {
const response = await fetch(
`/api/applications/${paId}/costs/${index}/offers`,
{ headers },
);
if (response.ok) {
const data = await response.json();
return {
index,
info: {
offerCount: data.offers?.length || 0,
noOffersRequired: data.no_offers_required || false,
hasJustification: !!data.justification,
},
};
} else if (response.status === 404) {
// Cost position not found - this can happen if the backend has a different view of the costs array
return {
index,
info: {
offerCount: 0,
noOffersRequired: false,
hasJustification: false,
},
};
}
} catch (err) {
console.error(`Error loading offers for position ${index}:`, err);
}
return null;
});
const results = await Promise.all(infoPromises);
const newOffersInfo: Record<number, CostPositionOffersInfo> = {};
results.forEach((result) => {
if (result) {
newOffersInfo[result.index] = result.info;
}
});
setOffersInfo(newOffersInfo);
} catch (err) {
console.error("Error loading offers info:", err);
} finally {
setLoading(false);
}
};
const getPositionStatus = (index: number) => {
const info = offersInfo[index];
if (!info) return "loading";
if (info.noOffersRequired && info.hasJustification) {
return "justified";
}
if (info.offerCount >= 3) {
return "complete";
}
return "incomplete";
};
const getStatusIcon = (status: string) => {
switch (status) {
case "complete":
return <CheckCircle color="success" />;
case "justified":
return <DoNotDisturb sx={{ color: "text.secondary" }} />;
case "incomplete":
return <Warning color="warning" />;
default:
return <CircularProgress size={20} />;
}
};
const getStatusText = (index: number) => {
const info = offersInfo[index];
if (!info) return "";
if (info.noOffersRequired) {
return "Keine Angebote erforderlich (begründet)";
}
return `${info.offerCount} von 3 Angeboten`;
};
const handleDialogClose = () => {
setSelectedPosition(null);
loadAllOffersInfo(); // Reload info after dialog closes
};
return (
<>
<List>
{costs
.map((cost, index) => ({ cost, originalIndex: index }))
.filter(({ cost }) => cost.name && cost.amountEur > 0)
.map(({ cost, originalIndex }) => {
const status = getPositionStatus(originalIndex);
const needsAttention = status === "incomplete" && !loading;
return (
<ListItem
key={originalIndex}
sx={{
borderBottom: "1px solid",
borderColor: "divider",
"&:last-child": { borderBottom: "none" },
"&:hover": {
backgroundColor: "action.hover",
},
}}
>
<Box sx={{ mr: 2 }}>{getStatusIcon(status)}</Box>
<ListItemText
primary={<Typography variant="body1">{cost.name}</Typography>}
secondary={
<Box>
<Typography variant="body2" color="primary">
{cost.amountEur.toFixed(2)}
</Typography>
{!loading && (
<Typography variant="caption" color="text.secondary">
{getStatusText(originalIndex)}
</Typography>
)}
</Box>
}
/>
<ListItemSecondaryAction>
<Button
variant="outlined"
size="small"
startIcon={<Receipt />}
onClick={() => setSelectedPosition(originalIndex)}
disabled={loading}
>
Angebote
</Button>
</ListItemSecondaryAction>
</ListItem>
);
})}
</List>
{/* Comparison Offers Dialog */}
{selectedPosition !== null && (
<ComparisonOffersDialog
open={true}
onClose={handleDialogClose}
paId={paId}
paKey={paKey}
costPositionIndex={selectedPosition}
costPositionName={costs[selectedPosition].name}
costPositionAmount={costs[selectedPosition].amountEur}
attachments={attachments}
isAdmin={isAdmin}
readOnly={readOnly}
/>
)}
</>
);
};
export default CostPositionsList;

View File

@ -45,6 +45,7 @@ import { VSM_FINANCING_LABELS, QSM_FINANCING_LABELS } from "../types/api";
// Components // Components
import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner"; import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner";
import AttachmentManager from "../components/Attachments/AttachmentManager"; import AttachmentManager from "../components/Attachments/AttachmentManager";
import CostPositionsList from "../components/ComparisonOffers/CostPositionsList";
// Utils // Utils
import { translateStatus, getStatusColor } from "../utils/statusTranslations"; import { translateStatus, getStatusColor } from "../utils/statusTranslations";
@ -69,15 +70,18 @@ const AdminApplicationView: React.FC = () => {
} = useApplicationStore(); } = useApplicationStore();
// Local state // Local state
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [editingStatus, setEditingStatus] = useState(false); const [editingStatus, setEditingStatus] = useState(false);
const [newStatus, setNewStatus] = useState(""); const [newStatus, setNewStatus] = useState("");
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showResetDialog, setShowResetDialog] = useState(false); const [showResetDialog, setShowResetDialog] = useState(false);
const [newCredentials, setNewCredentials] = useState<{ const [newCredentials, setNewCredentials] = useState<{
pa_id: string; pa_id: string;
pa_key: string; pa_key: string;
} | null>(null); } | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null); const [copiedField, setCopiedField] = useState<string | null>(null);
const [attachments, setAttachments] = useState<
Array<{ id: number; filename: string }>
>([]);
// Redirect if not admin // Redirect if not admin
useEffect(() => { useEffect(() => {
@ -87,6 +91,27 @@ const AdminApplicationView: React.FC = () => {
} }
}, [isAdmin, navigate]); }, [isAdmin, navigate]);
// Load attachments
const loadAttachments = async () => {
try {
const headers: HeadersInit = {};
if (masterKey) {
headers["X-MASTER-KEY"] = masterKey;
}
const response = await fetch(`/api/applications/${paId}/attachments`, {
headers,
});
if (response.ok) {
const data = await response.json();
setAttachments(data);
}
} catch (err) {
console.error("Error loading attachments:", err);
}
};
// Load application on mount // Load application on mount
useEffect(() => { useEffect(() => {
if (paId && isAdmin) { if (paId && isAdmin) {
@ -94,6 +119,13 @@ const AdminApplicationView: React.FC = () => {
} }
}, [paId, isAdmin, loadApplicationAdmin]); }, [paId, isAdmin, loadApplicationAdmin]);
// Load attachments when application is loaded
useEffect(() => {
if (currentApplication && paId) {
loadAttachments();
}
}, [currentApplication, paId]);
// Update status when application loads // Update status when application loads
useEffect(() => { useEffect(() => {
if (currentApplication) { if (currentApplication) {
@ -610,27 +642,13 @@ const AdminApplicationView: React.FC = () => {
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Kostenpositionen Kostenpositionen
</Typography> </Typography>
{formData.costs <CostPositionsList
.filter((cost) => cost.amountEur && cost.amountEur > 0) costs={formData.costs}
.map((cost, index) => ( paId={paId!}
<Box isAdmin={true}
key={index} readOnly={false}
sx={{ attachments={attachments}
display: "flex", />
justifyContent: "space-between",
py: 1,
}}
>
<Typography variant="body2">{cost.name}</Typography>
<Typography variant="body2" sx={{ fontWeight: "medium" }}>
{cost.amountEur?.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
}) || "0,00 €"}
</Typography>
</Box>
))}
<Divider sx={{ my: 1 }} />
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@ -780,7 +798,12 @@ const AdminApplicationView: React.FC = () => {
</Grid> </Grid>
{/* Attachments */} {/* Attachments */}
<AttachmentManager paId={paId!} readOnly={false} isAdmin={true} /> <AttachmentManager
paId={paId!}
readOnly={false}
isAdmin={true}
onAttachmentsChange={loadAttachments}
/>
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<Dialog <Dialog

View File

@ -345,7 +345,7 @@ const HomePage: React.FC = () => {
STUPA Antragsystem STUPA Antragsystem
</Typography> </Typography>
<Typography variant="h6" color="text.secondary" sx={{ mb: 3 }}> <Typography variant="h6" color="text.secondary" sx={{ mb: 3 }}>
Erstellen Sie Ihren Studienantrag oder verwalten Sie bestehende Erstellen Sie Ihren Projektantrag oder verwalten Sie bestehende
Anträge Anträge
</Typography> </Typography>

View File

@ -38,6 +38,7 @@ import { VSM_FINANCING_LABELS, QSM_FINANCING_LABELS } from "../types/api";
// Components // Components
import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner"; import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner";
import AttachmentManager from "../components/Attachments/AttachmentManager"; import AttachmentManager from "../components/Attachments/AttachmentManager";
import CostPositionsList from "../components/ComparisonOffers/CostPositionsList";
// Utils // Utils
import { translateStatus, getStatusColor } from "../utils/statusTranslations"; import { translateStatus, getStatusColor } from "../utils/statusTranslations";
@ -60,10 +61,14 @@ const ViewApplicationPage: React.FC = () => {
paId: storePaId, paId: storePaId,
paKey, paKey,
isAdmin, isAdmin,
masterKey,
} = useApplicationStore(); } = useApplicationStore();
// Local state // Local state
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [attachments, setAttachments] = useState<
Array<{ id: number; filename: string }>
>([]);
// Load application on mount // Load application on mount
useEffect(() => { useEffect(() => {
@ -76,7 +81,36 @@ const ViewApplicationPage: React.FC = () => {
} }
}, [paId, storePaId, paKey, isAdmin, loadApplication, loadApplicationAdmin]); }, [paId, storePaId, paKey, isAdmin, loadApplication, loadApplicationAdmin]);
// Load attachments when application is loaded
useEffect(() => {
if (currentApplication && paId) {
loadAttachments();
}
}, [currentApplication, paId]);
// Handle download PDF // Handle download PDF
const loadAttachments = async () => {
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}/attachments`, {
headers,
});
if (response.ok) {
const data = await response.json();
setAttachments(data);
}
} catch (err) {
console.error("Error loading attachments:", err);
}
};
const handleDownload = async () => { const handleDownload = async () => {
if (paId) { if (paId) {
await downloadApplicationPdf(paId); await downloadApplicationPdf(paId);
@ -551,27 +585,15 @@ const ViewApplicationPage: React.FC = () => {
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Kostenpositionen Kostenpositionen
</Typography> </Typography>
{formData.costs <CostPositionsList
.filter((cost) => cost.amountEur && cost.amountEur > 0) costs={formData.costs}
.map((cost, index) => ( paId={paId!}
<Box paKey={paKey || undefined}
key={index} isAdmin={isAdmin}
sx={{ readOnly={!isAdmin && currentApplication.status !== "new"}
display: "flex", attachments={attachments}
justifyContent: "space-between", />
py: 1,
}}
>
<Typography variant="body2">{cost.name}</Typography>
<Typography variant="body2" sx={{ fontWeight: "medium" }}>
{cost.amountEur?.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
}) || "0,00 €"}
</Typography>
</Box>
))}
<Divider sx={{ my: 1 }} />
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@ -579,19 +601,14 @@ const ViewApplicationPage: React.FC = () => {
fontWeight: "bold", fontWeight: "bold",
}} }}
> >
<Typography variant="subtitle1" color="text.primary"> <Typography variant="body1">Gesamt</Typography>
Gesamt: <Typography variant="body1">
</Typography> {formData.costs
<Typography variant="subtitle1" color="primary"> .reduce((sum, cost) => sum + (cost.amountEur || 0), 0)
{( .toLocaleString("de-DE", {
formData.costs?.reduce( style: "currency",
(sum, cost) => sum + (cost.amountEur || 0), currency: "EUR",
0, })}
) || 0
).toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
})}
</Typography> </Typography>
</Box> </Box>
</Paper> </Paper>
@ -678,6 +695,7 @@ const ViewApplicationPage: React.FC = () => {
paKey={paKey || undefined} paKey={paKey || undefined}
readOnly={!isAdmin && currentApplication.status !== "new"} readOnly={!isAdmin && currentApplication.status !== "new"}
isAdmin={isAdmin} isAdmin={isAdmin}
onAttachmentsChange={loadAttachments}
/> />
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}

View File

@ -1,63 +1,63 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import react from '@vitejs/plugin-react' import react from "@vitejs/plugin-react";
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from "vite-plugin-pwa";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: "autoUpdate",
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'] globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
}, },
manifest: { manifest: {
name: 'STUPA PDF API', name: "STUPA PDF API",
short_name: 'STUPA', short_name: "STUPA",
description: 'Studienantrag PDF Management System', description: "Projektantragsmanagementsystem",
theme_color: '#1976d2', theme_color: "#1976d2",
background_color: '#ffffff', background_color: "#ffffff",
icons: [ icons: [
{ {
src: '/icon-192x192.png', src: "/icon-192x192.png",
sizes: '192x192', sizes: "192x192",
type: 'image/png' type: "image/png",
}, },
{ {
src: '/icon-512x512.png', src: "/icon-512x512.png",
sizes: '512x512', sizes: "512x512",
type: 'image/png' type: "image/png",
} },
] ],
} },
}) }),
], ],
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {
'/api': { "/api": {
target: 'http://localhost:8000', target: "http://localhost:8000",
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '') rewrite: (path) => path.replace(/^\/api/, ""),
} },
} },
}, },
build: { build: {
outDir: 'dist', outDir: "dist",
sourcemap: true, sourcemap: true,
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'], vendor: ["react", "react-dom", "react-router-dom"],
mui: ['@mui/material', '@mui/icons-material'], mui: ["@mui/material", "@mui/icons-material"],
forms: ['react-hook-form', '@hookform/resolvers', 'yup'] forms: ["react-hook-form", "@hookform/resolvers", "yup"],
} },
} },
} },
}, },
resolve: { resolve: {
alias: { alias: {
'@': '/src' "@": "/src",
} },
} },
}) });

View File

@ -0,0 +1,41 @@
-- Migration: Add comparison offers tables
-- Description: Add tables for storing comparison offers for cost positions
-- Date: 2024
-- Create comparison offers table
CREATE TABLE IF NOT EXISTS `comparison_offers` (
`id` INT NOT NULL AUTO_INCREMENT,
`application_id` INT NOT NULL,
`cost_position_index` INT NOT NULL,
`supplier_name` VARCHAR(255) NOT NULL,
`amount` INT NOT NULL COMMENT 'Amount in cents',
`description` TEXT,
`attachment_id` INT,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_offer_supplier` (`application_id`, `cost_position_index`, `supplier_name`),
INDEX `idx_application_id` (`application_id`),
INDEX `idx_cost_position` (`application_id`, `cost_position_index`),
INDEX `idx_attachment_id` (`attachment_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Create cost position justifications table
CREATE TABLE IF NOT EXISTS `cost_position_justifications` (
`id` INT NOT NULL AUTO_INCREMENT,
`application_id` INT NOT NULL,
`cost_position_index` INT NOT NULL,
`no_offers_required` INT NOT NULL DEFAULT 0 COMMENT 'Boolean: 1 if no offers are required',
`justification` TEXT COMMENT 'Required when no_offers_required is 1',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_position_justification` (`application_id`, `cost_position_index`),
INDEX `idx_application_id` (`application_id`),
INDEX `idx_no_offers` (`no_offers_required`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Note: Foreign key constraints are not added because the Application table
-- doesn't have a direct foreign key relationship setup in the current schema.
-- The application_id references the id column in the applications table.
-- The attachment_id references the id column in the attachments table.

View File

@ -0,0 +1,10 @@
-- Add is_preferred column to comparison_offers table
ALTER TABLE comparison_offers
ADD COLUMN is_preferred BOOLEAN NOT NULL DEFAULT FALSE;
-- Add index for efficient queries
CREATE INDEX idx_comparison_offers_preferred
ON comparison_offers(application_id, cost_position_index, is_preferred);
-- Ensure only one offer per cost position can be preferred
-- This is enforced at the application level

View File

@ -0,0 +1,14 @@
-- Migration: Add URL column to comparison_offers table
-- Description: Add optional URL field to store links to online offers
-- Date: 2024
-- Add URL column to comparison_offers table
ALTER TABLE `comparison_offers`
ADD COLUMN `url` VARCHAR(500) DEFAULT NULL COMMENT 'Optional URL to offer document or webpage'
AFTER `description`;
-- Add index for URL column for better search performance
CREATE INDEX `idx_url` ON `comparison_offers` (`url`);
-- Update existing constraint to ensure either URL or attachment is provided
-- Note: This is handled at application level since MySQL doesn't support complex CHECK constraints

View File

@ -38,7 +38,7 @@ from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import ( from sqlalchemy import (
create_engine, Column, Integer, String, Text, DateTime, JSON as SAJSON, create_engine, Column, Integer, String, Text, DateTime, JSON as SAJSON,
select, func, UniqueConstraint select, func, UniqueConstraint, Boolean, Index
) )
from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.orm import declarative_base, sessionmaker, Session from sqlalchemy.orm import declarative_base, sessionmaker, Session
@ -135,6 +135,48 @@ class ApplicationAttachment(Base):
) )
class ComparisonOffer(Base):
__tablename__ = "comparison_offers"
id = Column(Integer, primary_key=True, autoincrement=True)
application_id = Column(Integer, nullable=False, index=True)
cost_position_index = Column(Integer, nullable=False) # Index of the cost position in the array
supplier_name = Column(String(255), nullable=False)
amount = Column(Integer, nullable=False) # Amount in cents
description = Column(Text, nullable=True)
url = Column(String(500), nullable=True) # Optional URL to offer
attachment_id = Column(Integer, nullable=True) # Link to attachment
is_preferred = Column(Boolean, default=False, nullable=False) # Whether this is the preferred offer
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Create composite index for efficient queries
__table_args__ = (
UniqueConstraint('application_id', 'cost_position_index', 'supplier_name', name='uq_offer'),
Index('idx_app_cost', 'application_id', 'cost_position_index'),
)
class CostPositionJustification(Base):
__tablename__ = "cost_position_justifications"
id = Column(Integer, primary_key=True, autoincrement=True)
application_id = Column(Integer, nullable=False, index=True)
cost_position_index = Column(Integer, nullable=False)
no_offers_required = Column(Integer, nullable=False, default=0) # Boolean as INT
justification = Column(Text, nullable=True) # Required when no_offers_required is True
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
UniqueConstraint("application_id", "cost_position_index", name="uq_position_justification"),
)
def init_db(): def init_db():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@ -231,6 +273,38 @@ class AttachmentUploadResponse(BaseModel):
filename: str filename: str
size: int size: int
class ComparisonOfferCreate(BaseModel):
supplier_name: str = Field(..., min_length=1, max_length=255)
amount: float = Field(..., gt=0) # Amount in EUR
description: Optional[str] = None
url: Optional[str] = Field(None, max_length=500, pattern="^https?://")
attachment_id: Optional[int] = None
is_preferred: Optional[bool] = False
class ComparisonOfferResponse(BaseModel):
id: int
supplier_name: str
amount: float # Amount in EUR
description: Optional[str]
url: Optional[str]
attachment_id: Optional[int]
is_preferred: bool
created_at: datetime
class CostPositionOffersResponse(BaseModel):
cost_position_index: int
offers: List[ComparisonOfferResponse]
no_offers_required: bool
justification: Optional[str]
class CostPositionJustificationRequest(BaseModel):
no_offers_required: bool
justification: Optional[str] = Field(None, min_length=10)
# ------------------------------------------------------------- # -------------------------------------------------------------
# Auth-Helpers # Auth-Helpers
# ------------------------------------------------------------- # -------------------------------------------------------------
@ -241,10 +315,12 @@ def _auth_from_request(
key_header: Optional[str], key_header: Optional[str],
key_query: Optional[str], key_query: Optional[str],
master_header: Optional[str], master_header: Optional[str],
x_forwarded_for: Optional[str] = None,
) -> dict: ) -> dict:
# Ratelimit (IP-unabhängig auf Key/Master) # Ratelimit (IP-unabhängig auf Key/Master)
if master_header: if master_header:
_rate_limit(f"MASTER:{master_header}", RATE_KEY_PER_MIN) if not should_skip_rate_limit(x_forwarded_for):
_rate_limit(f"MASTER:{master_header}", RATE_KEY_PER_MIN)
if not MASTER_KEY or master_header != MASTER_KEY: if not MASTER_KEY or master_header != MASTER_KEY:
raise HTTPException(status_code=403, detail="Invalid master key") raise HTTPException(status_code=403, detail="Invalid master key")
return {"scope": "master"} return {"scope": "master"}
@ -257,7 +333,8 @@ def _auth_from_request(
if not supplied: if not supplied:
raise HTTPException(status_code=401, detail="Missing key") raise HTTPException(status_code=401, detail="Missing key")
_rate_limit(f"APPKEY:{pa_id}", RATE_KEY_PER_MIN) if not should_skip_rate_limit(x_forwarded_for):
_rate_limit(f"APPKEY:{pa_id}", RATE_KEY_PER_MIN)
app = db.execute(select(Application).where(Application.pa_id == pa_id)).scalar_one_or_none() app = db.execute(select(Application).where(Application.pa_id == pa_id)).scalar_one_or_none()
if not app: if not app:
@ -278,10 +355,22 @@ app = FastAPI(title="STUPA PDF API", version="1.0.0")
def _startup(): def _startup():
init_db() init_db()
# Helper function to check if we should skip rate limiting
def should_skip_rate_limit(ip: str = "") -> bool:
"""Check if rate limiting should be skipped for localhost/Docker IPs"""
if not ip:
return False
return ip in ["127.0.0.1", "localhost", "172.26.0.1", "unknown"] or ip.startswith("172.")
# Globales IP-Ratelimit (sehr einfach) per Request # Globales IP-Ratelimit (sehr einfach) per Request
def rate_limit_ip(ip: str): def rate_limit_ip(ip: str):
if not ip: if not ip:
ip = "unknown" ip = "unknown"
# Skip rate limiting for localhost and Docker internal IPs
if should_skip_rate_limit(ip):
return
_rate_limit(f"IP:{ip}", RATE_IP_PER_MIN) _rate_limit(f"IP:{ip}", RATE_IP_PER_MIN)
def get_db(): def get_db():
@ -361,7 +450,7 @@ def _sanitize_payload_for_db(payload: Dict[str, Any]) -> Dict[str, Any]:
def create_application( def create_application(
response: Response, response: Response,
variant: Optional[str] = Query(None, description="QSM|VSM|AUTO"), variant: Optional[str] = Query(None, description="QSM|VSM|AUTO"),
return_format: str = Query("pdf", regex="^(pdf|json)$"), return_format: str = Query("pdf", pattern="^(pdf|json)$"),
pdf: Optional[UploadFile] = File(None, description="PDF Upload (Alternative zu form_json)"), pdf: Optional[UploadFile] = File(None, description="PDF Upload (Alternative zu form_json)"),
form_json_b64: Optional[str] = Form(None, description="Base64-kodiertes Roh-Form-JSON (Alternative zu Datei)"), form_json_b64: Optional[str] = Form(None, description="Base64-kodiertes Roh-Form-JSON (Alternative zu Datei)"),
x_forwarded_for: Optional[str] = Header(None), x_forwarded_for: Optional[str] = Header(None),
@ -454,7 +543,7 @@ def create_application(
def update_application( def update_application(
pa_id: str, pa_id: str,
response: Response, response: Response,
return_format: str = Query("pdf", regex="^(pdf|json)$"), return_format: str = Query("pdf", pattern="^(pdf|json)$"),
variant: Optional[str] = Query(None), variant: Optional[str] = Query(None),
pdf: Optional[UploadFile] = File(None), pdf: Optional[UploadFile] = File(None),
form_json_b64: Optional[str] = Form(None), form_json_b64: Optional[str] = Form(None),
@ -464,7 +553,7 @@ def update_application(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
rate_limit_ip(x_forwarded_for or "") rate_limit_ip(x_forwarded_for or "")
auth = _auth_from_request(db, pa_id, x_pa_key, None, x_master_key) auth = _auth_from_request(db, pa_id, x_pa_key, None, x_master_key, x_forwarded_for)
app_row: Application = auth.get("app") app_row: Application = auth.get("app")
if not app_row and auth["scope"] != "master": if not app_row and auth["scope"] != "master":
raise HTTPException(status_code=404, detail="Application not found") raise HTTPException(status_code=404, detail="Application not found")
@ -531,7 +620,7 @@ def update_application(
@app.get("/applications/{pa_id}") @app.get("/applications/{pa_id}")
def get_application( def get_application(
pa_id: str, pa_id: str,
format: str = Query("json", regex="^(json|pdf)$"), format: str = Query("json", pattern="^(json|pdf)$"),
x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"), x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
key: Optional[str] = Query(None, description="Alternative zum Header für den App-Key"), key: Optional[str] = Query(None, description="Alternative zum Header für den App-Key"),
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"), x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
@ -539,7 +628,7 @@ def get_application(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
rate_limit_ip(x_forwarded_for or "") rate_limit_ip(x_forwarded_for or "")
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key) auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key, x_forwarded_for)
app_row: Application = auth.get("app") app_row: Application = auth.get("app")
if not app_row and auth["scope"] != "master": if not app_row and auth["scope"] != "master":
raise HTTPException(status_code=404, detail="Application not found") raise HTTPException(status_code=404, detail="Application not found")
@ -659,7 +748,7 @@ def set_status(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
rate_limit_ip(x_forwarded_for or "") rate_limit_ip(x_forwarded_for or "")
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key) auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key, x_forwarded_for)
app_row: Application = auth.get("app") app_row: Application = auth.get("app")
if not app_row and auth["scope"] != "master": if not app_row and auth["scope"] != "master":
raise HTTPException(status_code=404, detail="Application not found") raise HTTPException(status_code=404, detail="Application not found")
@ -691,7 +780,7 @@ def delete_application(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
rate_limit_ip(x_forwarded_for or "") rate_limit_ip(x_forwarded_for or "")
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key) auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key, x_forwarded_for)
app_row: Application = auth.get("app") app_row: Application = auth.get("app")
if not app_row and auth["scope"] != "master": if not app_row and auth["scope"] != "master":
raise HTTPException(status_code=404, detail="Application not found") raise HTTPException(status_code=404, detail="Application not found")
@ -806,7 +895,7 @@ async def upload_attachment(
): ):
"""Upload an attachment for an application""" """Upload an attachment for an application"""
rate_limit_ip(x_forwarded_for or "") rate_limit_ip(x_forwarded_for or "")
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key) auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key, x_forwarded_for)
if not auth: if not auth:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
@ -870,7 +959,7 @@ def list_attachments(
): ):
"""List all attachments for an application""" """List all attachments for an application"""
rate_limit_ip(x_forwarded_for or "") rate_limit_ip(x_forwarded_for or "")
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key) auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key, x_forwarded_for)
if not auth: if not auth:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
@ -908,7 +997,7 @@ def download_attachment(
): ):
"""Download a specific attachment""" """Download a specific attachment"""
rate_limit_ip(x_forwarded_for or "") rate_limit_ip(x_forwarded_for or "")
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key) auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key, x_forwarded_for)
if not auth: if not auth:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
@ -951,7 +1040,7 @@ def delete_attachment(
): ):
"""Delete a specific attachment""" """Delete a specific attachment"""
rate_limit_ip(x_forwarded_for or "") rate_limit_ip(x_forwarded_for or "")
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key) auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key, x_forwarded_for)
if not auth: if not auth:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
@ -976,3 +1065,285 @@ def delete_attachment(
db.commit() db.commit()
return {"detail": "Attachment deleted successfully"} return {"detail": "Attachment deleted successfully"}
# -------------------------------------------------------------
# Comparison Offers Endpoints
# -------------------------------------------------------------
@app.post("/applications/{pa_id}/costs/{cost_position_index}/offers", response_model=ComparisonOfferResponse)
async def create_comparison_offer(
pa_id: str,
cost_position_index: int,
offer: ComparisonOfferCreate,
x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
key: Optional[str] = Query(None),
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
x_forwarded_for: Optional[str] = Header(None),
db: Session = Depends(get_db),
):
"""Create a comparison offer for a cost position"""
rate_limit_ip(x_forwarded_for or "")
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key, x_forwarded_for)
# Check if application exists
app = db.query(Application).filter(Application.pa_id == pa_id).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
# Validate cost position index
payload = app.payload_json
costs = payload.get("pa", {}).get("project", {}).get("costs", [])
# print(f"DEBUG: Application {pa_id} has {len(costs)} cost positions, requesting index {cost_position_index}")
# print(f"DEBUG: Costs array: {costs}")
if cost_position_index < 0 or cost_position_index >= len(costs):
raise HTTPException(status_code=404, detail=f"Cost position not found: index {cost_position_index} out of range (0-{len(costs)-1})")
# Validate that either URL or attachment is provided
if not offer.url and not offer.attachment_id:
raise HTTPException(status_code=400, detail="Either URL or attachment is required")
# Validate URL format if provided
if offer.url:
import re
if not re.match(r'^https?://', offer.url):
raise HTTPException(status_code=400, detail="URL must start with http:// or https://")
# Check if attachment exists and belongs to this application
if offer.attachment_id:
app_attachment = db.query(ApplicationAttachment).filter(
ApplicationAttachment.application_id == app.id,
ApplicationAttachment.attachment_id == offer.attachment_id
).first()
if not app_attachment:
raise HTTPException(status_code=400, detail="Attachment not found or doesn't belong to this application")
# Create comparison offer
comparison_offer = ComparisonOffer(
application_id=app.id,
cost_position_index=cost_position_index,
supplier_name=offer.supplier_name,
amount=int(offer.amount * 100), # Convert EUR to cents
description=offer.description,
url=offer.url,
attachment_id=offer.attachment_id,
is_preferred=offer.is_preferred or False
)
try:
db.add(comparison_offer)
db.commit()
db.refresh(comparison_offer)
except IntegrityError as e:
db.rollback()
if "uq_offer_supplier" in str(e):
raise HTTPException(
status_code=409,
detail=f"Ein Angebot von '{offer.supplier_name}' existiert bereits für diese Kostenposition"
)
raise HTTPException(status_code=400, detail="Datenbankfehler beim Speichern des Angebots")
return ComparisonOfferResponse(
id=comparison_offer.id,
supplier_name=comparison_offer.supplier_name,
amount=comparison_offer.amount / 100, # Convert cents to EUR
description=comparison_offer.description,
url=comparison_offer.url,
attachment_id=comparison_offer.attachment_id,
created_at=comparison_offer.created_at
)
@app.get("/applications/{pa_id}/costs/{cost_position_index}/offers", response_model=CostPositionOffersResponse)
def get_cost_position_offers(
pa_id: str,
cost_position_index: int,
x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
key: Optional[str] = Query(None),
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
x_forwarded_for: Optional[str] = Header(None),
db: Session = Depends(get_db),
):
"""Get all comparison offers for a cost position"""
rate_limit_ip(x_forwarded_for or "")
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key, x_forwarded_for)
# Get application
app = db.query(Application).filter(Application.pa_id == pa_id).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
# Validate cost position index
payload = app.payload_json
costs = payload.get("pa", {}).get("project", {}).get("costs", [])
# print(f"DEBUG GET: Application {pa_id} has {len(costs)} cost positions, requesting index {cost_position_index}")
if cost_position_index < 0 or cost_position_index >= len(costs):
raise HTTPException(status_code=404, detail=f"Cost position not found: index {cost_position_index} out of range (0-{len(costs)-1})")
# Get offers
offers = db.query(ComparisonOffer).filter(
ComparisonOffer.application_id == app.id,
ComparisonOffer.cost_position_index == cost_position_index
).all()
# Get justification
justification = db.query(CostPositionJustification).filter(
CostPositionJustification.application_id == app.id,
CostPositionJustification.cost_position_index == cost_position_index
).first()
return CostPositionOffersResponse(
cost_position_index=cost_position_index,
offers=[
ComparisonOfferResponse(
id=offer.id,
supplier_name=offer.supplier_name,
amount=offer.amount / 100, # Convert cents to EUR
description=offer.description,
url=offer.url,
attachment_id=offer.attachment_id,
is_preferred=offer.is_preferred,
created_at=offer.created_at
)
for offer in offers
],
no_offers_required=bool(justification and justification.no_offers_required),
justification=justification.justification if justification else None
)
@app.delete("/applications/{pa_id}/costs/{cost_position_index}/offers/{offer_id}")
def delete_comparison_offer(
pa_id: str,
cost_position_index: int,
offer_id: int,
x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
key: Optional[str] = Query(None),
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
x_forwarded_for: Optional[str] = Header(None),
db: Session = Depends(get_db),
):
"""Delete a comparison offer"""
rate_limit_ip(x_forwarded_for or "")
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key, x_forwarded_for)
# Get application
app = db.query(Application).filter(Application.pa_id == pa_id).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
# Get and delete offer
offer = db.query(ComparisonOffer).filter(
ComparisonOffer.id == offer_id,
ComparisonOffer.application_id == app.id,
ComparisonOffer.cost_position_index == cost_position_index
).first()
if not offer:
raise HTTPException(status_code=404, detail="Offer not found")
db.delete(offer)
db.commit()
return {"detail": "Offer deleted successfully"}
@app.put("/applications/{pa_id}/costs/{cost_position_index}/justification")
def update_cost_position_justification(
pa_id: str,
cost_position_index: int,
request: CostPositionJustificationRequest,
x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
key: Optional[str] = Query(None),
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
x_forwarded_for: Optional[str] = Header(None),
db: Session = Depends(get_db),
):
"""Update justification for a cost position without comparison offers"""
rate_limit_ip(x_forwarded_for or "")
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key, x_forwarded_for)
# Get application
app = db.query(Application).filter(Application.pa_id == pa_id).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
# Validate cost position index
payload = app.payload_json
costs = payload.get("pa", {}).get("project", {}).get("costs", [])
if cost_position_index < 0 or cost_position_index >= len(costs):
raise HTTPException(status_code=404, detail="Cost position not found")
# Validate justification
if request.no_offers_required and not request.justification:
raise HTTPException(status_code=400, detail="Justification is required when no offers are needed")
# Get or create justification
justification = db.query(CostPositionJustification).filter(
CostPositionJustification.application_id == app.id,
CostPositionJustification.cost_position_index == cost_position_index
).first()
if justification:
justification.no_offers_required = 1 if request.no_offers_required else 0
justification.justification = request.justification
justification.updated_at = datetime.utcnow()
else:
justification = CostPositionJustification(
application_id=app.id,
cost_position_index=cost_position_index,
no_offers_required=1 if request.no_offers_required else 0,
justification=request.justification
)
db.add(justification)
db.commit()
return {"detail": "Justification updated successfully"}
@app.put("/applications/{pa_id}/costs/{cost_position_index}/offers/{offer_id}/preferred")
def set_preferred_offer(
pa_id: str,
cost_position_index: int,
offer_id: int,
x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
key: Optional[str] = Query(None),
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
x_forwarded_for: Optional[str] = Header(None),
db: Session = Depends(get_db),
):
"""Set an offer as preferred for a cost position"""
rate_limit_ip(x_forwarded_for or "")
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key, x_forwarded_for)
# Get application
app = db.query(Application).filter(Application.pa_id == pa_id).first()
if not app:
raise HTTPException(status_code=404, detail="Application not found")
# Validate cost position index
payload = app.payload_json
costs = payload.get("pa", {}).get("project", {}).get("costs", [])
if cost_position_index < 0 or cost_position_index >= len(costs):
raise HTTPException(status_code=404, detail="Cost position not found")
# Get the offer to set as preferred
offer = db.query(ComparisonOffer).filter(
ComparisonOffer.id == offer_id,
ComparisonOffer.application_id == app.id,
ComparisonOffer.cost_position_index == cost_position_index
).first()
if not offer:
raise HTTPException(status_code=404, detail="Offer not found")
# Clear any existing preferred offer for this cost position
db.query(ComparisonOffer).filter(
ComparisonOffer.application_id == app.id,
ComparisonOffer.cost_position_index == cost_position_index,
ComparisonOffer.is_preferred == True
).update({"is_preferred": False})
# Set this offer as preferred
offer.is_preferred = True
db.commit()
return {"detail": "Preferred offer updated successfully"}