Files
tilbudgivern/docs/FEATURE_ROADMAP.md
alexpolo1 4a372d2e28 clean up
2025-10-30 18:28:58 +00:00

61 KiB
Raw Blame History

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

  • System genkender nøgleord i beskrivelser
  • Beskrivelser mappe til standardiserede kategorier
  • Kan eksportere statistik pr. kategori

🔧 Teknisk Implementation

Database Schema

-- 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

// 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

// 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

-- 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

// 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

  • Feltnavn ændres til korrekt begreb (ryghøjde)
  • Gradfelt kan ændres efter beregning uden fejl
  • Spærafstand kan tilpasses (0,6m / 1m / variabel)

🔧 Teknisk Implementation

Database Schema Update

-- 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

// 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} </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

// 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

  • Pakker gemmes med defineret rækkefølge af opgaver
  • Materialer og tid kobles automatisk til geometridata
  • Final review viser samlet tids- og materialeberegning

🔧 Teknisk Implementation

Database Schema

-- 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

// 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

  • Beskrivelse er letforståelig og kundevenlig
  • Teknisk montagevejledning gemmes separat i opgavefeltet
  • Begge dele overføres korrekt til Ordrestyring

🔧 Teknisk Implementation

Database Schema

-- 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

// 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

// 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

// 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

  • Knap i final review åbner en PDF-preview
  • PDF indeholder alle relevante detaljer og firmaets logo
  • PDF'en kan gemmes eller sendes videre

🔧 Teknisk Implementation

Install Dependencies

npm install puppeteer pdfkit --save

PDF Generation Service

// 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: info@ditfirma.dk
    </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

// 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

// 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

  • Web-scraper logger ind med gyldige brugeroplysninger
  • Henter PDF-links til montagevejledninger
  • Gemmer PDF'er lokalt til brug i smartpakker

🔧 Teknisk Implementation

Install Dependencies

npm install puppeteer axios cheerio --save

Web Scraper Service

// 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

-- 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

// 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

// 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

// 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

// tests/integration/smartPackages.test.js
// tests/integration/finalReview.test.js

E2E Tests

// tests/e2e/createProject.test.js
// tests/e2e/generateQuote.test.js

📝 Environment Variables Required

# 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)