- 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.
2196 lines
61 KiB
Markdown
2196 lines
61 KiB
Markdown
# 🗺️ 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)
|