# 🗺️ 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 (

Tag Geometri

{/* Basic dimensions */}
{ setGeometry({...geometry, width: e.target.value}); recalculateGeometry({...geometry, width: e.target.value}); }} />
{ setGeometry({...geometry, length: e.target.value}); recalculateGeometry({...geometry, length: e.target.value}); }} />
{ setGeometry({...geometry, sternToRidgeHeight: e.target.value}); recalculateGeometry({...geometry, sternToRidgeHeight: e.target.value}); }} />
{/* Angle/Pitch input */}
handleAngleChange(parseFloat(e.target.value))} disabled={!allowEdit && calculatedValues} /> {!allowEdit && ( )}
{/* Spærafstand selector */}
{ setGeometry({...geometry, spaerDistance: e.target.value}); recalculateGeometry({...geometry, spaerDistance: e.target.value}); }} />
{/* Calculated values */} {calculatedValues && (

Beregnede værdier:

📐 Tagareal: {calculatedValues.roofArea} m²
📏 Taghældning: {calculatedValues.roofSlope} m
🪚 Antal spær: {calculatedValues.spaerCount} stk
📏 Faktisk afstand: {calculatedValues.actualSpaerDistance} m
🔗 Total spærlængde: {calculatedValues.totalSpaerLength} m
📐 Vindskede: {calculatedValues.vindskede} lbm
)}
); } ``` #### 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 (

Teknisk beskrivelse (til montører):

{technicalDescription}

{customerDescription && (

Kundevenlig beskrivelse:

{customerDescription}