feat: Enhance material pricing integration by adding JOINs in API and updating SmartPackageForm to auto-add tasks from installation manuals
This commit is contained in:
Executable
+218
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Import prices from bygma_products to material_prices
|
||||
* Matches materials by name similarity and imports current_netto_pris
|
||||
*/
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const dbConfig = {
|
||||
host: '127.0.0.1',
|
||||
user: 'tilbudgivern_service',
|
||||
password: 'REDACTED_PASSWORD',
|
||||
database: 'tilbudgivern'
|
||||
};
|
||||
|
||||
// Calculate simple similarity score between two strings
|
||||
function similarity(s1, s2) {
|
||||
const longer = s1.length > s2.length ? s1 : s2;
|
||||
const shorter = s1.length > s2.length ? s2 : s1;
|
||||
|
||||
if (longer.length === 0) return 1.0;
|
||||
|
||||
const editDistance = levenshteinDistance(longer.toLowerCase(), shorter.toLowerCase());
|
||||
return (longer.length - editDistance) / longer.length;
|
||||
}
|
||||
|
||||
// Levenshtein distance algorithm
|
||||
function levenshteinDistance(str1, str2) {
|
||||
const costs = [];
|
||||
for (let i = 0; i <= str1.length; i++) {
|
||||
let lastValue = i;
|
||||
for (let j = 0; j <= str2.length; j++) {
|
||||
if (i === 0) {
|
||||
costs[j] = j;
|
||||
} else {
|
||||
if (j > 0) {
|
||||
let newValue = costs[j - 1];
|
||||
if (str1.charAt(i - 1) !== str2.charAt(j - 1)) {
|
||||
newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1;
|
||||
}
|
||||
costs[j - 1] = lastValue;
|
||||
lastValue = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (i > 0) costs[str2.length] = lastValue;
|
||||
}
|
||||
return costs[str2.length];
|
||||
}
|
||||
|
||||
// Check if material name contains key words from bygma product
|
||||
function containsKeyWords(materialName, bygmaText) {
|
||||
const materialWords = materialName.toLowerCase().split(/\s+/).filter(w => w.length > 2);
|
||||
const bygmaWords = bygmaText.toLowerCase().split(/\s+/);
|
||||
|
||||
if (materialWords.length === 0) return 0;
|
||||
|
||||
let matchCount = 0;
|
||||
for (const mWord of materialWords) {
|
||||
if (bygmaWords.some(bWord => bWord.includes(mWord) || mWord.includes(bWord))) {
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return matchCount / materialWords.length;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const connection = await mysql.createConnection(dbConfig);
|
||||
|
||||
try {
|
||||
console.log('🔍 Finding materials without prices...\n');
|
||||
|
||||
// Get materials without prices
|
||||
const [materials] = await connection.execute(`
|
||||
SELECT m.id, m.name, m.unit, m.category
|
||||
FROM materials m
|
||||
LEFT JOIN material_prices mp ON m.id = mp.material_id AND mp.is_active = 1
|
||||
WHERE mp.id IS NULL
|
||||
ORDER BY m.id
|
||||
`);
|
||||
|
||||
console.log(`Found ${materials.length} materials without prices\n`);
|
||||
|
||||
if (materials.length === 0) {
|
||||
console.log('✅ All materials already have prices!');
|
||||
await connection.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all active bygma products with prices
|
||||
const [bygmaProducts] = await connection.execute(`
|
||||
SELECT id, tekst, enhed, current_netto_pris, category_name
|
||||
FROM bygma_products
|
||||
WHERE is_active = 1 AND current_netto_pris IS NOT NULL
|
||||
ORDER BY tekst
|
||||
`);
|
||||
|
||||
console.log(`Loaded ${bygmaProducts.length} Bygma products with prices\n`);
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
const matches = [];
|
||||
let autoImportCount = 0;
|
||||
let manualReviewCount = 0;
|
||||
|
||||
// Match each material to best bygma product
|
||||
for (const material of materials) {
|
||||
let bestMatch = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const bygma of bygmaProducts) {
|
||||
// Calculate similarity score
|
||||
const nameSimilarity = similarity(material.name, bygma.tekst);
|
||||
const keywordScore = containsKeyWords(material.name, bygma.tekst);
|
||||
|
||||
// Bonus if units match (normalize units)
|
||||
const materialUnit = (material.unit || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const bygmaUnit = (bygma.enhed || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const unitBonus = materialUnit === bygmaUnit ? 0.2 : 0;
|
||||
|
||||
// Bonus if categories match
|
||||
const categoryBonus = material.category && bygma.category_name &&
|
||||
material.category.toLowerCase().includes(bygma.category_name.toLowerCase()) ? 0.1 : 0;
|
||||
|
||||
const totalScore = (nameSimilarity * 0.5) + (keywordScore * 0.3) + unitBonus + categoryBonus;
|
||||
|
||||
if (totalScore > bestScore) {
|
||||
bestScore = totalScore;
|
||||
bestMatch = bygma;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch && bestScore >= 0.3) {
|
||||
matches.push({
|
||||
material,
|
||||
bygma: bestMatch,
|
||||
score: bestScore,
|
||||
autoImport: bestScore >= 0.6 // High confidence threshold
|
||||
});
|
||||
|
||||
if (bestScore >= 0.6) {
|
||||
autoImportCount++;
|
||||
} else {
|
||||
manualReviewCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📊 Match Results:`);
|
||||
console.log(` High confidence (auto-import): ${autoImportCount}`);
|
||||
console.log(` Medium confidence (review): ${manualReviewCount}`);
|
||||
console.log(` No match found: ${materials.length - matches.length}\n`);
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
// Show matches and ask for confirmation
|
||||
console.log('\n🎯 Matches found:\n');
|
||||
|
||||
for (const match of matches) {
|
||||
const confidence = match.autoImport ? '✅ AUTO' : '⚠️ REVIEW';
|
||||
const scorePercent = (match.score * 100).toFixed(0);
|
||||
|
||||
console.log(`${confidence} [${scorePercent}%] Material: "${match.material.name}" (${match.material.unit})`);
|
||||
console.log(` → Bygma: "${match.bygma.tekst}" (${match.bygma.enhed})`);
|
||||
console.log(` → Price: ${parseFloat(match.bygma.current_netto_pris).toFixed(2)} DKK`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log('─'.repeat(80));
|
||||
console.log(`\n💾 Importing ${autoImportCount} high-confidence matches...\n`);
|
||||
|
||||
// Import high-confidence matches
|
||||
let importedCount = 0;
|
||||
for (const match of matches.filter(m => m.autoImport)) {
|
||||
try {
|
||||
await connection.execute(`
|
||||
INSERT INTO material_prices
|
||||
(name, material_id, price, currency, valid_from, is_active, unit, supplier_name, category)
|
||||
VALUES (?, ?, ?, 'DKK', CURDATE(), 1, ?, 'Bygma', ?)
|
||||
`, [
|
||||
match.bygma.tekst,
|
||||
match.material.id,
|
||||
match.bygma.current_netto_pris,
|
||||
match.bygma.enhed,
|
||||
match.material.category
|
||||
]);
|
||||
|
||||
importedCount++;
|
||||
console.log(`✅ Imported: ${match.material.name} → ${parseFloat(match.bygma.current_netto_pris).toFixed(2)} DKK`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error importing ${match.material.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✨ Successfully imported ${importedCount} prices!`);
|
||||
|
||||
if (manualReviewCount > 0) {
|
||||
console.log(`\n⚠️ ${manualReviewCount} matches need manual review (score < 60%)`);
|
||||
console.log(' Review the medium-confidence matches above and import manually if correct.');
|
||||
}
|
||||
|
||||
if (materials.length - matches.length > 0) {
|
||||
console.log(`\n❓ ${materials.length - matches.length} materials had no suitable Bygma match`);
|
||||
console.log(' Consider adding prices manually or improving product descriptions.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
main().catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -2988,17 +2988,40 @@ app.get('/api/materials', async (req, res) => {
|
||||
const params = [];
|
||||
|
||||
if (category) {
|
||||
whereClause += ' AND category = ?';
|
||||
whereClause += ' AND m.category = ?';
|
||||
params.push(category);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
whereClause += ' AND (name LIKE ? OR description LIKE ? OR sku LIKE ?)';
|
||||
whereClause += ' AND (m.name LIKE ? OR m.description LIKE ? OR m.sku LIKE ?)';
|
||||
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||
}
|
||||
|
||||
const materials = await databaseService.query(
|
||||
`SELECT * FROM materials ${whereClause} ORDER BY name ASC LIMIT ? OFFSET ?`,
|
||||
`SELECT
|
||||
m.*,
|
||||
COALESCE(mp.price, bp.current_netto_pris) as price,
|
||||
COALESCE(mp.unit, bp.enhed) as price_unit,
|
||||
mp.valid_from,
|
||||
COALESCE(mp.supplier_name, 'Bygma') as price_supplier,
|
||||
bp.tekst as bygma_product_name
|
||||
FROM materials m
|
||||
LEFT JOIN material_prices mp ON m.id = mp.material_id
|
||||
AND mp.is_active = 1
|
||||
AND mp.id = (
|
||||
SELECT id
|
||||
FROM material_prices
|
||||
WHERE material_id = m.id
|
||||
AND is_active = 1
|
||||
ORDER BY valid_from DESC, id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
LEFT JOIN bygma_materials_mapping bmm ON m.id = bmm.material_id
|
||||
LEFT JOIN bygma_products bp ON bmm.bygma_product_id = bp.id
|
||||
AND bp.is_active = 1
|
||||
${whereClause}
|
||||
ORDER BY m.name ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, parseInt(limit), parseInt(offset)]
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# Materiale Pris Integration Fix
|
||||
|
||||
## Dato: 2024-11-07
|
||||
## Status: ✅ Færdig
|
||||
|
||||
## Problem
|
||||
|
||||
Når man tilføjer et materiale i SmartPackageForm via "Tilføj materiale" knappen, viste prisen altid 0.00 kr selvom materialer har priser i databasen.
|
||||
|
||||
### Symptomer
|
||||
- MaterialDialog viste "Standardpris: 0.00 kr/stk"
|
||||
- Effektiv pris viste også 0.00 kr/stk
|
||||
- Brugerdefineret pris skulle indtastes manuelt
|
||||
|
||||
### Root Cause
|
||||
`/api/materials` endpoint hentede kun data fra `materials` tabellen uden at JOIN'e med `material_prices` tabellen hvor priserne faktisk er gemt.
|
||||
|
||||
## Løsning
|
||||
|
||||
### Backend Ændringer
|
||||
|
||||
**Fil:** `/backend/unified-server.js`
|
||||
|
||||
**Ændring 1: Tilføjet JOIN med material_prices**
|
||||
```javascript
|
||||
const materials = await databaseService.query(
|
||||
`SELECT
|
||||
m.*,
|
||||
mp.price,
|
||||
mp.unit as price_unit,
|
||||
mp.valid_from,
|
||||
mp.supplier_name as price_supplier
|
||||
FROM materials m
|
||||
LEFT JOIN material_prices mp ON m.id = mp.material_id
|
||||
AND mp.is_active = 1
|
||||
AND mp.id = (
|
||||
SELECT id
|
||||
FROM material_prices
|
||||
WHERE material_id = m.id
|
||||
AND is_active = 1
|
||||
ORDER BY valid_from DESC, id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
${whereClause}
|
||||
ORDER BY m.name ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, parseInt(limit), parseInt(offset)]
|
||||
);
|
||||
```
|
||||
|
||||
**Ændring 2: Rettede ambiguous column names**
|
||||
```javascript
|
||||
// FØR:
|
||||
if (search) {
|
||||
whereClause += ' AND (name LIKE ? OR description LIKE ? OR sku LIKE ?)';
|
||||
}
|
||||
|
||||
// EFTER:
|
||||
if (search) {
|
||||
whereClause += ' AND (m.name LIKE ? OR m.description LIKE ? OR m.sku LIKE ?)';
|
||||
}
|
||||
```
|
||||
|
||||
### Hvordan det virker
|
||||
|
||||
1. **LEFT JOIN:** Alle materialer vises, også dem uden priser
|
||||
2. **Subquery:** Finder den nyeste aktive pris for hvert materiale
|
||||
3. **ORDER BY valid_from DESC:** Bruger den senest gyldige pris
|
||||
4. **is_active = 1:** Kun aktive priser inkluderes
|
||||
|
||||
### Database Schema
|
||||
|
||||
**Tabeller involveret:**
|
||||
- `materials` - Materiale grunddata (id, name, unit, description, etc.)
|
||||
- `material_prices` - Priser for materialer (material_id, price, valid_from, etc.)
|
||||
|
||||
**Relationer:**
|
||||
```
|
||||
materials.id ← material_prices.material_id (1:N)
|
||||
```
|
||||
|
||||
## Test Resultater
|
||||
|
||||
### Før Fix
|
||||
```bash
|
||||
curl http://localhost:4031/api/materials?limit=5
|
||||
```
|
||||
```json
|
||||
{
|
||||
"id": 16,
|
||||
"name": "B7 tagplader",
|
||||
"unit": "m2",
|
||||
"price": null // ❌ Ingen pris
|
||||
}
|
||||
```
|
||||
|
||||
### Efter Fix
|
||||
```bash
|
||||
curl http://localhost:4031/api/materials?search=B7%20tag
|
||||
```
|
||||
```json
|
||||
{
|
||||
"id": 16,
|
||||
"name": "B7 tagplader",
|
||||
"unit": "m2",
|
||||
"price": "125.00" // ✅ Pris hentet fra material_prices
|
||||
}
|
||||
```
|
||||
|
||||
### Test Case: Eternit tagsten
|
||||
```json
|
||||
{
|
||||
"id": 37,
|
||||
"name": "Eternit tagsten B6 rød",
|
||||
"unit": "m2",
|
||||
"price": "145.00"
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
MaterialDialog modtager nu automatisk pris fra backend:
|
||||
|
||||
**Før (linje 105-107 i MaterialDialog.js):**
|
||||
```javascript
|
||||
const materialPrice = value.price ? parseFloat(value.price) : 0;
|
||||
// value.price var altid undefined → price = 0
|
||||
```
|
||||
|
||||
**Efter:**
|
||||
```javascript
|
||||
const materialPrice = value.price ? parseFloat(value.price) : 0;
|
||||
// value.price = "125.00" fra database → price = 125.00 ✅
|
||||
```
|
||||
|
||||
### Autocomplete Visning
|
||||
MaterialDialog's Autocomplete viser nu priser:
|
||||
```javascript
|
||||
getOptionLabel={(option) => {
|
||||
const priceInfo = option.price
|
||||
? ` - ${parseFloat(option.price).toFixed(2)} kr/${option.unit}`
|
||||
: '';
|
||||
return `${option.name}${priceInfo}`;
|
||||
}}
|
||||
```
|
||||
|
||||
**Resultat:**
|
||||
- "B7 tagplader - 125.00 kr/m2"
|
||||
- "Eternit tagsten B6 rød - 145.00 kr/m2"
|
||||
|
||||
## Materialer med Priser
|
||||
|
||||
Efter fix har følgende materialer priser:
|
||||
|
||||
| ID | Materiale | Pris | Enhed |
|
||||
|----|-----------|------|-------|
|
||||
| 16 | B7 tagplader | 125.00 | m2 |
|
||||
| 35 | Test materiale | 150.00 | stk |
|
||||
| 36 | Test materiale efter fix | 200.00 | m2 |
|
||||
| 37 | Eternit tagsten B6 rød | 145.00 | m2 |
|
||||
| 38 | Tagpap underlag T400 | 35.50 | m2 |
|
||||
| 39 | Tagplade stål 35/1035 sort | 185.00 | m2 |
|
||||
| 40 | Tagrender halvrund zink 150mm | 245.00 | m |
|
||||
| 41 | Nedløbsrør zink 100mm | 195.00 | m |
|
||||
| 42 | Stålskotskinne galvaniseret | 45.00 | m |
|
||||
| 43 | Tagisolering mineraluld 200mm | 85.00 | m2 |
|
||||
| 44 | Eternit vindskeder B6 | 75.00 | stk |
|
||||
| 45 | Eternit rygningsten B6 | 195.00 | m |
|
||||
| 46 | Lægter 38x63mm trykimprægneret | 18.50 | m |
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
# Genstart backend
|
||||
pm2 restart tilbudgivern-unified
|
||||
|
||||
# Frontend kræver ikke rebuild - ændringen er kun i backend
|
||||
```
|
||||
|
||||
### Server Status
|
||||
- Backend: Online (port 4031) - 11 restarts
|
||||
- Frontend: Online (port 3001) - 5 restarts
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Materialer uden priser:** Vises stadig i listen men med `price: null`
|
||||
2. **Historiske priser:** Kun den nyeste pris vises (baseret på `valid_from`)
|
||||
3. **Multiple leverandører:** Hvis samme materiale har flere priser, vises kun den nyeste
|
||||
|
||||
## Fremtidige Forbedringer
|
||||
|
||||
### 1. Pris Import System
|
||||
Automatisk import af priser fra leverandører:
|
||||
- Bygma prisliste
|
||||
- CSV upload
|
||||
- API integration
|
||||
|
||||
### 2. Pris Historik
|
||||
Vis prisudvikling over tid:
|
||||
```sql
|
||||
SELECT
|
||||
material_id,
|
||||
price,
|
||||
valid_from,
|
||||
supplier_name
|
||||
FROM material_prices
|
||||
WHERE material_id = ?
|
||||
ORDER BY valid_from DESC
|
||||
```
|
||||
|
||||
### 3. Leverandør Valg
|
||||
Lad brugeren vælge mellem forskellige leverandører:
|
||||
```javascript
|
||||
const [selectedSupplier, setSelectedSupplier] = useState('Bygma');
|
||||
// Filtrer priser baseret på supplier_name
|
||||
```
|
||||
|
||||
### 4. Bulk Pris Opdatering
|
||||
Interface til at opdatere mange priser på én gang:
|
||||
- Upload CSV fil med nye priser
|
||||
- Automatisk validering
|
||||
- Preview før import
|
||||
|
||||
## Konklusion
|
||||
|
||||
✅ Materialer trækker nu automatisk priser fra databasen
|
||||
✅ MaterialDialog viser korrekt standardpris
|
||||
✅ Autocomplete viser priser i dropdown
|
||||
✅ Brugerdefineret pris kan stadig anvendes til at overskrive
|
||||
✅ Ingen frontend ændringer nødvendige
|
||||
|
||||
Systemet er nu fuldt funktionelt med prisintegration!
|
||||
@@ -281,9 +281,42 @@ const SmartPackageForm = ({ packageId, onBack }) => {
|
||||
updatedMaterials = [...packageData.materials, newMaterial];
|
||||
}
|
||||
|
||||
// Automatisk tilføj opgaver fra installation manual hvis de findes
|
||||
let updatedTasks = packageData.tasks;
|
||||
if (materialData.installationManual && materialData.installationManual.installation_steps) {
|
||||
const manual = materialData.installationManual;
|
||||
const steps = Array.isArray(manual.installation_steps)
|
||||
? manual.installation_steps
|
||||
: JSON.parse(manual.installation_steps || '[]');
|
||||
|
||||
// Opret opgaver fra installation steps
|
||||
if (steps.length > 0) {
|
||||
const newTasks = steps.map((step, index) => ({
|
||||
id: `temp-manual-${Date.now()}-${index}`,
|
||||
name: step.title || step.step || `Trin ${index + 1}`,
|
||||
description: step.description || step.details || '',
|
||||
category: 'Montage',
|
||||
estimated_hours: step.estimated_time ? parseFloat(step.estimated_time) : 1,
|
||||
hourly_rate: 450, // Standard timepris
|
||||
steps: step.details ? [step.details] : [],
|
||||
material_reference: materialData.material_name || materialData.description,
|
||||
from_installation_manual: true,
|
||||
manual_product: manual.product_name
|
||||
}));
|
||||
|
||||
updatedTasks = [...packageData.tasks, ...newTasks];
|
||||
|
||||
enqueueSnackbar(
|
||||
`${newTasks.length} opgave${newTasks.length > 1 ? 'r' : ''} tilføjet automatisk fra montagevejledning`,
|
||||
{ variant: 'success' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setPackageData({
|
||||
...packageData,
|
||||
materials: updatedMaterials
|
||||
materials: updatedMaterials,
|
||||
tasks: updatedTasks
|
||||
});
|
||||
|
||||
setMaterialDialogOpen(false);
|
||||
|
||||
@@ -244,7 +244,9 @@ const MaterialDialog = ({ open, onClose, onSave, material, availableMaterials })
|
||||
const dataToSave = {
|
||||
...materialData,
|
||||
// Hvis der ikke bruges formel, fjern formlen
|
||||
quantity_formula: materialData.use_formula ? materialData.quantity_formula : null
|
||||
quantity_formula: materialData.use_formula ? materialData.quantity_formula : null,
|
||||
// Tilføj installation manual data hvis det findes
|
||||
installationManual: installationManual || null
|
||||
};
|
||||
|
||||
onSave(dataToSave);
|
||||
@@ -509,7 +511,7 @@ const MaterialDialog = ({ open, onClose, onSave, material, availableMaterials })
|
||||
disabled
|
||||
value={materialData.price.toFixed(2)}
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">kr/stk</InputAdornment>,
|
||||
endAdornment: <InputAdornment position="end">kr/{materialData.unit}</InputAdornment>,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
@@ -524,18 +526,32 @@ const MaterialDialog = ({ open, onClose, onSave, material, availableMaterials })
|
||||
onChange={handleInputChange('custom_price')}
|
||||
placeholder="Lad være tom for at bruge standardpris"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">kr/stk</InputAdornment>,
|
||||
endAdornment: <InputAdornment position="end">kr/{materialData.unit}</InputAdornment>,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Box p={2} textAlign="right">
|
||||
<Box p={2} textAlign="right" bgcolor="grey.50" borderRadius={1}>
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
Effektiv pris
|
||||
Enhedspris
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{((materialData.custom_price !== null ? materialData.custom_price : materialData.price) || 0).toFixed(2)} kr/stk
|
||||
{((materialData.custom_price !== null ? materialData.custom_price : materialData.price) || 0).toFixed(2)} kr/{materialData.unit}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Box p={2} textAlign="right" bgcolor="primary.light" borderRadius={1} sx={{ bgcolor: 'primary.light', color: 'primary.contrastText' }}>
|
||||
<Typography variant="body2" gutterBottom fontWeight="bold">
|
||||
Total pris
|
||||
</Typography>
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
{(((materialData.custom_price !== null ? materialData.custom_price : materialData.price) || 0) * (materialData.quantity || 0)).toFixed(2)} kr
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block">
|
||||
{materialData.quantity || 0} × {((materialData.custom_price !== null ? materialData.custom_price : materialData.price) || 0).toFixed(2)} kr/{materialData.unit}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
Reference in New Issue
Block a user