Files
tilbudgivern/docs/features/FEATURE_ROADMAP.md
T
alexpolo1 bdb9e524db Implement manual and automated tests for auto-save countdown feature
- Added a comprehensive manual testing guide for the countdown functionality in `test_countdown_manual.md`.
- Developed a detailed Selenium test suite in `test_countdown_selenium.py` to verify countdown behavior, including server accessibility, login, countdown progression, and UI elements.
- Created a minimal Selenium test version in `test_countdown_selenium_minimal.py` for quick checks without complex setup.
- Introduced a verification script in `test_countdown_verification.py` to analyze code correctness, including server checks, build file existence, and countdown logic.
2025-11-14 11:28:58 +00:00

2196 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🗺️ Feature Roadmap - Tilbudgivern
**Oprettet**: 2025-10-19
**Status**: Planning Phase
---
## 📋 Feature Overview
| # | Feature | Prioritet | Status | Estimeret Tid |
|---|---------|-----------|--------|---------------|
| 1 | Standardisering af opgavebeskrivelser | 🔴 Høj | 📋 Planlagt | 3-5 dage |
| 2 | Geometri-beregning af tag | 🔴 Høj | 📋 Planlagt | 2-3 dage |
| 3 | Smart pakker udvidelse | 🟡 Medium | 📋 Planlagt | 4-6 dage |
| 4 | AI-baseret kundebeskrivelse | 🟢 Lav | 📋 Planlagt | 3-4 dage |
| 5 | PDF-preview i final review | 🟡 Medium | 📋 Planlagt | 2-3 dage |
| 6 | Web-scraper til montagevejledninger | 🟢 Lav | 📋 Planlagt | 5-7 dage |
**Total Estimeret Tid**: 19-28 dage
---
## 1️⃣ Standardisering af Opgavebeskrivelser
### 📝 Beskrivelse
Indfør key phrases (vinduer, døre, gulve, tage) så data kan segmenteres til erfaringspakker.
### 🎯 Acceptance Criteria
- [x] System genkender nøgleord i beskrivelser
- [x] Beskrivelser mappe til standardiserede kategorier
- [x] Kan eksportere statistik pr. kategori
### 🔧 Teknisk Implementation
#### Database Schema
```sql
-- Ny tabel til standardiserede kategorier
CREATE TABLE task_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
category_name VARCHAR(100) NOT NULL,
category_key VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
icon VARCHAR(50),
color_code VARCHAR(7),
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Key phrases til kategori matching
CREATE TABLE category_keywords (
id INT AUTO_INCREMENT PRIMARY KEY,
category_id INT NOT NULL,
keyword VARCHAR(100) NOT NULL,
weight DECIMAL(3,2) DEFAULT 1.0,
FOREIGN KEY (category_id) REFERENCES task_categories(id),
INDEX idx_keyword (keyword)
);
-- Link opgaver til kategorier
ALTER TABLE project_tasks
ADD COLUMN category_id INT,
ADD COLUMN auto_categorized BOOLEAN DEFAULT FALSE,
ADD COLUMN category_confidence DECIMAL(3,2),
ADD FOREIGN KEY (category_id) REFERENCES task_categories(id);
```
#### Service: TaskCategorizationService
```javascript
// backend/src/services/taskCategorizationService.js
class TaskCategorizationService {
constructor() {
this.categories = {
vinduer: {
keywords: ['vindu', 'vinduer', 'termoruder', 'karm', 'karme', 'ruder'],
weight: { exact: 1.0, partial: 0.7 }
},
døre: {
keywords: ['dør', 'døre', 'entre', 'entredør', 'indgang', 'udgang'],
weight: { exact: 1.0, partial: 0.7 }
},
gulve: {
keywords: ['gulv', 'gulve', 'trægulv', 'laminat', 'parket', 'vinyl', 'fliser'],
weight: { exact: 1.0, partial: 0.7 }
},
tage: {
keywords: ['tag', 'tage', 'tagplader', 'spær', 'lægter', 'undertag', 'tagrende'],
weight: { exact: 1.0, partial: 0.7 }
},
vægge: {
keywords: ['væg', 'vægge', 'facade', 'puds', 'maling', 'isolering'],
weight: { exact: 1.0, partial: 0.7 }
},
el: {
keywords: ['el', 'elektriker', 'stikkontakt', 'belysning', 'ledninger'],
weight: { exact: 1.0, partial: 0.7 }
},
vvs: {
keywords: ['vvs', 'rør', 'badeværelse', 'køkken', 'vandhane', 'afløb'],
weight: { exact: 1.0, partial: 0.7 }
}
};
}
/**
* Kategoriser en opgave baseret på beskrivelse
* @param {string} description - Opgavebeskrivelse
* @returns {Object} - { category, confidence, matchedKeywords }
*/
categorizeTask(description) {
const normalizedDesc = description.toLowerCase();
const scores = {};
for (const [category, config] of Object.entries(this.categories)) {
let score = 0;
const matchedKeywords = [];
for (const keyword of config.keywords) {
// Exact match
if (normalizedDesc.includes(keyword)) {
score += config.weight.exact;
matchedKeywords.push({ keyword, type: 'exact' });
}
// Partial match (fuzzy)
else if (this.fuzzyMatch(normalizedDesc, keyword)) {
score += config.weight.partial;
matchedKeywords.push({ keyword, type: 'partial' });
}
}
if (score > 0) {
scores[category] = {
score,
matchedKeywords,
confidence: Math.min(score / config.keywords.length, 1.0)
};
}
}
// Find højeste score
const bestMatch = Object.entries(scores)
.sort(([, a], [, b]) => b.score - a.score)[0];
if (!bestMatch) {
return { category: 'andre', confidence: 0, matchedKeywords: [] };
}
const [category, data] = bestMatch;
return {
category,
confidence: data.confidence,
matchedKeywords: data.matchedKeywords
};
}
/**
* Fuzzy string matching
*/
fuzzyMatch(text, keyword) {
const threshold = 0.8;
const distance = this.levenshteinDistance(text, keyword);
const similarity = 1 - (distance / Math.max(text.length, keyword.length));
return similarity >= threshold;
}
/**
* Levenshtein distance for fuzzy matching
*/
levenshteinDistance(str1, str2) {
const matrix = [];
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= str1.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j] + 1
);
}
}
}
return matrix[str2.length][str1.length];
}
/**
* Batch kategoriser alle opgaver i et projekt
*/
async categorizeProjectTasks(projectId) {
const tasks = await db.query(
'SELECT id, task_description FROM project_tasks WHERE project_id = ?',
[projectId]
);
const results = [];
for (const task of tasks) {
const categorization = this.categorizeTask(task.task_description);
await db.query(`
UPDATE project_tasks
SET category_id = (SELECT id FROM task_categories WHERE category_key = ?),
auto_categorized = TRUE,
category_confidence = ?
WHERE id = ?
`, [categorization.category, categorization.confidence, task.id]);
results.push({
taskId: task.id,
...categorization
});
}
return results;
}
/**
* Eksporter statistik pr. kategori
*/
async exportCategoryStatistics(filters = {}) {
const { startDate, endDate, minConfidence = 0.7 } = filters;
const query = `
SELECT
tc.category_name,
tc.category_key,
COUNT(pt.id) as task_count,
SUM(pt.estimated_hours) as total_hours,
AVG(pt.estimated_hours) as avg_hours_per_task,
AVG(pt.category_confidence) as avg_confidence,
SUM(pt.actual_cost) as total_cost
FROM task_categories tc
LEFT JOIN project_tasks pt ON pt.category_id = tc.id
WHERE pt.category_confidence >= ?
${startDate ? 'AND pt.created_at >= ?' : ''}
${endDate ? 'AND pt.created_at <= ?' : ''}
GROUP BY tc.id, tc.category_name, tc.category_key
ORDER BY total_hours DESC
`;
const params = [minConfidence];
if (startDate) params.push(startDate);
if (endDate) params.push(endDate);
return await db.query(query, params);
}
}
module.exports = new TaskCategorizationService();
```
#### API Endpoints
```javascript
// Kategoriser en enkelt opgave
app.post('/api/tasks/:taskId/categorize', async (req, res) => {
const { taskId } = req.params;
const task = await db.query('SELECT * FROM project_tasks WHERE id = ?', [taskId]);
const result = taskCategorizationService.categorizeTask(task[0].task_description);
await db.query(`
UPDATE project_tasks
SET category_id = (SELECT id FROM task_categories WHERE category_key = ?),
auto_categorized = TRUE,
category_confidence = ?
WHERE id = ?
`, [result.category, result.confidence, taskId]);
res.json({ success: true, categorization: result });
});
// Kategoriser alle opgaver i et projekt
app.post('/api/projects/:projectId/categorize-tasks', async (req, res) => {
const { projectId } = req.params;
const results = await taskCategorizationService.categorizeProjectTasks(projectId);
res.json({ success: true, results });
});
// Eksporter kategori-statistik
app.get('/api/statistics/categories', async (req, res) => {
const { startDate, endDate, minConfidence } = req.query;
const stats = await taskCategorizationService.exportCategoryStatistics({
startDate, endDate, minConfidence: parseFloat(minConfidence)
});
res.json({ success: true, statistics: stats });
});
// Hent alle kategorier
app.get('/api/categories', async (req, res) => {
const categories = await db.query('SELECT * FROM task_categories ORDER BY sort_order');
res.json({ success: true, categories });
});
```
### 📊 Seed Data
```sql
-- Indsæt standard kategorier
INSERT INTO task_categories (category_name, category_key, icon, color_code, sort_order) VALUES
('Vinduer', 'vinduer', '🪟', '#3b82f6', 1),
('Døre', 'døre', '🚪', '#10b981', 2),
('Gulve', 'gulve', '🏠', '#f59e0b', 3),
('Tage', 'tage', '🏘️', '#ef4444', 4),
('Vægge', 'vægge', '🧱', '#8b5cf6', 5),
('El-arbejde', 'el', '⚡', '#eab308', 6),
('VVS', 'vvs', '🚰', '#06b6d4', 7),
('Andet', 'andre', '🔧', '#6b7280', 99);
-- Indsæt keywords
INSERT INTO category_keywords (category_id, keyword, weight) VALUES
-- Vinduer
(1, 'vindu', 1.0), (1, 'vinduer', 1.0), (1, 'termoruder', 1.0),
(1, 'karm', 0.8), (1, 'karme', 0.8), (1, 'ruder', 0.7),
-- Døre
(2, 'dør', 1.0), (2, 'døre', 1.0), (2, 'entre', 0.9),
(2, 'entredør', 1.0), (2, 'indgang', 0.7), (2, 'udgang', 0.7),
-- Gulve
(3, 'gulv', 1.0), (3, 'gulve', 1.0), (3, 'trægulv', 1.0),
(3, 'laminat', 1.0), (3, 'parket', 1.0), (3, 'vinyl', 0.9),
-- Tage
(4, 'tag', 1.0), (4, 'tage', 1.0), (4, 'tagplader', 1.0),
(4, 'spær', 1.0), (4, 'lægter', 1.0), (4, 'undertag', 1.0),
-- VVS
(7, 'vvs', 1.0), (7, 'rør', 0.8), (7, 'badeværelse', 0.7),
(7, 'køkken', 0.6), (7, 'vandhane', 0.9), (7, 'afløb', 0.9);
```
### 🧪 Tests
```javascript
// test_task_categorization.js
const service = require('./backend/src/services/taskCategorizationService');
console.log('=== Testing Task Categorization ===\n');
const testCases = [
{ desc: 'Montering af nye vinduer i stue', expected: 'vinduer' },
{ desc: 'Udskiftning af entredør med sikkerhedslås', expected: 'døre' },
{ desc: 'Lægning af trægulv i alle værelser', expected: 'gulve' },
{ desc: 'Renovering af tag med nye tagplader og spær', expected: 'tage' },
{ desc: 'Installation af stikkontakter og belysning', expected: 'el' },
{ desc: 'Montering af ny vandhane i køkken', expected: 'vvs' }
];
for (const test of testCases) {
const result = service.categorizeTask(test.desc);
const status = result.category === test.expected ? '✅' : '❌';
console.log(`${status} "${test.desc}"`);
console.log(` → ${result.category} (${(result.confidence * 100).toFixed(0)}% confidence)`);
console.log(` → Keywords: ${result.matchedKeywords.map(k => k.keyword).join(', ')}\n`);
}
```
---
## 2️⃣ Geometri-beregning af Tag
### 📝 Beskrivelse
Geometrimodulet skal håndtere 'stern til kip' korrekt og tillade ændring af grader og spærafstand.
### 🎯 Acceptance Criteria
- [x] Feltnavn ændres til korrekt begreb (ryghøjde)
- [x] Gradfelt kan ændres efter beregning uden fejl
- [x] Spærafstand kan tilpasses (0,6m / 1m / variabel)
### 🔧 Teknisk Implementation
#### Database Schema Update
```sql
-- Opdater roof_geometry tabel
ALTER TABLE roof_geometry
-- Omdøb wall_height til ridge_to_stern_height (stern til kip)
CHANGE COLUMN wall_height stern_to_ridge_height DECIMAL(10,2) COMMENT 'Højde fra stern til kip',
-- Tilføj nye felter
ADD COLUMN roof_angle_degrees DECIMAL(5,2) AFTER roof_pitch COMMENT 'Taghældning i grader',
ADD COLUMN spaer_distance DECIMAL(5,2) DEFAULT 0.6 COMMENT 'Spærafstand i meter (0.6m, 1m, custom)',
ADD COLUMN spaer_count INT COMMENT 'Antal spær beregnet',
ADD COLUMN allow_angle_edit BOOLEAN DEFAULT TRUE COMMENT 'Tillad ændring af grader efter beregning',
ADD COLUMN calculation_method ENUM('pitch', 'angle', 'manual') DEFAULT 'pitch' COMMENT 'Beregningsmetode';
-- Tilføj index for hurtigere queries
CREATE INDEX idx_roof_angle ON roof_geometry(roof_angle_degrees);
CREATE INDEX idx_spaer_distance ON roof_geometry(spaer_distance);
```
#### Frontend Component Update
```jsx
// frontend/src/components/GeometryInput.js
function RoofGeometryInput({ projectId, onComplete }) {
const [geometry, setGeometry] = useState({
width: '',
length: '',
sternToRidgeHeight: '', // Renamed from wallHeight
roofAngleDegrees: '', // New field
roofPitch: '',
spaerDistance: 0.6, // Default 0.6m
calculationMethod: 'angle' // 'pitch', 'angle', or 'manual'
});
const [calculatedValues, setCalculatedValues] = useState(null);
const [allowEdit, setAllowEdit] = useState(true);
// Beregn taghældning fra grader eller omvendt
const handleAngleChange = (angleDegrees) => {
const angleRad = (angleDegrees * Math.PI) / 180;
const pitch = Math.tan(angleRad);
setGeometry(prev => ({
...prev,
roofAngleDegrees: angleDegrees,
roofPitch: pitch.toFixed(4),
calculationMethod: 'angle'
}));
recalculateGeometry({ ...geometry, roofAngleDegrees: angleDegrees });
};
const handlePitchChange = (pitch) => {
const angleDegrees = (Math.atan(pitch) * 180) / Math.PI;
setGeometry(prev => ({
...prev,
roofPitch: pitch,
roofAngleDegrees: angleDegrees.toFixed(2),
calculationMethod: 'pitch'
}));
recalculateGeometry({ ...geometry, roofPitch: pitch });
};
const recalculateGeometry = (updatedGeometry) => {
if (!updatedGeometry.width || !updatedGeometry.length || !updatedGeometry.sternToRidgeHeight) {
return;
}
const width = parseFloat(updatedGeometry.width);
const length = parseFloat(updatedGeometry.length);
const ridgeHeight = parseFloat(updatedGeometry.sternToRidgeHeight);
const angle = parseFloat(updatedGeometry.roofAngleDegrees);
const spaerDist = parseFloat(updatedGeometry.spaerDistance);
// Beregn tagets geometri
const halfWidth = width / 2;
const roofSlope = Math.sqrt(Math.pow(halfWidth, 2) + Math.pow(ridgeHeight, 2));
const roofArea = 2 * roofSlope * length;
// Beregn antal spær
const spaerCount = Math.ceil(length / spaerDist) + 1;
// Beregn vindskede (diagonal)
const vindskede = Math.sqrt(Math.pow(roofSlope, 2) + Math.pow(length, 2));
setCalculatedValues({
roofArea: roofArea.toFixed(2),
roofSlope: roofSlope.toFixed(2),
spaerCount,
totalSpaerLength: (spaerCount * roofSlope * 2).toFixed(2),
vindskede: vindskede.toFixed(2),
actualSpaerDistance: (length / (spaerCount - 1)).toFixed(2)
});
};
const handleSubmit = async () => {
const payload = {
roofWidth: geometry.width,
roofLength: geometry.length,
sternToRidgeHeight: geometry.sternToRidgeHeight,
roofAngleDegrees: geometry.roofAngleDegrees,
roofPitch: geometry.roofPitch,
spaerDistance: geometry.spaerDistance,
calculationMethod: geometry.calculationMethod,
allowAngleEdit: allowEdit
};
const response = await fetch(`/api/customer-projects/${projectId}/geometry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await response.json();
if (result.success) {
onComplete(result);
}
};
return (
<div className="roof-geometry-input">
<h3>Tag Geometri</h3>
{/* Basic dimensions */}
<div className="input-group">
<label>Tagbredde (m):</label>
<input
type="number"
value={geometry.width}
onChange={(e) => {
setGeometry({...geometry, width: e.target.value});
recalculateGeometry({...geometry, width: e.target.value});
}}
/>
</div>
<div className="input-group">
<label>Taglængde (m):</label>
<input
type="number"
value={geometry.length}
onChange={(e) => {
setGeometry({...geometry, length: e.target.value});
recalculateGeometry({...geometry, length: e.target.value});
}}
/>
</div>
<div className="input-group">
<label>Højde stern til kip (m):</label>
<input
type="number"
step="0.1"
value={geometry.sternToRidgeHeight}
onChange={(e) => {
setGeometry({...geometry, sternToRidgeHeight: e.target.value});
recalculateGeometry({...geometry, sternToRidgeHeight: e.target.value});
}}
/>
</div>
{/* Angle/Pitch input */}
<div className="input-group">
<label>Taghældning (grader):</label>
<input
type="number"
step="0.5"
value={geometry.roofAngleDegrees}
onChange={(e) => handleAngleChange(parseFloat(e.target.value))}
disabled={!allowEdit && calculatedValues}
/>
{!allowEdit && (
<button onClick={() => setAllowEdit(true)}>Rediger hældning</button>
)}
</div>
{/* Spærafstand selector */}
<div className="input-group">
<label>Spærafstand:</label>
<div className="spaer-distance-selector">
<button
className={geometry.spaerDistance === 0.6 ? 'active' : ''}
onClick={() => {
setGeometry({...geometry, spaerDistance: 0.6});
recalculateGeometry({...geometry, spaerDistance: 0.6});
}}
>
0,6m (standard)
</button>
<button
className={geometry.spaerDistance === 1.0 ? 'active' : ''}
onClick={() => {
setGeometry({...geometry, spaerDistance: 1.0});
recalculateGeometry({...geometry, spaerDistance: 1.0});
}}
>
1,0m
</button>
<input
type="number"
step="0.1"
placeholder="Variabel"
value={geometry.spaerDistance}
onChange={(e) => {
setGeometry({...geometry, spaerDistance: e.target.value});
recalculateGeometry({...geometry, spaerDistance: e.target.value});
}}
/>
</div>
</div>
{/* Calculated values */}
{calculatedValues && (
<div className="calculated-results">
<h4>Beregnede værdier:</h4>
<div className="result-grid">
<div>📐 Tagareal: <strong>{calculatedValues.roofArea} m²</strong></div>
<div>📏 Taghældning: <strong>{calculatedValues.roofSlope} m</strong></div>
<div>🪚 Antal spær: <strong>{calculatedValues.spaerCount} stk</strong></div>
<div>📏 Faktisk afstand: <strong>{calculatedValues.actualSpaerDistance} m</strong></div>
<div>🔗 Total spærlængde: <strong>{calculatedValues.totalSpaerLength} m</strong></div>
<div>📐 Vindskede: <strong>{calculatedValues.vindskede} lbm</strong></div>
</div>
</div>
)}
<button onClick={handleSubmit} className="submit-btn">
Gem geometri
</button>
</div>
);
}
```
#### Backend API Update
```javascript
// unified-server.js - Update geometry endpoint
app.post('/api/customer-projects/:projectId/geometry', async (req, res) => {
try {
const { projectId } = req.params;
const {
roofWidth,
roofLength,
sternToRidgeHeight, // Renamed from wallHeight
roofAngleDegrees, // New field
roofPitch,
roofType,
complexity,
spaerDistance = 0.6, // New field with default
calculationMethod = 'angle',
allowAngleEdit = true
} = req.body;
// Validation
if (!roofWidth || !roofLength || !sternToRidgeHeight) {
return res.status(400).json({
success: false,
error: 'Tag bredde, længde og højde stern til kip er påkrævet'
});
}
// Calculate geometry
const width = parseFloat(roofWidth);
const length = parseFloat(roofLength);
const ridgeHeight = parseFloat(sternToRidgeHeight);
const angle = parseFloat(roofAngleDegrees);
const spaerDist = parseFloat(spaerDistance);
// Calculate roof area using Pythagoras
const halfWidth = width / 2;
const roofSlope = Math.sqrt(Math.pow(halfWidth, 2) + Math.pow(ridgeHeight, 2));
const totalArea = 2 * roofSlope * length;
// Calculate spær count
const spaerCount = Math.ceil(length / spaerDist) + 1;
const totalSpaerLength = spaerCount * roofSlope * 2;
// Calculate vindskede
const vindskede = Math.sqrt(Math.pow(roofSlope, 2) + Math.pow(length, 2));
// Save to database
await databaseService.query(`
INSERT INTO roof_geometry (
project_id, width_main, length_main, total_area,
stern_to_ridge_height, roof_angle_degrees, roof_pitch,
roof_type, complexity_factor, roof_height, vindskede_lbm,
spaer_distance, spaer_count, allow_angle_edit, calculation_method,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
width_main = VALUES(width_main),
length_main = VALUES(length_main),
total_area = VALUES(total_area),
stern_to_ridge_height = VALUES(stern_to_ridge_height),
roof_angle_degrees = VALUES(roof_angle_degrees),
roof_pitch = VALUES(roof_pitch),
spaer_distance = VALUES(spaer_distance),
spaer_count = VALUES(spaer_count),
allow_angle_edit = VALUES(allow_angle_edit),
calculation_method = VALUES(calculation_method),
updated_at = NOW()
`, [
projectId, width, length, totalArea,
ridgeHeight, angle, roofPitch,
roofType, complexity, roofSlope, vindskede,
spaerDist, spaerCount, allowAngleEdit, calculationMethod
]);
res.json({
success: true,
geometry: {
area: totalArea.toFixed(2),
roofSlope: roofSlope.toFixed(2),
spaerCount,
totalSpaerLength: totalSpaerLength.toFixed(2),
vindskede: vindskede.toFixed(2),
actualSpaerDistance: (length / (spaerCount - 1)).toFixed(2)
}
});
} catch (error) {
console.error('Geometry calculation error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
```
---
## 3️⃣ Smart Pakker Udvidelse
### 📝 Beskrivelse
Smart pakker skal indeholde materialer, tidsforbrug og opgaver i korrekt rækkefølge.
### 🎯 Acceptance Criteria
- [x] Pakker gemmes med defineret rækkefølge af opgaver
- [x] Materialer og tid kobles automatisk til geometridata
- [x] Final review viser samlet tids- og materialeberegning
### 🔧 Teknisk Implementation
#### Database Schema
```sql
-- Tilføj task ordering til smart packages
ALTER TABLE package_tasks
ADD COLUMN task_order INT DEFAULT 0 COMMENT 'Rækkefølge i pakken',
ADD COLUMN task_phase ENUM('forberedelse', 'hovedarbejde', 'finishing') DEFAULT 'hovedarbejde',
ADD COLUMN depends_on_task_id INT COMMENT 'Opgave der skal være færdig først',
ADD FOREIGN KEY (depends_on_task_id) REFERENCES package_tasks(id);
-- Tilføj geometri-kobling til materialer
ALTER TABLE package_materials
ADD COLUMN geometry_multiplier VARCHAR(50) COMMENT 'Formel: area, length, width, spaer_count',
ADD COLUMN base_quantity DECIMAL(10,2) COMMENT 'Basisantal før multiplikation',
ADD COLUMN waste_factor DECIMAL(5,2) DEFAULT 1.1 COMMENT 'Spild-faktor (1.1 = 10% spild)';
CREATE INDEX idx_task_order ON package_tasks(smart_package_id, task_order);
```
#### Service Implementation
```javascript
// backend/src/services/smartPackageService.js
class SmartPackageService {
/**
* Hent smart pakke med opgaver i korrekt rækkefølge
*/
async getPackageWithOrderedTasks(packageId) {
const packageData = await db.query(
'SELECT * FROM smart_packages WHERE id = ?',
[packageId]
);
const tasks = await db.query(`
SELECT
pt.*,
pt.task_order,
pt.task_phase,
pt.depends_on_task_id,
dt.task_name as depends_on_task_name
FROM package_tasks pt
LEFT JOIN package_tasks dt ON pt.depends_on_task_id = dt.id
WHERE pt.smart_package_id = ?
ORDER BY pt.task_phase, pt.task_order ASC
`, [packageId]);
const materials = await db.query(`
SELECT
pm.*,
pm.geometry_multiplier,
pm.base_quantity,
pm.waste_factor
FROM package_materials pm
WHERE pm.smart_package_id = ?
ORDER BY pm.material_category, pm.material_name
`, [packageId]);
return {
package: packageData[0],
tasks,
materials
};
}
/**
* Beregn materialer baseret på geometri
*/
calculateMaterialsForGeometry(materials, geometry) {
return materials.map(material => {
let quantity = material.base_quantity || 0;
// Apply geometry multiplier
switch (material.geometry_multiplier) {
case 'area':
quantity = geometry.total_area * material.base_quantity;
break;
case 'length':
quantity = geometry.length_main * material.base_quantity;
break;
case 'width':
quantity = geometry.width_main * material.base_quantity;
break;
case 'spaer_count':
quantity = geometry.spaer_count * material.base_quantity;
break;
case 'perimeter':
quantity = (2 * geometry.width_main + 2 * geometry.length_main) * material.base_quantity;
break;
}
// Apply waste factor
const finalQuantity = quantity * (material.waste_factor || 1.1);
return {
...material,
calculated_quantity: Math.ceil(finalQuantity),
base_calculation: quantity,
waste_amount: finalQuantity - quantity
};
});
}
/**
* Generer final review data
*/
async generateFinalReview(projectId) {
// Get project data
const project = await db.query(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
// Get geometry
const geometry = await db.query(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
// Get selected packages
const packages = await db.query(`
SELECT sp.*, cpp.quantity
FROM smart_packages sp
JOIN customer_project_packages cpp ON sp.id = cpp.smart_package_id
WHERE cpp.project_id = ?
`, [projectId]);
const packageDetails = [];
let totalHours = 0;
let totalMaterialCost = 0;
for (const pkg of packages) {
const details = await this.getPackageWithOrderedTasks(pkg.id);
const materials = this.calculateMaterialsForGeometry(
details.materials,
geometry[0]
);
// Calculate costs
const packageHours = details.tasks.reduce((sum, task) =>
sum + (task.estimated_hours || 0), 0
);
const packageMaterialCost = materials.reduce((sum, mat) =>
sum + (mat.calculated_quantity * mat.unit_price || 0), 0
);
totalHours += packageHours;
totalMaterialCost += packageMaterialCost;
packageDetails.push({
package: pkg,
tasks: details.tasks,
materials,
summary: {
hours: packageHours,
laborCost: packageHours * 1626, // Default hourly rate
materialCost: packageMaterialCost,
totalCost: (packageHours * 1626) + packageMaterialCost
}
});
}
return {
project: project[0],
geometry: geometry[0],
packages: packageDetails,
totals: {
hours: totalHours,
laborCost: totalHours * 1626,
materialCost: totalMaterialCost,
totalCost: (totalHours * 1626) + totalMaterialCost,
vat: ((totalHours * 1626) + totalMaterialCost) * 0.25
}
};
}
}
module.exports = new SmartPackageService();
```
---
## 4️⃣ AI-baseret Kundebeskrivelse
### 📝 Beskrivelse
Generér en kundevenlig tekst ud fra montagevejledningen og indsæt i tilbudsbeskrivelsen.
### 🎯 Acceptance Criteria
- [x] Beskrivelse er letforståelig og kundevenlig
- [x] Teknisk montagevejledning gemmes separat i opgavefeltet
- [x] Begge dele overføres korrekt til Ordrestyring
### 🔧 Teknisk Implementation
#### Database Schema
```sql
-- Tilføj felter til beskrivelser
ALTER TABLE package_tasks
ADD COLUMN technical_description TEXT COMMENT 'Teknisk montagevejledning',
ADD COLUMN customer_friendly_description TEXT COMMENT 'Kundevenlig beskrivelse',
ADD COLUMN ai_generated BOOLEAN DEFAULT FALSE,
ADD COLUMN ai_generation_timestamp TIMESTAMP NULL;
ALTER TABLE customer_projects
ADD COLUMN customer_description TEXT COMMENT 'Samlet kundevenlig beskrivelse',
ADD COLUMN technical_notes TEXT COMMENT 'Tekniske noter til montører';
```
#### AI Service
```javascript
// backend/src/services/aiDescriptionService.js
const OpenAI = require('openai');
class AIDescriptionService {
constructor() {
this.openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
}
/**
* Generér kundevenlig beskrivelse fra teknisk tekst
*/
async generateCustomerFriendlyDescription(technicalDescription, context = {}) {
const prompt = `
Du er en ekspert i at kommunikere byggeprojekter til kunder på en letforståelig måde.
TEKNISK BESKRIVELSE:
${technicalDescription}
KONTEKST:
- Projekt type: ${context.projectType || 'Byggeprojekt'}
- Område: ${context.area || 'Ikke specificeret'}
- Kunde niveau: ${context.customerLevel || 'Standard'}
OPGAVE:
Omskriv den tekniske beskrivelse til en kundevenlig tekst der:
1. Er let at forstå for en person uden byggefaglig baggrund
2. Fokuserer på fordele og resultatet for kunden
3. Undgår teknisk jargon (eller forklarer det simpelt)
4. Er positiv og professionel i tonen
5. Er max 3-4 sætninger
KUNDEVENLIG BESKRIVELSE:
`.trim();
try {
const completion = await this.openai.chat.completions.create({
model: "gpt-4",
messages: [
{
role: "system",
content: "Du er en professionel kommunikationsekspert inden for byggeri. Du omskriver tekniske beskrivelser til kundevenlige tekster."
},
{
role: "user",
content: prompt
}
],
temperature: 0.7,
max_tokens: 300
});
return completion.choices[0].message.content.trim();
} catch (error) {
console.error('AI generation error:', error);
throw new Error('Kunne ikke generere kundevenlig beskrivelse');
}
}
/**
* Generér beskrivelse for alle opgaver i en pakke
*/
async generateDescriptionsForPackage(packageId) {
const tasks = await db.query(
'SELECT * FROM package_tasks WHERE smart_package_id = ?',
[packageId]
);
const results = [];
for (const task of tasks) {
if (!task.technical_description) {
console.log(`⚠️ No technical description for task ${task.id}, skipping`);
continue;
}
const customerDescription = await this.generateCustomerFriendlyDescription(
task.technical_description,
{
projectType: 'Tag udskiftning',
area: 'Tag'
}
);
await db.query(`
UPDATE package_tasks
SET customer_friendly_description = ?,
ai_generated = TRUE,
ai_generation_timestamp = NOW()
WHERE id = ?
`, [customerDescription, task.id]);
results.push({
taskId: task.id,
taskName: task.task_name,
technical: task.technical_description,
customerFriendly: customerDescription
});
}
return results;
}
/**
* Generér samlet projektbeskrivelse
*/
async generateProjectDescription(projectId) {
const tasks = await db.query(`
SELECT
pt.customer_friendly_description,
pt.task_name,
pt.task_order,
sp.package_name
FROM package_tasks pt
JOIN customer_project_packages cpp ON pt.smart_package_id = cpp.smart_package_id
JOIN smart_packages sp ON sp.id = cpp.smart_package_id
WHERE cpp.project_id = ?
ORDER BY pt.task_order
`, [projectId]);
// Gruppér efter pakke
const packageGroups = {};
for (const task of tasks) {
if (!packageGroups[task.package_name]) {
packageGroups[task.package_name] = [];
}
packageGroups[task.package_name].push(task);
}
// Byg samlet beskrivelse
let description = '## Hvad indgår i projektet?\n\n';
for (const [packageName, packageTasks] of Object.entries(packageGroups)) {
description += `### ${packageName}\n`;
for (const task of packageTasks) {
if (task.customer_friendly_description) {
description += `- ${task.customer_friendly_description}\n`;
}
}
description += '\n';
}
await db.query(
'UPDATE customer_projects SET customer_description = ? WHERE id = ?',
[description, projectId]
);
return description;
}
/**
* Sync til Ordrestyring med begge beskrivelser
*/
async syncToOrdrestyring(projectId) {
const project = await db.query(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
const tasks = await db.query(`
SELECT
pt.task_name,
pt.technical_description,
pt.customer_friendly_description,
pt.estimated_hours
FROM package_tasks pt
JOIN customer_project_packages cpp ON pt.smart_package_id = cpp.smart_package_id
WHERE cpp.project_id = ?
`, [projectId]);
// Format til Ordrestyring
const ordrestyringPayload = {
project_name: project[0].project_name,
customer_description: project[0].customer_description,
tasks: tasks.map(task => ({
name: task.task_name,
description: task.customer_friendly_description, // Til kunden
technical_notes: task.technical_description, // Til montører
estimated_hours: task.estimated_hours
}))
};
// Send til Ordrestyring API
const response = await fetch(`${process.env.ORDRESTYRING_API_URL}/projects`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.ORDRESTYRING_API_TOKEN}`
},
body: JSON.stringify(ordrestyringPayload)
});
return await response.json();
}
}
module.exports = new AIDescriptionService();
```
#### API Endpoints
```javascript
// Generér kundevenlig beskrivelse for en opgave
app.post('/api/tasks/:taskId/generate-description', async (req, res) => {
const { taskId } = req.params;
const task = await db.query('SELECT * FROM package_tasks WHERE id = ?', [taskId]);
const description = await aiDescriptionService.generateCustomerFriendlyDescription(
task[0].technical_description
);
await db.query(
'UPDATE package_tasks SET customer_friendly_description = ?, ai_generated = TRUE WHERE id = ?',
[description, taskId]
);
res.json({ success: true, description });
});
// Generér beskrivelser for hele pakken
app.post('/api/packages/:packageId/generate-descriptions', async (req, res) => {
const { packageId } = req.params;
const results = await aiDescriptionService.generateDescriptionsForPackage(packageId);
res.json({ success: true, results });
});
// Generér samlet projektbeskrivelse
app.post('/api/projects/:projectId/generate-description', async (req, res) => {
const { projectId } = req.params;
const description = await aiDescriptionService.generateProjectDescription(projectId);
res.json({ success: true, description });
});
// Sync til Ordrestyring
app.post('/api/projects/:projectId/sync-to-ordrestyring', async (req, res) => {
const { projectId } = req.params;
const result = await aiDescriptionService.syncToOrdrestyring(projectId);
res.json({ success: true, result });
});
```
#### Frontend Component
```jsx
// frontend/src/components/AIDescriptionGenerator.js
function AIDescriptionGenerator({ taskId, technicalDescription, onGenerated }) {
const [loading, setLoading] = useState(false);
const [customerDescription, setCustomerDescription] = useState('');
const generateDescription = async () => {
setLoading(true);
try {
const response = await fetch(`/api/tasks/${taskId}/generate-description`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
setCustomerDescription(result.description);
onGenerated(result.description);
}
} catch (error) {
console.error('Error generating description:', error);
} finally {
setLoading(false);
}
};
return (
<div className="ai-description-generator">
<div className="technical-desc">
<h4>Teknisk beskrivelse (til montører):</h4>
<p>{technicalDescription}</p>
</div>
<button onClick={generateDescription} disabled={loading}>
{loading ? '🤖 Genererer...' : '✨ Generér kundevenlig beskrivelse'}
</button>
{customerDescription && (
<div className="customer-desc">
<h4>Kundevenlig beskrivelse:</h4>
<p>{customerDescription}</p>
<textarea
value={customerDescription}
onChange={(e) => setCustomerDescription(e.target.value)}
placeholder="Rediger beskrivelsen her..."
/>
</div>
)}
</div>
);
}
```
---
## 5️⃣ PDF-preview i Final Review
### 📝 Beskrivelse
Systemet skal tilbyde en PDF-forhåndsvisning af tilbuddet med logo og formatering.
### 🎯 Acceptance Criteria
- [x] Knap i final review åbner en PDF-preview
- [x] PDF indeholder alle relevante detaljer og firmaets logo
- [x] PDF'en kan gemmes eller sendes videre
### 🔧 Teknisk Implementation
#### Install Dependencies
```bash
npm install puppeteer pdfkit --save
```
#### PDF Generation Service
```javascript
// backend/src/services/pdfGenerationService.js
const puppeteer = require('puppeteer');
const fs = require('fs').promises;
const path = require('path');
class PDFGenerationService {
/**
* Generér PDF tilbud
*/
async generateQuotePDF(projectId) {
// Get all data
const project = await db.query(
'SELECT * FROM customer_projects WHERE id = ?',
[projectId]
);
const geometry = await db.query(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
const packages = await db.query(`
SELECT
sp.*,
cpp.quantity,
cpp.custom_price
FROM smart_packages sp
JOIN customer_project_packages cpp ON sp.id = cpp.smart_package_id
WHERE cpp.project_id = ?
`, [projectId]);
const tasks = await db.query(`
SELECT
pt.task_name,
pt.customer_friendly_description,
pt.estimated_hours,
sp.package_name
FROM package_tasks pt
JOIN customer_project_packages cpp ON pt.smart_package_id = cpp.smart_package_id
JOIN smart_packages sp ON sp.id = cpp.smart_package_id
WHERE cpp.project_id = ?
ORDER BY pt.task_order
`, [projectId]);
const materials = await db.query(`
SELECT
pm.material_name,
pm.calculated_quantity,
pm.unit_price,
pm.unit,
sp.package_name
FROM package_materials pm
JOIN customer_project_packages cpp ON pm.smart_package_id = cpp.smart_package_id
JOIN smart_packages sp ON sp.id = cpp.smart_package_id
WHERE cpp.project_id = ?
`, [projectId]);
// Calculate totals
const totalHours = tasks.reduce((sum, t) => sum + t.estimated_hours, 0);
const laborCost = totalHours * 1626;
const materialCost = materials.reduce((sum, m) =>
sum + (m.calculated_quantity * m.unit_price), 0
);
const subtotal = laborCost + materialCost;
const vat = subtotal * 0.25;
const total = subtotal + vat;
// Generate HTML
const html = this.generateQuoteHTML({
project: project[0],
geometry: geometry[0],
packages,
tasks,
materials,
totals: {
hours: totalHours,
laborCost,
materialCost,
subtotal,
vat,
total
}
});
// Generate PDF from HTML
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({
format: 'A4',
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
},
printBackground: true
});
await browser.close();
// Save PDF
const filename = `tilbud_${projectId}_${Date.now()}.pdf`;
const filepath = path.join(__dirname, '../../public/quotes', filename);
await fs.writeFile(filepath, pdfBuffer);
// Save reference in database
await db.query(
'UPDATE customer_projects SET quote_pdf_path = ? WHERE id = ?',
[filename, projectId]
);
return {
filename,
filepath,
url: `/quotes/${filename}`
};
}
/**
* Generér HTML til PDF
*/
generateQuoteHTML(data) {
const { project, geometry, packages, tasks, materials, totals } = data;
const today = new Date().toLocaleDateString('da-DK');
return `
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<title>Tilbud - ${project.project_name}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 11pt;
line-height: 1.6;
color: #333;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
border-bottom: 3px solid #f59e0b;
padding-bottom: 20px;
}
.logo {
width: 150px;
height: auto;
}
.company-info {
text-align: right;
font-size: 9pt;
}
h1 {
color: #1e40af;
font-size: 24pt;
margin-bottom: 10px;
}
h2 {
color: #1e40af;
font-size: 16pt;
margin: 20px 0 10px 0;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 5px;
}
h3 {
color: #374151;
font-size: 13pt;
margin: 15px 0 8px 0;
}
.customer-info, .project-info {
margin-bottom: 20px;
background: #f9fafb;
padding: 15px;
border-radius: 5px;
}
.info-grid {
display: grid;
grid-template-columns: 150px 1fr;
gap: 8px;
}
.info-label {
font-weight: bold;
color: #6b7280;
}
table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}
th {
background: #1e40af;
color: white;
padding: 10px;
text-align: left;
font-weight: bold;
}
td {
padding: 8px 10px;
border-bottom: 1px solid #e5e7eb;
}
tr:nth-child(even) {
background: #f9fafb;
}
.totals-table {
margin-top: 20px;
width: 400px;
margin-left: auto;
}
.totals-table td {
padding: 8px;
}
.totals-table .total-row {
background: #fef3c7;
font-weight: bold;
font-size: 12pt;
border-top: 2px solid #f59e0b;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 2px solid #e5e7eb;
font-size: 9pt;
color: #6b7280;
text-align: center;
}
.section {
margin-bottom: 30px;
}
</style>
</head>
<body>
<div class="header">
<div>
<img src="/logo.png" alt="Firma Logo" class="logo" />
</div>
<div class="company-info">
<strong>Dit Firma ApS</strong><br>
Firmaadresse 123<br>
8000 Aarhus C<br>
CVR: 12345678<br>
Tlf: +45 12 34 56 78<br>
Email: [email protected]
</div>
</div>
<h1>Tilbud</h1>
<p style="margin-bottom: 20px;">Dato: ${today}</p>
<div class="section customer-info">
<h2>Kundeoplysninger</h2>
<div class="info-grid">
<span class="info-label">Navn:</span>
<span>${project.customer_name}</span>
<span class="info-label">Adresse:</span>
<span>${project.customer_address || 'Ikke angivet'}</span>
<span class="info-label">Email:</span>
<span>${project.customer_email || 'Ikke angivet'}</span>
<span class="info-label">Telefon:</span>
<span>${project.customer_phone || 'Ikke angivet'}</span>
</div>
</div>
<div class="section project-info">
<h2>Projektbeskrivelse</h2>
<div class="info-grid">
<span class="info-label">Projekt:</span>
<span>${project.project_name}</span>
<span class="info-label">Type:</span>
<span>Tag udskiftning</span>
${geometry ? `
<span class="info-label">Tagareal:</span>
<span>${geometry.total_area} m²</span>
<span class="info-label">Dimensioner:</span>
<span>${geometry.width_main}m × ${geometry.length_main}m</span>
` : ''}
</div>
${project.customer_description ? `
<div style="margin-top: 15px;">
<p>${project.customer_description.replace(/\n/g, '<br>')}</p>
</div>
` : ''}
</div>
<div class="section">
<h2>Arbejdsopgaver</h2>
<table>
<thead>
<tr>
<th>Opgave</th>
<th>Beskrivelse</th>
<th style="text-align: right;">Timer</th>
</tr>
</thead>
<tbody>
${tasks.map(task => `
<tr>
<td><strong>${task.task_name}</strong></td>
<td>${task.customer_friendly_description || '-'}</td>
<td style="text-align: right;">${task.estimated_hours}h</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div class="section">
<h2>Materialer</h2>
<table>
<thead>
<tr>
<th>Materiale</th>
<th style="text-align: right;">Antal</th>
<th>Enhed</th>
<th style="text-align: right;">Pris/enhed</th>
<th style="text-align: right;">Total</th>
</tr>
</thead>
<tbody>
${materials.map(mat => `
<tr>
<td>${mat.material_name}</td>
<td style="text-align: right;">${mat.calculated_quantity}</td>
<td>${mat.unit}</td>
<td style="text-align: right;">${mat.unit_price.toFixed(2)} kr.</td>
<td style="text-align: right;">${(mat.calculated_quantity * mat.unit_price).toFixed(2)} kr.</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<table class="totals-table">
<tr>
<td>Arbejdsløn (${totals.hours}h á 1.626 kr.):</td>
<td style="text-align: right;">${totals.laborCost.toFixed(2)} kr.</td>
</tr>
<tr>
<td>Materialer:</td>
<td style="text-align: right;">${totals.materialCost.toFixed(2)} kr.</td>
</tr>
<tr>
<td><strong>Subtotal:</strong></td>
<td style="text-align: right;"><strong>${totals.subtotal.toFixed(2)} kr.</strong></td>
</tr>
<tr>
<td>Moms (25%):</td>
<td style="text-align: right;">${totals.vat.toFixed(2)} kr.</td>
</tr>
<tr class="total-row">
<td>TOTAL:</td>
<td style="text-align: right;">${totals.total.toFixed(2)} kr.</td>
</tr>
</table>
<div class="section" style="margin-top: 40px;">
<h2>Betalingsbetingelser</h2>
<p>
Betaling: Netto 30 dage<br>
Tilbuddet er gyldigt i 30 dage fra dato.<br>
Priserne er ekskl. moms, medmindre andet er angivet.
</p>
</div>
<div class="footer">
<p>Med venlig hilsen<br>
<strong>Dit Firma ApS</strong></p>
</div>
</body>
</html>
`;
}
}
module.exports = new PDFGenerationService();
```
#### API Endpoint
```javascript
// Generér PDF tilbud
app.post('/api/projects/:projectId/generate-pdf', async (req, res) => {
try {
const { projectId } = req.params;
const result = await pdfGenerationService.generateQuotePDF(projectId);
res.json({
success: true,
pdfUrl: result.url,
filename: result.filename
});
} catch (error) {
console.error('PDF generation error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// Download PDF
app.get('/api/projects/:projectId/download-pdf', async (req, res) => {
const { projectId } = req.params;
const project = await db.query(
'SELECT quote_pdf_path FROM customer_projects WHERE id = ?',
[projectId]
);
if (!project[0] || !project[0].quote_pdf_path) {
return res.status(404).json({ error: 'PDF ikke fundet' });
}
const filepath = path.join(__dirname, 'public/quotes', project[0].quote_pdf_path);
res.download(filepath);
});
```
#### Frontend Component
```jsx
// frontend/src/components/PDFPreview.js
function PDFPreview({ projectId }) {
const [pdfUrl, setPdfUrl] = useState(null);
const [loading, setLoading] = useState(false);
const generatePDF = async () => {
setLoading(true);
try {
const response = await fetch(`/api/projects/${projectId}/generate-pdf`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
setPdfUrl(result.pdfUrl);
}
} catch (error) {
console.error('Error generating PDF:', error);
} finally {
setLoading(false);
}
};
return (
<div className="pdf-preview">
<button onClick={generatePDF} disabled={loading}>
{loading ? '📄 Genererer PDF...' : '📄 Generer PDF forhåndsvisning'}
</button>
{pdfUrl && (
<div className="pdf-viewer">
<iframe
src={pdfUrl}
width="100%"
height="800px"
title="PDF Preview"
/>
<div className="pdf-actions">
<a href={pdfUrl} download className="btn">
💾 Download PDF
</a>
<button onClick={() => window.open(pdfUrl, '_blank')}>
🔗 Åbn i nyt vindue
</button>
</div>
</div>
)}
</div>
);
}
```
---
## 6️⃣ Web-scraper til Montagevejledninger
### 📝 Beskrivelse
Systemet skal kunne logge ind på Bygma og hente montagevejledninger (PDF) automatisk.
### 🎯 Acceptance Criteria
- [x] Web-scraper logger ind med gyldige brugeroplysninger
- [x] Henter PDF-links til montagevejledninger
- [x] Gemmer PDF'er lokalt til brug i smartpakker
### 🔧 Teknisk Implementation
#### Install Dependencies
```bash
npm install puppeteer axios cheerio --save
```
#### Web Scraper Service
```javascript
// backend/src/services/bygmaScraperService.js
const puppeteer = require('puppeteer');
const axios = require('axios');
const fs = require('fs').promises;
const path = require('path');
class BygmaScraperService {
constructor() {
this.baseUrl = 'https://www.bygma.dk';
this.loginUrl = 'https://www.bygma.dk/login';
this.credentials = {
username: process.env.BYGMA_USERNAME,
password: process.env.BYGMA_PASSWORD
};
}
/**
* Login til Bygma
*/
async login(page) {
console.log('🔐 Logger ind på Bygma...');
await page.goto(this.loginUrl, { waitUntil: 'networkidle2' });
// Udfyld login formular
await page.type('#username', this.credentials.username);
await page.type('#password', this.credentials.password);
// Klik login knap
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle2' }),
page.click('button[type="submit"]')
]);
console.log('✅ Login succesfuldt');
}
/**
* Søg efter produkt
*/
async searchProduct(page, productNumber) {
console.log(`🔍 Søger efter produkt: ${productNumber}`);
await page.goto(`${this.baseUrl}/search?q=${productNumber}`, {
waitUntil: 'networkidle2'
});
// Klik på første resultat
const firstProduct = await page.$('.product-item a');
if (!firstProduct) {
throw new Error(`Produkt ${productNumber} ikke fundet`);
}
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle2' }),
firstProduct.click()
]);
}
/**
* Hent montagevejledning links
*/
async getInstallationGuideLinks(page) {
console.log('📄 Henter montagevejledninger...');
// Find download sektion
const pdfLinks = await page.evaluate(() => {
const links = [];
const downloadSection = document.querySelector('.downloads') ||
document.querySelector('.documents');
if (downloadSection) {
const pdfElements = downloadSection.querySelectorAll('a[href$=".pdf"]');
pdfElements.forEach(el => {
links.push({
title: el.textContent.trim(),
url: el.href
});
});
}
return links;
});
console.log(`✅ Fundet ${pdfLinks.length} PDF'er`);
return pdfLinks;
}
/**
* Download PDF
*/
async downloadPDF(url, filename) {
console.log(`⬇️ Downloader: ${filename}`);
const response = await axios({
method: 'GET',
url: url,
responseType: 'arraybuffer'
});
const filepath = path.join(__dirname, '../../public/manuals', filename);
await fs.writeFile(filepath, response.data);
console.log(`✅ Gemt: ${filepath}`);
return filepath;
}
/**
* Hent alle montagevejledninger for et produkt
*/
async fetchInstallationGuides(productNumber) {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const page = await browser.newPage();
// Login
await this.login(page);
// Søg produkt
await this.searchProduct(page, productNumber);
// Hent PDF links
const pdfLinks = await this.getInstallationGuideLinks(page);
// Download alle PDF'er
const downloadedFiles = [];
for (const link of pdfLinks) {
const filename = `${productNumber}_${link.title.replace(/[^a-z0-9]/gi, '_')}.pdf`;
const filepath = await this.downloadPDF(link.url, filename);
downloadedFiles.push({
title: link.title,
filename,
filepath,
productNumber
});
}
// Gem reference i database
for (const file of downloadedFiles) {
await db.query(`
INSERT INTO installation_manuals (
product_number,
title,
filename,
filepath,
source,
downloaded_at
) VALUES (?, ?, ?, ?, 'bygma', NOW())
`, [file.productNumber, file.title, file.filename, file.filepath]);
}
return downloadedFiles;
} finally {
await browser.close();
}
}
/**
* Batch hent vejledninger for flere produkter
*/
async fetchBulkInstallationGuides(productNumbers) {
const results = {
success: [],
failed: []
};
for (const productNumber of productNumbers) {
try {
const files = await this.fetchInstallationGuides(productNumber);
results.success.push({
productNumber,
files
});
} catch (error) {
console.error(`❌ Fejl ved ${productNumber}:`, error.message);
results.failed.push({
productNumber,
error: error.message
});
}
// Vent mellem requests for at undgå rate limiting
await new Promise(resolve => setTimeout(resolve, 2000));
}
return results;
}
/**
* Hent vejledning fra cache hvis den findes
*/
async getManualFromCache(productNumber) {
const manuals = await db.query(
'SELECT * FROM installation_manuals WHERE product_number = ? ORDER BY downloaded_at DESC',
[productNumber]
);
return manuals;
}
}
module.exports = new BygmaScraperService();
```
#### Database Schema
```sql
-- Tabel til montagevejledninger
CREATE TABLE installation_manuals (
id INT AUTO_INCREMENT PRIMARY KEY,
product_number VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
filename VARCHAR(255) NOT NULL,
filepath VARCHAR(500) NOT NULL,
source ENUM('bygma', 'manual_upload', 'other') DEFAULT 'bygma',
file_size INT COMMENT 'Filstørrelse i bytes',
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_accessed TIMESTAMP NULL,
INDEX idx_product_number (product_number),
INDEX idx_source (source)
);
-- Link vejledninger til materialer
ALTER TABLE package_materials
ADD COLUMN installation_manual_id INT,
ADD FOREIGN KEY (installation_manual_id) REFERENCES installation_manuals(id);
```
#### API Endpoints
```javascript
// Hent montagevejledninger for et produkt
app.post('/api/manuals/fetch/:productNumber', async (req, res) => {
try {
const { productNumber } = req.params;
// Check cache først
const cached = await bygmaScraperService.getManualFromCache(productNumber);
if (cached.length > 0) {
return res.json({
success: true,
source: 'cache',
manuals: cached
});
}
// Hent fra Bygma
const files = await bygmaScraperService.fetchInstallationGuides(productNumber);
res.json({
success: true,
source: 'bygma',
manuals: files
});
} catch (error) {
console.error('Manual fetch error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// Batch hent vejledninger
app.post('/api/manuals/fetch-bulk', async (req, res) => {
const { productNumbers } = req.body;
if (!Array.isArray(productNumbers)) {
return res.status(400).json({ error: 'productNumbers skal være et array' });
}
const results = await bygmaScraperService.fetchBulkInstallationGuides(productNumbers);
res.json({ success: true, results });
});
// Hent vejledning fra cache
app.get('/api/manuals/:productNumber', async (req, res) => {
const { productNumber } = req.params;
const manuals = await bygmaScraperService.getManualFromCache(productNumber);
res.json({ success: true, manuals });
});
// Download manual fil
app.get('/api/manuals/:id/download', async (req, res) => {
const { id } = req.params;
const manual = await db.query(
'SELECT * FROM installation_manuals WHERE id = ?',
[id]
);
if (!manual[0]) {
return res.status(404).json({ error: 'Manual ikke fundet' });
}
// Update last accessed
await db.query(
'UPDATE installation_manuals SET last_accessed = NOW() WHERE id = ?',
[id]
);
res.download(manual[0].filepath);
});
```
#### Frontend Component
```jsx
// frontend/src/components/ManualFetcher.js
function ManualFetcher({ productNumber, onFetched }) {
const [loading, setLoading] = useState(false);
const [manuals, setManuals] = useState([]);
const fetchManuals = async () => {
setLoading(true);
try {
const response = await fetch(`/api/manuals/fetch/${productNumber}`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
setManuals(result.manuals);
onFetched(result.manuals);
}
} catch (error) {
console.error('Error fetching manuals:', error);
} finally {
setLoading(false);
}
};
return (
<div className="manual-fetcher">
<button onClick={fetchManuals} disabled={loading}>
{loading ? '📥 Henter vejledninger...' : '📚 Hent montagevejledninger'}
</button>
{manuals.length > 0 && (
<div className="manuals-list">
<h4>Montagevejledninger:</h4>
<ul>
{manuals.map(manual => (
<li key={manual.id}>
<a href={`/api/manuals/${manual.id}/download`} target="_blank">
📄 {manual.title}
</a>
<span className="manual-date">
{new Date(manual.downloaded_at).toLocaleDateString('da-DK')}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
```
---
## 📅 Implementation Timeline
### Uge 1-2: Foundation
- [ ] Database schema updates (alle features)
- [ ] Basic service implementations
- [ ] API endpoint struktur
### Uge 3: Feature 1 & 2
- [ ] Task categorization service
- [ ] Geometry calculation updates
- [ ] Testing og validering
### Uge 4: Feature 3 & 4
- [ ] Smart pakker udvidelse
- [ ] AI description service
- [ ] OpenAI integration
### Uge 5: Feature 5 & 6
- [ ] PDF generation service
- [ ] Bygma web scraper
- [ ] Testing og optimering
### Uge 6: Integration & Testing
- [ ] End-to-end testing
- [ ] Performance optimization
- [ ] Documentation
- [ ] Deployment
---
## 🧪 Testing Strategy
### Unit Tests
```javascript
// tests/services/taskCategorization.test.js
// tests/services/geometry.test.js
// tests/services/aiDescription.test.js
// tests/services/pdfGeneration.test.js
// tests/services/bygmaScraper.test.js
```
### Integration Tests
```javascript
// tests/integration/smartPackages.test.js
// tests/integration/finalReview.test.js
```
### E2E Tests
```javascript
// tests/e2e/createProject.test.js
// tests/e2e/generateQuote.test.js
```
---
## 📝 Environment Variables Required
```env
# OpenAI for AI descriptions
OPENAI_API_KEY=sk-...
# Bygma credentials
BYGMA_USERNAME=your_username
BYGMA_PASSWORD=your_password
# Ordrestyring API
ORDRESTYRING_API_URL=https://api.ordrestyring.dk
ORDRESTYRING_API_TOKEN=your_token
# File paths
MANUALS_DIR=/path/to/manuals
QUOTES_PDF_DIR=/path/to/quotes
```
---
**Status**: 📋 Komplet Roadmap
**Klar til**: Implementation
**Estimeret Tid**: 19-28 dage (4-6 uger)