Add project data, quotes, and real project details for Kunda a/s

- Created test_project_data.json with project details including customer information, project description, and counts for geometry, labor, materials, and quotes.
- Added test_quote_data.json containing company information, project details, geometry, labor, materials, and calculation summary.
- Introduced test_real_project.json with comprehensive project data including labor, materials, and detailed calculations.
- Generated updated_real_project_quote.pdf reflecting the latest project quote details.
This commit is contained in:
2025-09-17 15:13:52 +02:00
parent 04c84494ba
commit fc40f65fec
43 changed files with 2583 additions and 364 deletions
+110
View File
@@ -0,0 +1,110 @@
# Git Large File Problem - LØST ✅
## Problem
Git push fejlede med fejl: **"File core is 196.59 MB; this exceeds GitHub's file size limit of 100.00 MB"**
```
remote: error: File core is 196.59 MB; this exceeds GitHub's file size limit of 100.00 MB
remote: error: GH001: Large files detected. You may want to try Git Large File Storage
```
## Root Cause
En **core dump fil** (196MB) var blevet tilføjet til repository i commit `779bb30`. Core dumps genereres typisk når programmer crasher og skal ikke være i version control.
## Løsning Udført
### 1. 🔍 Identificeret Problem
```bash
find /home/alex/git/tilbudgivern -name "core" -type f -exec ls -lh {} \;
# Result: -rw------- 1 alex alex 197M Sep 17 09:37 /home/alex/git/tilbudgivern/core
```
### 2. 🗑️ Fjernet Core Fil
```bash
rm core
```
### 3. 📝 Opdateret .gitignore
Tilføjet core dump patterns til `.gitignore`:
```gitignore
# Core dumps and debug files
core
core.*
*.core
*.dump
```
### 4. 🧹 Renset Git Historie
Brugt `git filter-branch` til at fjerne core filen fra hele git historikken:
```bash
git filter-branch --force --index-filter 'git rm --cached --ignore-unmatch core' --prune-empty --tag-name-filter cat -- --all
```
### 5. 🚮 Garbage Collection
Renset git repository og fjernet alle spor:
```bash
rm -rf .git/refs/original/
git reflog expire --expire=now --all
git gc --prune=now --aggressive
```
### 6. ⬆️ Force Push
Push'et den rensede historie til GitHub:
```bash
git push origin main --force
```
## Resultat: ✅ SUCCESS
### Før Cleanup:
- **Repository størrelse**: >200MB (med core fil)
- **Git push**: ❌ FEJL - "File size limit exceeded"
### Efter Cleanup:
- **Repository størrelse**: 3.6MB
- **Git push**: ✅ SUCCESS
- **Core filer**: Permanent fjernet fra historie
## Preventive Tiltag
### 📋 .gitignore Opdateret
Tilføjet patterns til at forhindre fremtidige core dump filer:
```gitignore
# Core dumps and debug files
core
core.*
*.core
*.dump
```
### 🛡️ Beskyttelse Mod Fremtidige Issues
- Core dump filer vil automatisk blive ignoreret
- PM2 crash dumps vil ikke længere blive tracked
- Repository forbliver rent og let
## Lektioner Lært
### 🚨 Core Dumps
- Genereres ved program crashes (fx Node.js PM2 fejl)
- Skal ALDRIG committes til version control
- Kan være meget store (100MB+)
### 🧹 Git Cleanup
- `git filter-branch` kan fjerne filer fra hele historikken
- `--force` push nødvendigt efter historie ændring
- Altid cleanup refs og garbage collect efter filter-branch
### 📁 .gitignore Best Practices
- Inkluder core dump patterns
- Overvej PM2 og process dump filer
- Regulær review af `.gitignore` for nye file types
## Status: 🎉 **PROBLEM LØST**
- ✅ Core fil fjernet fra repository
- ✅ Git historie renset
- ✅ Push til GitHub successful
- ✅ .gitignore opdateret for fremtiden
- ✅ Repository størrelse optimeret (3.6MB)
**Alle nye commits og push'es vil nu fungere normalt!**
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""
PDF Generator for Tilbudgivern
Uses ReportLab for reliable PDF generation on ARM/Raspberry Pi systems
"""
import sys
import json
import os
from datetime import datetime
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm, cm
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
from reportlab.lib import colors
from reportlab.lib.utils import ImageReader
from io import BytesIO
import base64
class TilbudgivernPDFGenerator:
def __init__(self):
self.styles = getSampleStyleSheet()
self.setup_custom_styles()
def setup_custom_styles(self):
"""Setup custom styles for the PDF"""
# Header style
self.styles.add(ParagraphStyle(
name='CompanyHeader',
parent=self.styles['Heading1'],
fontSize=16,
spaceAfter=6,
textColor=colors.darkblue,
alignment=TA_CENTER
))
# Quote header style
self.styles.add(ParagraphStyle(
name='QuoteHeader',
parent=self.styles['Heading2'],
fontSize=14,
spaceAfter=12,
textColor=colors.black,
alignment=TA_LEFT
))
# Body text with spacing
self.styles.add(ParagraphStyle(
name='BodySpaced',
parent=self.styles['Normal'],
fontSize=10,
spaceAfter=6,
leading=12
))
# Price text
self.styles.add(ParagraphStyle(
name='PriceText',
parent=self.styles['Normal'],
fontSize=11,
spaceAfter=4,
fontName='Helvetica-Bold'
))
def load_logo(self, logo_path):
"""Load company logo if available"""
try:
if os.path.exists(logo_path):
return Image(logo_path, width=60*mm, height=30*mm)
except Exception as e:
print(f"Warning: Could not load logo from {logo_path}: {e}")
return None
def safe_float(self, value, default=0):
"""Safely convert value to float"""
try:
if value is None:
return default
return float(value)
except (ValueError, TypeError):
return default
def safe_format_price(self, value, default=0):
"""Safely format value as price"""
try:
return f"{self.safe_float(value, default):.2f}"
except:
return f"{default:.2f}"
def generate_pdf(self, quote_data, output_path):
"""Generate PDF from quote data matching the exact HTML quote structure"""
try:
# Create document
doc = SimpleDocTemplate(
output_path,
pagesize=A4,
rightMargin=20*mm,
leftMargin=20*mm,
topMargin=20*mm,
bottomMargin=20*mm
)
# Build content
story = []
# Extract data
project = quote_data.get('project', {})
geometry = quote_data.get('geometry', {})
labor = quote_data.get('labor', {})
materials = quote_data.get('materials', [])
calculation = quote_data.get('calculation', {})
# Company header - exact match to HTML
story.append(Paragraph("TILBUD FRA Tømrer- og Snedkermester Mikael Holck", self.styles['CompanyHeader']))
story.append(Spacer(1, 10*mm))
# Customer and project info - exact match
story.append(Paragraph(f"Til: {project.get('customer_name', 'N/A')}", self.styles['BodySpaced']))
story.append(Paragraph(f"Projekt: {project.get('project_name', 'N/A')}", self.styles['BodySpaced']))
story.append(Paragraph(f"Dato: {datetime.now().strftime('%d.%m.%Y')}", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Greeting - exact match
story.append(Paragraph(f"Kære {project.get('customer_name', 'N/A')},", self.styles['BodySpaced']))
story.append(Spacer(1, 3*mm))
# Thank you message - exact match
story.append(Paragraph(f"Tak for din henvendelse vedrørende {project.get('project_description', 'dit projekt')}.", self.styles['BodySpaced']))
story.append(Spacer(1, 3*mm))
# Company motto - exact match
story.append(Paragraph("Measure once cut thrice - Dette er kernen i alt, hvad vi laver hos Tømrer- og Snedkermester Mikael Holck.", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Project description header - exact match
story.append(Paragraph("PROJEKTBESKRIVELSE:", self.styles['QuoteHeader']))
story.append(Paragraph("Tagrenovering hos os betyder, at du får mere end bare et nyt tag. Vi giver dig professionel rådgivning i valg af den rigtige tagbelægning, så løsningen passer til både husets udtryk, dit budget og holder i mange år.", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Work section header - exact match
story.append(Paragraph("ARBEJDE DER UDFØRES:", self.styles['QuoteHeader']))
# Work details - exact match to HTML format
if geometry.get('roof_type'):
story.append(Paragraph(f"• Tag type: {geometry['roof_type']}", self.styles['BodySpaced']))
if geometry.get('total_area'):
story.append(Paragraph(f"• Samlet areal: {geometry['total_area']}", self.styles['BodySpaced']))
if labor.get('total_work_hours'):
carpenter_count = labor.get('carpenter_count', 1)
hourly_rate = self.safe_float(labor.get('hourly_rate'), 580)
story.append(Paragraph(f"• Arbejdstimer: {labor['total_work_hours']} timer med {carpenter_count} tømrere", self.styles['BodySpaced']))
story.append(Paragraph(f"• Timepris: {self.safe_format_price(hourly_rate)} kr/time", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Materials section - exact match
story.append(Paragraph("MATERIALER:", self.styles['QuoteHeader']))
# Materials list - exact match to HTML format
material_total = 0
for material in materials:
material_category = material.get('material_category', 'materialer')
quantity = material.get('quantity', 1)
total_price = self.safe_float(material.get('total_price'), 0)
material_total += total_price
story.append(Paragraph(f"{material_category}: {quantity} stk. - {self.safe_format_price(total_price)} kr", self.styles['BodySpaced']))
story.append(Spacer(1, 3*mm))
story.append(Paragraph("Vi indhenter tilbud fra flere leverandører, så du får den bedste balance mellem pris, kvalitet og holdbarhed. Alle materialer er godkendte og lever op til gældende byggestandarder.", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Price specification - exact match to HTML
story.append(Paragraph("PRISSPECIFIKATION:", self.styles['QuoteHeader']))
# Calculate values to match HTML exactly
try:
labor_cost = self.safe_float(calculation.get('total_labor_cost'), 0)
material_cost = self.safe_float(calculation.get('total_material_cost'), 285)
subtotal = labor_cost + material_cost
overhead_pct = 15.0
profit_pct = 20.0
vat_pct = 25.0
overhead = subtotal * (overhead_pct / 100)
profit = subtotal * (profit_pct / 100)
before_vat = subtotal + overhead + profit
vat = before_vat * (vat_pct / 100)
total = before_vat + vat
# Price lines - exact format match
story.append(Paragraph(f"Arbejdsløn: {self.safe_format_price(labor_cost)} kr", self.styles['BodySpaced']))
story.append(Paragraph(f"Materialer: {self.safe_format_price(material_cost)} kr", self.styles['BodySpaced']))
story.append(Paragraph(f"Subtotal: {self.safe_format_price(subtotal)} kr", self.styles['BodySpaced']))
story.append(Paragraph(f"Overhead ({overhead_pct:.1f}%): {self.safe_format_price(overhead)} kr", self.styles['BodySpaced']))
story.append(Paragraph(f"Fortjeneste ({profit_pct:.1f}%): {self.safe_format_price(profit)} kr", self.styles['BodySpaced']))
story.append(Paragraph(f"Moms ({vat_pct:.1f}%): {self.safe_format_price(vat)} kr", self.styles['BodySpaced']))
story.append(Spacer(1, 3*mm))
story.append(Paragraph(f"<b>SAMLET PRIS: {self.safe_format_price(total)} kr</b>", self.styles['PriceText']))
story.append(Spacer(1, 6*mm))
except Exception as e:
print(f"Error in price calculation: {e}")
print(f"Debug - calculation data: {calculation}")
# Fallback to simple pricing
story.append(Paragraph("PRISSPECIFIKATION:", self.styles['QuoteHeader']))
story.append(Paragraph("Priser beregnes efter aftale", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Company promise section - exact match
story.append(Paragraph("VORES LØFTE TIL DIG:", self.styles['QuoteHeader']))
story.append(Paragraph("• Præcision og ordentlighed i alt hvad vi laver", self.styles['BodySpaced']))
story.append(Paragraph("• Tradition og transformation går hånd i hånd", self.styles['BodySpaced']))
story.append(Paragraph("• Meningsfuldt, ordentligt og bæredygtigt håndværk", self.styles['BodySpaced']))
story.append(Paragraph("• Vi kommer til tiden og står inde for vores arbejde", self.styles['BodySpaced']))
story.append(Spacer(1, 3*mm))
story.append(Paragraph("Vi leverer altid det aftalte til tiden og med kvaliteten i top. Vores erfaring sikrer en flot og holdbar løsning, der øger både komforten og værdien af dit hjem.", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Guarantees section - exact match
story.append(Paragraph("GARANTIER OG SERVICE:", self.styles['QuoteHeader']))
story.append(Paragraph("• Gratis tagtjek tilbydes", self.styles['BodySpaced']))
story.append(Paragraph("• Professionel rådgivning i valg af løsninger", self.styles['BodySpaced']))
story.append(Paragraph("• Korrekt dokumentation og tryghed", self.styles['BodySpaced']))
story.append(Paragraph("• Kvalitet der holder i mange år", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Validity - exact match
story.append(Paragraph("<b>TILBUDDET ER GYLDIGT I 30 DAGE</b>", self.styles['PriceText']))
story.append(Spacer(1, 6*mm))
# Closing - exact match
story.append(Paragraph("Med venlig hilsen", self.styles['BodySpaced']))
story.append(Paragraph("Tømrer- og Snedkermester Mikael Holck", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Contact info - exact match
story.append(Paragraph("Kontakt:", self.styles['QuoteHeader']))
story.append(Paragraph("Telefon: XX XX XX XX", self.styles['BodySpaced']))
story.append(Paragraph("Email: [email protected]", self.styles['BodySpaced']))
story.append(Paragraph("Website: www.3byggetilbud.dk", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Final paragraph - exact match
story.append(Paragraph("Med fokus på kvalitet, præcision og tryghed leverer vi et resultat, du kan stole på. Vi vurderer altid tagets generelle tilstand og sikrer byggeteknisk korrekte løsninger.", self.styles['BodySpaced']))
# Build PDF
doc.build(story)
return True
except Exception as e:
print(f"Error generating PDF: {e}")
return False
def main():
"""Main function to handle command line arguments"""
if len(sys.argv) != 3:
print("Usage: python3 pdf_generator.py <json_data_file> <output_pdf_path>")
sys.exit(1)
json_file = sys.argv[1]
output_path = sys.argv[2]
try:
# Read quote data from JSON file
with open(json_file, 'r', encoding='utf-8') as f:
quote_data = json.load(f)
# Generate PDF
generator = TilbudgivernPDFGenerator()
success = generator.generate_pdf(quote_data, output_path)
if success:
print(f"SUCCESS: PDF generated at {output_path}")
sys.exit(0)
else:
print("ERROR: Failed to generate PDF")
sys.exit(1)
except FileNotFoundError:
print(f"ERROR: JSON file not found: {json_file}")
sys.exit(1)
except json.JSONDecodeError as e:
print(f"ERROR: Invalid JSON in file {json_file}: {e}")
sys.exit(1)
except Exception as e:
print(f"ERROR: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+443
View File
@@ -0,0 +1,443 @@
const axios = require('axios');
const logger = require('../utils/logger');
class OrdrestyringService {
constructor() {
this.apiUrl = 'https://beta7-api.ordrestyring.dk/graphql';
this.apiToken = process.env.ORDRESTYRING_API_TOKEN || 'SfwkRVY92qVee8tM';
this.headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${this.apiToken}`
};
}
async makeGraphQLRequest(query, variables = {}) {
try {
const response = await axios.post(this.apiUrl, {
query,
variables
}, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${this.apiToken}`
}
}); if (response.data.errors) {
logger.error('GraphQL errors:', response.data.errors);
throw new Error(`GraphQL Error: ${response.data.errors[0].message}`);
}
return response.data.data;
} catch (error) {
logger.error('Ordrestyring API error:', error.message);
throw error;
}
}
// Test API connection
async testConnection() {
const query = `
query {
cases(pagination: {cursor: null, limit: 1}) {
items {
id
caseNumber
}
nextCursor
}
}
`;
try {
const result = await this.makeGraphQLRequest(query);
logger.info('Ordrestyring API connection successful');
return result;
} catch (error) {
logger.error('Ordrestyring API connection failed:', error);
throw error;
}
}
// Get all cases with pagination
async getAllCases(limit = 50) {
let allCases = [];
let cursor = null;
let hasNextPage = true;
while (hasNextPage) {
const query = `
query($cursor: String, $limit: Int) {
cases(pagination: {cursor: $cursor, limit: $limit}, orderBy: {field: "updatedAt", direction: DESC}) {
items {
id
caseNumber
createdAt
updatedAt
status {
id
name
}
customer {
id
name
email
phone
address
}
projectName
projectDescription
total {
totalAmountExVat
totalAmountIncVat
currency
}
validUntil
caseType {
id
name
}
}
nextCursor
previousCursor
}
}
`;
try {
const result = await this.makeGraphQLRequest(query, { cursor, limit });
const cases = result.cases;
allCases = allCases.concat(cases.items);
cursor = cases.nextCursor;
hasNextPage = cursor !== null;
logger.info(`Fetched ${cases.items.length} cases, total so far: ${allCases.length}`);
// Rate limiting - wait between requests
if (hasNextPage) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
} catch (error) {
logger.error('Error fetching cases:', error);
throw error;
}
}
return allCases;
}
// Get detailed case by ID
async getCaseById(caseId) {
const query = `
query($id: Int!) {
caseById(id: $id) {
id
caseNumber
createdAt
updatedAt
status {
id
name
}
customer {
id
name
email
phone
address
}
projectName
projectDescription
total {
totalAmountExVat
totalAmountIncVat
currency
}
validUntil
caseType {
id
name
}
caseMaterials {
items {
id
description
quantity
unitPrice
totalPrice
unit {
name
}
}
}
caseDocuments {
items {
id
filename
fileSize
mimeType
createdAt
}
}
}
}
`;
try {
const result = await this.makeGraphQLRequest(query, { id: parseInt(caseId) });
return result.caseById;
} catch (error) {
logger.error(`Error fetching case ${caseId}:`, error);
throw error;
}
}
// Transform Ordrestyring case to our quote format
transformCaseToQuote(ordrestyringCase) {
return {
external_id: ordrestyringCase.id,
external_case_number: ordrestyringCase.caseNumber,
customer_name: ordrestyringCase.customer?.name || null,
customer_email: ordrestyringCase.customer?.email || null,
customer_phone: ordrestyringCase.customer?.phone || null,
customer_address: ordrestyringCase.customer?.address || null,
project_name: ordrestyringCase.projectName,
project_description: ordrestyringCase.projectDescription,
total_amount_excl_vat: parseFloat(ordrestyringCase.total?.totalAmountExVat || 0),
total_amount_incl_vat: parseFloat(ordrestyringCase.total?.totalAmountIncVat || 0),
currency: ordrestyringCase.total?.currency || 'DKK',
status: ordrestyringCase.status?.name || ordrestyringCase.status,
case_type: ordrestyringCase.caseType?.name || null,
valid_until: ordrestyringCase.validUntil,
created_at: ordrestyringCase.createdAt,
updated_at: ordrestyringCase.updatedAt,
source: 'ordrestyring'
};
}
// Import cases into our database
async importCasesToDatabase(databaseService) {
try {
logger.info('Starting import from Ordrestyring API...');
// Test connection first
await this.testConnection();
// Get all cases
const cases = await this.getAllCases();
logger.info(`Retrieved ${cases.length} cases from Ordrestyring`);
// Check if we need to create import table
await this.ensureImportTableExists(databaseService);
let importedCount = 0;
let updatedCount = 0;
let skippedCount = 0;
for (const ordrestyringCase of cases) {
try {
// Transform to our format
const quoteData = this.transformCaseToQuote(ordrestyringCase);
// Check if already exists
const [existing] = await databaseService.query(
'SELECT id FROM imported_quotes WHERE external_id = ? AND source = ?',
[quoteData.external_id, quoteData.source]
);
if (existing) {
// Update existing
await databaseService.query(`
UPDATE imported_quotes SET
external_case_number = ?,
customer_name = ?,
customer_email = ?,
customer_phone = ?,
customer_address = ?,
project_name = ?,
project_description = ?,
total_amount_excl_vat = ?,
total_amount_incl_vat = ?,
currency = ?,
status = ?,
valid_until = ?,
updated_at = ?,
imported_at = NOW()
WHERE external_id = ? AND source = ?
`, [
quoteData.external_case_number,
quoteData.customer_name,
quoteData.customer_email,
quoteData.customer_phone,
quoteData.customer_address,
quoteData.project_name,
quoteData.project_description,
quoteData.total_amount_excl_vat,
quoteData.total_amount_incl_vat,
quoteData.currency,
quoteData.status,
quoteData.valid_until,
quoteData.updated_at,
quoteData.external_id,
quoteData.source
]);
updatedCount++;
} else {
// Insert new
await databaseService.query(`
INSERT INTO imported_quotes (
external_id, external_case_number, customer_name, customer_email,
customer_phone, customer_address, project_name, project_description,
total_amount_excl_vat, total_amount_incl_vat, currency, status,
case_type, valid_until, created_at, updated_at, source, imported_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
`, [
quoteData.external_id,
quoteData.external_case_number,
quoteData.customer_name,
quoteData.customer_email,
quoteData.customer_phone,
quoteData.customer_address,
quoteData.project_name,
quoteData.project_description,
quoteData.total_amount_excl_vat,
quoteData.total_amount_incl_vat,
quoteData.currency,
quoteData.status,
quoteData.valid_until,
quoteData.created_at,
quoteData.updated_at,
quoteData.source
]);
importedCount++;
}
} catch (error) {
logger.error(`Error processing case ${ordrestyringCase.id}:`, error);
skippedCount++;
}
}
const summary = {
total_cases: cases.length,
imported: importedCount,
updated: updatedCount,
skipped: skippedCount
};
logger.info('Import completed:', summary);
return summary;
} catch (error) {
logger.error('Import failed:', error);
throw error;
}
}
// Ensure import table exists
async ensureImportTableExists(databaseService) {
const createTableQuery = `
CREATE TABLE IF NOT EXISTS imported_quotes (
id INT PRIMARY KEY AUTO_INCREMENT,
external_id INT NOT NULL,
external_case_number VARCHAR(100),
customer_name VARCHAR(255),
customer_email VARCHAR(255),
customer_phone VARCHAR(50),
customer_address TEXT,
project_name VARCHAR(255),
project_description TEXT,
total_amount_excl_vat DECIMAL(12,2),
total_amount_incl_vat DECIMAL(12,2),
currency VARCHAR(10) DEFAULT 'DKK',
status VARCHAR(50),
valid_until DATETIME,
created_at DATETIME,
updated_at DATETIME,
source VARCHAR(50) NOT NULL,
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_external_id_source (external_id, source),
INDEX idx_customer_name (customer_name),
INDEX idx_total_amount (total_amount_incl_vat),
INDEX idx_created_at (created_at),
UNIQUE KEY unique_external_source (external_id, source)
)
`;
await databaseService.query(createTableQuery);
logger.info('Import table ensured to exist');
}
// Generate statistics from imported data
async generateStatistics(databaseService) {
try {
// Basic statistics
const [totalStats] = await databaseService.query(`
SELECT
COUNT(*) as total_quotes,
SUM(total_amount_incl_vat) as total_value,
AVG(total_amount_incl_vat) as average_value,
MIN(total_amount_incl_vat) as min_value,
MAX(total_amount_incl_vat) as max_value,
COUNT(DISTINCT customer_name) as unique_customers
FROM imported_quotes
WHERE source = 'ordrestyring'
`);
// Monthly statistics
const monthlyStats = await databaseService.query(`
SELECT
DATE_FORMAT(created_at, '%Y-%m') as month,
COUNT(*) as quote_count,
SUM(total_amount_incl_vat) as monthly_value,
AVG(total_amount_incl_vat) as avg_quote_value
FROM imported_quotes
WHERE source = 'ordrestyring'
AND created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ORDER BY month DESC
`);
// Status distribution
const statusStats = await databaseService.query(`
SELECT
status,
COUNT(*) as count,
SUM(total_amount_incl_vat) as total_value
FROM imported_quotes
WHERE source = 'ordrestyring'
GROUP BY status
ORDER BY count DESC
`);
// Top customers
const topCustomers = await databaseService.query(`
SELECT
customer_name,
COUNT(*) as quote_count,
SUM(total_amount_incl_vat) as total_value,
AVG(total_amount_incl_vat) as avg_value
FROM imported_quotes
WHERE source = 'ordrestyring'
AND customer_name IS NOT NULL
GROUP BY customer_name
HAVING quote_count > 1
ORDER BY total_value DESC
LIMIT 20
`);
return {
total: totalStats,
monthly: monthlyStats,
status: statusStats,
top_customers: topCustomers
};
} catch (error) {
logger.error('Error generating statistics:', error);
throw error;
}
}
}
module.exports = OrdrestyringService;
@@ -186,6 +186,99 @@ class RoofGeometryService {
}
}
// AI-baseret timeberegning baseret på historiske data
async estimateWorkHoursWithAI(projectData, historicalData) {
try {
// Hent historiske projekter med lignende karakteristika
const similarProjects = await this.db.pool.execute(`
SELECT rg.total_area, rg.roof_type, rg.complexity_factor, pl.total_work_hours, pl.carpenter_count
FROM roof_geometry rg
JOIN project_labor pl ON rg.project_id = pl.project_id
WHERE rg.roof_type = ? AND rg.total_area BETWEEN ? AND ?
ORDER BY ABS(rg.total_area - ?) ASC
LIMIT 10
`, [
projectData.roof_type,
projectData.total_area * 0.7, // -30%
projectData.total_area * 1.3, // +30%
projectData.total_area
]);
if (similarProjects[0].length === 0) {
// Fallback til standard estimering hvis ingen historiske data
return this.estimateWorkHours(projectData.total_area, projectData.complexity_factor, projectData.roof_type);
}
// Beregn gennemsnit fra historiske data
const avgHoursPerM2 = similarProjects[0].reduce((sum, project) => {
return sum + (project.total_work_hours / project.total_area);
}, 0) / similarProjects[0].length;
const estimatedHours = Math.max(
projectData.total_area * avgHoursPerM2 * projectData.complexity_factor,
8 // Minimum 8 timer
);
logger.info('AI work hours estimation completed', {
projectData,
similarProjectsCount: similarProjects[0].length,
avgHoursPerM2,
estimatedHours
});
return Math.round(estimatedHours);
} catch (error) {
logger.error('Error in AI work hours estimation, falling back to standard:', error);
return this.estimateWorkHours(projectData.total_area, projectData.complexity_factor, projectData.roof_type);
}
}
// Statistik baseret timeforslag
async getWorkHourStatistics(roofType, areaRange) {
try {
const [areaMin, areaMax] = areaRange || [0, 1000];
const stats = await this.db.pool.execute(`
SELECT
COUNT(*) as project_count,
AVG(pl.total_work_hours) as avg_hours,
MIN(pl.total_work_hours) as min_hours,
MAX(pl.total_work_hours) as max_hours,
AVG(pl.total_work_hours / rg.total_area) as avg_hours_per_m2,
AVG(pl.carpenter_count) as avg_carpenters
FROM roof_geometry rg
JOIN project_labor pl ON rg.project_id = pl.project_id
WHERE rg.roof_type = ? AND rg.total_area BETWEEN ? AND ?
`, [roofType, areaMin, areaMax]);
if (stats[0].length === 0 || stats[0][0].project_count === 0) {
return {
hasData: false,
message: 'Ingen historiske data for denne tagtype og størrelse'
};
}
const data = stats[0][0];
return {
hasData: true,
projectCount: data.project_count,
averageHours: Math.round(data.avg_hours),
hoursRange: {
min: Math.round(data.min_hours),
max: Math.round(data.max_hours)
},
averageHoursPerM2: parseFloat(data.avg_hours_per_m2).toFixed(2),
averageCarpenters: Math.round(data.avg_carpenters)
};
} catch (error) {
logger.error('Error getting work hour statistics:', error);
return {
hasData: false,
error: error.message
};
}
}
// Genberegn estimater hvis geometri ændres
async recalculateEstimates(projectId) {
try {
+6 -6
View File
@@ -1,13 +1,13 @@
{
"files": {
"main.css": "/static/css/main.29a6efb4.css",
"main.js": "/static/js/main.52b9f72f.js",
"main.css": "/static/css/main.27af5b33.css",
"main.js": "/static/js/main.b4962e87.js",
"index.html": "/index.html",
"main.29a6efb4.css.map": "/static/css/main.29a6efb4.css.map",
"main.52b9f72f.js.map": "/static/js/main.52b9f72f.js.map"
"main.27af5b33.css.map": "/static/css/main.27af5b33.css.map",
"main.b4962e87.js.map": "/static/js/main.b4962e87.js.map"
},
"entrypoints": [
"static/css/main.29a6efb4.css",
"static/js/main.52b9f72f.js"
"static/css/main.27af5b33.css",
"static/js/main.b4962e87.js"
]
}
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="da"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="AI-baseret tilbudsberegner specifikt for tømrerfaget og træarbejde"/><title>Tilbudgivern - AI Tilbudsberegner for Tømrere</title><script defer="defer" src="/static/js/main.52b9f72f.js"></script><link href="/static/css/main.29a6efb4.css" rel="stylesheet"></head><body><noscript>Du skal aktivere JavaScript for at køre denne app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="da"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="AI-baseret tilbudsberegner specifikt for tømrerfaget og træarbejde"/><title>Tilbudgivern - AI Tilbudsberegner for Tømrere</title><script defer="defer" src="/static/js/main.b4962e87.js"></script><link href="/static/css/main.27af5b33.css" rel="stylesheet"></head><body><noscript>Du skal aktivere JavaScript for at køre denne app.</noscript><div id="root"></div></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
File diff suppressed because one or more lines are too long
+34
View File
@@ -0,0 +1,34 @@
-- Opret imported_quotes tabel til Ordrestyring import
CREATE TABLE IF NOT EXISTS imported_quotes (
id INT AUTO_INCREMENT PRIMARY KEY,
external_id VARCHAR(100) NOT NULL,
external_case_number VARCHAR(100) NOT NULL,
customer_name VARCHAR(255) NOT NULL,
customer_email VARCHAR(255),
customer_phone VARCHAR(50),
customer_address TEXT,
project_name VARCHAR(255) NOT NULL,
project_description TEXT,
total_amount_excl_vat DECIMAL(15,2) NOT NULL,
total_amount_incl_vat DECIMAL(15,2) NOT NULL,
currency VARCHAR(10) DEFAULT 'DKK',
status ENUM('Sendt tilbud', 'Under behandling', 'Accepteret', 'Afvist', 'Annulleret') NOT NULL,
case_type VARCHAR(100),
valid_until DATE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
source VARCHAR(50) DEFAULT 'ordrestyring',
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Indexes for performance
INDEX idx_external_id (external_id),
INDEX idx_case_number (external_case_number),
INDEX idx_customer_name (customer_name),
INDEX idx_status (status),
INDEX idx_case_type (case_type),
INDEX idx_created_at (created_at),
INDEX idx_total_amount (total_amount_incl_vat),
INDEX idx_imported_at (imported_at),
UNIQUE KEY unique_external_case (external_id, external_case_number)
);
+2
View File
@@ -0,0 +1,2 @@
quote_text
TILBUD FRA Tømrer- og Snedkermester Mikael Holck\n\nTil: Hans Hansen\nProjekt: Kunda a/s\nDato: 17.9.2025\n\nKære Hans Hansen,\n\nTak for din henvendelse vedrørende udskiftning af b7 tag til nyt b7 tag.\n\nMeasure once cut thrice - Dette er kernen i alt, hvad vi laver hos Tømrer- og Snedkermester Mikael Holck.\n\nPROJEKTBESKRIVELSE:\nTagrenovering hos os betyder, at du får mere end bare et nyt tag. Vi giver dig professionel rådgivning i valg af den rigtige tagbelægning, så løsningen passer til både husets udtryk, dit budget og holder i mange år.\n\nARBEJDE DER UDFØRES:\n• Tag type: skraat_tag\n• Samlet areal: 342.24 m²\n• Arbejdstimer: 2.00 timer med 2 tømrere\n• Timepris: 580.00 kr/time\n\nMATERIALER:\n• tagmaterialer: 1 stk. - 285.00 kr\n\nVi indhenter tilbud fra flere leverandører, så du får den bedste balance mellem pris, kvalitet og holdbarhed. Alle materialer er godkendte og lever op til gældende byggestandarder.\n\nPRISSPECIFIKATION:\nArbejdsløn: 0.00 kr\nMaterialer: 285.00 kr\nSubtotal: 285.00 kr\nOverhead (15.00%): 42.75 kr\nFortjeneste (20.00%): 57.00 kr\nMoms (25.00%): 71.25 kr\n\nSAMLET PRIS: 356.25 kr\n\nVORES LØFTE TIL DIG:\n• Præcision og ordentlighed i alt hvad vi laver\n• Tradition og transformation går hånd i hånd\n• Meningsfuldt, ordentligt og bæredygtigt håndværk\n• Vi kommer til tiden og står inde for vores arbejde\n\nVi leverer altid det aftalte til tiden og med kvaliteten i top. Vores erfaring sikrer en flot og holdbar løsning, der øger både komforten og værdien af dit hjem.\n\nGARANTIER OG SERVICE:\n• Gratis tagtjek tilbydes\n• Professionel rådgivning i valg af løsninger\n• Korrekt dokumentation og tryghed\n• Kvalitet der holder i mange år\n\nTILBUDDET ER GYLDIGT I 30 DAGE\n\nMed venlig hilsen\nTømrer- og Snedkermester Mikael Holck\n\nKontakt:\nTelefon: XX XX XX XX\nEmail: [email protected]\nWebsite: www.3byggetilbud.dk\n\nMed fokus på kvalitet, præcision og tryghed leverer vi et resultat, du kan stole på. Vi vurderer altid tagets generelle tilstand og sikrer byggeteknisk korrekte løsninger.
+93
View File
@@ -0,0 +1,93 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/Contents 10 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
>>
endobj
7 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20250917132703+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20250917132703+00'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
8 0 obj
<<
/Count 2 /Kids [ 4 0 R 5 0 R ] /Type /Pages
>>
endobj
9 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1357
>>
stream
GatU3gMZ"A&:Ml+eB:J.ME(%tSN!hk%?k<Y$_DW1=uGQaFF7TQ%3tk$X%`Z%=r[qq+q*W"3d!H*M@d#^7@NC4!H=[Hh3\29O=YsmQ4(eZ`6$7g2rr63Vj8FPLd9l%(5_qpn):N3ZolsHUC^CNK7:nX[1>i[DCH.S\>@\7Fbgc#1^rn:iQt4EO8%O$0V/atR6,t*.%tOP8mta-SdADQ=pkL011MM`cW0t-I=gMpP2G6l^TK9E^[Q@q@S3cLZX"77B`hfd%/).'s%A+7PJJ0j9oYTPHATiNn<oPao+:#1D!f'so1H*fWd5DJ*l,[jj:'[Hh#T*HY='<a>n);eQ5s,XVHsKYO'&)K^"+t9:382"/=;4'j8)CG>j6]mJ>d*@:?].l7%AnGKmdSO/1Um/QEV"`<g6kM[=GC;b0X7Y04;]`9J1H:b7rSA('/*7JsJE]48nm#3hM0DKiX!5B[+FN?%+SCbCd;P'6t4lhI'oqnjU?a;Y76ATN=bFk?Ac0XTdV_i[C+e":6X2I9:>(LTQ6"D=MFBeK'D=4jRFj`*LeLI%l)BSi/_M[&*.21rsO>%el_TIJsjD,kpiq@643*5L/9)q0:]13=A@U(:p<_L^K&OW*nnEIg_ro>c9h?%=A7NjLD+\&D.G&%kO)nUDp3MT"oOVWAO;1153-9QD1Nn<MD#oF-Yco__cY;oTnSJnMk\ea]-G_(fq*-Rs@VcFWY@>)043@g3eO]CKB&C@u.^0>ROYNC8#ERZrXf?O!,Wq2a#(S.[*\hM'+ecLA)5J9gTj=NSD>X8Jning8.;lb8NO6hT&tm0oY*@HlJ(.NaklF=bgTNOae/;Zo#uu&3s^eI('jnW&)D[rYs&;!Ak(3"a%bA*#jLB@CVRBQWhmm@SV@*!STbFiX_];C*?o/XX2D>!7\;kB\ju)3Sr6Rl6&,UJoUf_jUl[<A3BoGZqVO.\hg%%!%,4I#\din%1hWgOmm(P`7)it0qNP2*qd3f-m)u\1b0DXY%5(W"Mk/l;!Z=l/Z5;OY=F?j:jO-Dpn/BZ3#Go/EGi$1SnA]IOrr%<9p6mD%BB/KK:2j7`F,)Q)9`9,*@pr7UT/O?3f9aG[Y7!'$'`W'R2o=a4;S/<$M5K/"dJj3;Q-QF_%2]>2_ba2!Z\7Q[U!(qqC4M:`J[G):ot["9T=(eg6qWJfVL$73"1lte@uJc7r?kP;++XS8qNhSV(6C<Rsg:rR#EC'_dr?9B*YRO']*nEVoeWjn'0E3#@Ma%(3/Q'Leg7_q>quWEk\3E!G@mUY-_'S$fGu>(pkg5pDHQ2')8eJhBN=#g\fM#pRW+EST&Rg>_tPFP^X@Dq5&hm\7kW`peTbGPi:b392'&K~>endstream
endobj
10 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1184
>>
stream
GatU2gN)%,&:O:Slq:u]Nlr'JML):$1TDdBj%-8@cKGEe5RJi/\0*&Xs1Sk5TpgpR;=Wj:$<Z[:mc>E"E:HBNmtV8.I0RH8+@&'1EZfoH5W46O]D2,Pln'I.M%VHb&4`umOsn0HiO7KYlf%t/ONo;dWHJd-etHa4?/DT]J`W^E3poeeG@O/fF9JFh#MC>45MLFTnT?Bp\@](%NuJ4>B@$m@lp,h]aT"LWY&@re#']\'@LU)oFcp,L,a(.7.-sA:NOo'P5X>W'BE=)r5kgo5klPHihfh:q.:gmFge9*Udtj_H4;IC2?'ZariD)')+F4TY:c50QP\.Qd;A<ol5QI&Rem$:).mVN7+-<0ELu3_1e!(0F#@fTmRp>38[OVMJ%5k6SV7,U]G69OE-`ceFVH[Ab\nLYoAEN0=+_)[uD#,u$086Y]HUQV3[2]1*O>d0O(Jq-3iYmc;pd&:';e(!-&iJ^"l%?+d]E,$6r/pNuTY^h%I>$Y/fApB,G6Y`j(ECDRO<qq26Hn(dJcXm9BQ!;Es7RlkCqt'aV(g]_M?'EC*4To/L$P7DX9g_X['"@4Q`;XY>"Q/s)W]>-h8U5ulK]*j1,QD^"^Z')6Hfre&6!+_0m@bM!e8T;YRt=Ba=Cmgb!O]'Z1%@`bW?Z37gtUIq=UB51nDO^#qXO#rUfi+!%e>>AkrPWV6qWgB<D8&$hMG4>FF"Lj3<+an`VQEn$L5_A(cZ2cu"aLZ*e/]%_us1Cgdg]*C<:_F?9)u+Sr%Dk+r*Ub"[SR:Clga@Td^OJA?LFJGg%0D-u&SGS*bLI!TH2I/j=J/J<b'DgM\+g_lV0UaT42s15a,/]A3jr%g^fb$YRj$TH\$oJACSKRFcYrJKm\6"b0!_#kQcbA3ng=:<gOH2sBfqS?R-mE#^WkSMtmMjgu8l\[V*X*#pbMXC=sOq.XO_=s'tEI@X>iZ8NRj-#H1qmSBakei=BP;.@r_'RE-4bbUs](^`"&c'7fBgm^qUY+\n2h1Ku'\nl%L,j'8Y.`b8.Z]A`S$*a#atZ"C5<J&K[;lh?\@9JrdeDVV9Sp/oT-IJrn7:dW'@A=TqX1SsB<Y-nnW8M_K*&ZI897kJ2Ghg?cP&qASMS<X3OTkh!Z_sp21h;m)(l;'j//'CS2Z2^8PO=o]uq'BCXJ@p]e-S]Dts6WZ%\,9!;RiW=o~>endstream
endobj
xref
0 11
0000000000 65535 f
0000000073 00000 n
0000000114 00000 n
0000000221 00000 n
0000000333 00000 n
0000000536 00000 n
0000000740 00000 n
0000000808 00000 n
0000001091 00000 n
0000001156 00000 n
0000002604 00000 n
trailer
<<
/ID
[<0dbd524bebd54924bde658b18d043468><0dbd524bebd54924bde658b18d043468>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 7 0 R
/Root 6 0 R
/Size 11
>>
startxref
3880
%%EOF
@@ -653,6 +653,23 @@
transform: scale(1.02);
}
.generate-pdf-btn {
background: #9b59b6;
color: white;
font-size: 0.85rem;
}
.generate-pdf-btn:hover {
background: #8e44ad;
transform: scale(1.02);
}
.generate-pdf-btn:disabled {
background: #bdc3c7;
cursor: not-allowed;
transform: none;
}
.download-btn {
background: #27ae60;
color: white;
+53 -2
View File
@@ -150,6 +150,34 @@ const CompletedQuotes = ({ apiBaseUrl }) => {
}
};
// Function to generate PDF for quote
const generatePDF = async (quote) => {
try {
setLoading(true);
const response = await fetch(`${apiBaseUrl}/api/quotes/${quote.id}/generate-pdf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const data = await response.json();
if (data.success) {
alert('PDF genereret succesfuldt! Du kan nu downloade det.');
// Refresh quotes to show updated status
loadCompletedQuotes();
} else {
throw new Error(data.error || 'Fejl ved generering af PDF');
}
} catch (error) {
console.error('Error generating PDF:', error);
alert('Fejl ved generering af PDF: ' + error.message);
} finally {
setLoading(false);
}
};
const downloadQuote = (quote) => {
const blob = new Blob([quote.quote_text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
@@ -257,6 +285,12 @@ const CompletedQuotes = ({ apiBaseUrl }) => {
<span className="stat-label">AI genereret:</span>
<span className="stat-value">{quotes.filter(q => q.ai_model !== 'static').length}</span>
</div>
<div className="stat-item">
<span className="stat-label">Samlet arbejdstimer:</span>
<span className="stat-value">
{quotes.reduce((sum, q) => sum + (parseFloat(q.total_work_hours) || 0), 0)}
</span>
</div>
</div>
</div>
@@ -385,7 +419,7 @@ const CompletedQuotes = ({ apiBaseUrl }) => {
👁 Vis
</button>
{quote.quote_format === 'pdf' && (
{quote.quote_format === 'pdf' ? (
<button
onClick={() => downloadPDF(quote)}
className="action-btn pdf-btn"
@@ -393,6 +427,15 @@ const CompletedQuotes = ({ apiBaseUrl }) => {
>
📄 PDF
</button>
) : (
<button
onClick={() => generatePDF(quote)}
className="action-btn generate-pdf-btn"
title="Generér PDF med logo og firmaoplysninger"
disabled={loading}
>
📄 Generér PDF
</button>
)}
<button
@@ -619,13 +662,21 @@ const CompletedQuotes = ({ apiBaseUrl }) => {
</div>
<div className="modal-actions">
{selectedQuote.quote_format === 'pdf' && (
{selectedQuote.quote_format === 'pdf' ? (
<button
onClick={() => downloadPDF(selectedQuote)}
className="action-btn pdf-btn"
>
📄 Download PDF
</button>
) : (
<button
onClick={() => generatePDF(selectedQuote)}
className="action-btn generate-pdf-btn"
disabled={loading}
>
📄 Generér PDF
</button>
)}
<button
onClick={() => downloadQuote(selectedQuote)}
+88 -8
View File
@@ -169,6 +169,75 @@ const LaborInput = ({ apiBaseUrl, project, geometry, existingLabor, onComplete }
return Math.ceil(totalHours / (carpenters * hoursPerDay));
};
// AI-baseret timeestimering
const getAIWorkHourEstimate = async () => {
if (!geometry?.total_area || !geometry?.roof_type) {
setError('Geometri data mangler for AI estimering');
return;
}
setLoading(true);
setError('');
try {
// Først hent statistik for lignende projekter
const statsResponse = await fetch(
`${apiBaseUrl}/api/work-hour-statistics/${geometry.roof_type}?areaMin=${geometry.total_area * 0.7}&areaMax=${geometry.total_area * 1.3}`
);
const statsData = await statsResponse.json();
if (statsData.success && statsData.data.hasData) {
// Brug statistik data til at foreslå timer
const suggestedHours = Math.round(
parseFloat(statsData.data.averageHoursPerM2) * parseFloat(geometry.total_area)
);
setFormData(prev => ({
...prev,
totalWorkHours: Math.max(suggestedHours, 8), // Minimum 8 timer
carpenterCount: statsData.data.averageCarpenters || 2
}));
alert(`🤖 AI Forslag baseret på ${statsData.data.projectCount} lignende projekter:\n` +
`• Anbefalede timer: ${Math.max(suggestedHours, 8)}\n` +
`• Gennemsnit timer/m²: ${statsData.data.averageHoursPerM2}\n` +
`• Anbefalede tømrere: ${statsData.data.averageCarpenters || 2}`);
} else {
// Fallback til API baseret estimering hvis ingen historisk data
const estimateResponse = await fetch(`${apiBaseUrl}/api/estimate-work-hours`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
roof_type: geometry.roof_type,
total_area: parseFloat(geometry.total_area),
complexity_factor: parseFloat(geometry.complexity_factor || 1.0)
})
});
const estimateData = await estimateResponse.json();
if (estimateData.success) {
setFormData(prev => ({
...prev,
totalWorkHours: estimateData.data.estimatedHours
}));
alert(`🤖 AI Estimering (standard beregning):\n` +
`• Anbefalede timer: ${estimateData.data.estimatedHours}\n` +
`• Baseret på: ${geometry.total_area}${geometry.roof_type}`);
} else {
throw new Error('AI estimering fejlede');
}
}
} catch (error) {
console.error('Error getting AI work hour estimate:', error);
setError('Fejl ved AI timeestimering: ' + error.message);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
@@ -310,14 +379,25 @@ const LaborInput = ({ apiBaseUrl, project, geometry, existingLabor, onComplete }
<div className="overview-item">
<label>Total arbejdstimer (estimeret)</label>
<input
type="number"
name="totalWorkHours"
value={formData.totalWorkHours}
onChange={handleInputChange}
min="1"
required
/>
<div className="input-with-ai">
<input
type="number"
name="totalWorkHours"
value={formData.totalWorkHours}
onChange={handleInputChange}
min="1"
required
/>
<button
type="button"
className="ai-estimate-btn"
onClick={getAIWorkHourEstimate}
disabled={!geometry?.total_area || !geometry?.roof_type}
title="Få AI forslag til timer baseret på historiske data"
>
🤖 AI Forslag
</button>
</div>
</div>
<div className="overview-item">
+32 -1
View File
@@ -449,7 +449,38 @@
.overview-item small {
margin-top: 5px;
color: #6c757d;
font-size: 12px;
font-size: 0.875em;
}
.input-with-ai {
display: flex;
gap: 10px;
align-items: center;
}
.input-with-ai input {
flex: 1;
}
.ai-estimate-btn {
background: #8e44ad;
color: white;
border: none;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
font-size: 0.875rem;
white-space: nowrap;
transition: background-color 0.2s ease;
}
.ai-estimate-btn:hover {
background: #7d3c98;
}
.ai-estimate-btn:disabled {
background: #bdc3c7;
cursor: not-allowed;
}
.calculations {
+1
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"axios": "^1.12.2",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"csv-parser": "^3.2.0",
+1
View File
@@ -8,6 +8,7 @@
"dev": "nodemon unified-server.js"
},
"dependencies": {
"axios": "^1.12.2",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"csv-parser": "^3.2.0",
File diff suppressed because one or more lines are too long
+93
View File
@@ -0,0 +1,93 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/Contents 10 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
>>
endobj
7 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20250917132622+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20250917132622+00'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
8 0 obj
<<
/Count 2 /Kids [ 4 0 R 5 0 R ] /Type /Pages
>>
endobj
9 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1339
>>
stream
Gatm;D0+\p&H88.YohF)E@ff6$f=KpjuAO\'n#b4R?!)W;>`mp6&0sIPmJe.iksh<0HNK[I@f8CZmuF7p`duSV#YariVnr>Tb+DX\<[Nm'1DB8?T7&jd\E*EdZB"*I#EYaM#2/E/6^8/Fr5a%DpT#/?$d,!k`u;?N*Ao6;hnJ(8:pooirj8[aj\/:0g*`N&2,$\/sK1V:o,j!7?RsWLft/icSFN)LJLqLP5PIP/&]3>h7m5=gHY1MN\RA2^hhUV5&W^Qf&.+&rL[K]+W/Fch;kL@ju/P$&3fWiDB>N7EpG')cOXK5<Rn.koFRnS1?$%;lQ0`Rqa%SDf@:FgDqUgQD9@040k(;=AY!Q/S?Ol\Nm)V(\=O?%1HjqfLbd.n!jA()EE[uFlcq:g<=0@A_HN0c;'mKj"meXON/X%t!ObRjad"JiR0$a3dKe#qcn^AsYM[&tQfDI^?j2pJc8_2jns_pH*5juZaKsgt(,K0[0*2oRU5uD\]iGc\hn`E./H?;(*lV$A+G&^%!:(Y$?md,\4'YukFcXFeN:XJ<`-oK\HD4`m*B$%uFG9+j4ihK7%Lu!Cp>p?PN?=njF>iq94jBb\ANm9c>_JkYLKm_V7"r`<;D@p`pJU4',1HV&L1CZBe2UB'#cn61#F=.md#D"`:>ia;<181^151^f<hca.<FT03Ao!!VNRD@,koJ(hi?_Sd,uZBo@9bBQDU)0qX9VRkc&eFke`I[Th=/!F*$`sEj''6E>74`3U:&]f"/LJDh'OTAX%Yc=#(kh_3::_DRt,f!)Q/?*O<Em@ZqF1!f+iTgH\akH,8`p,pbmS+30$.[junUhK\f/=o;uFeHllg=cM&84KK<&%lT[,k4[Au(B0bXmaKZkd)idk=EN)B4-l1=-4eTL3.K0A?81her;JV-[*C<#bq1N3PE0ic^#18&.ZMp9),O@nAH3c(+/_c"Rh&@h,J-c]5696tpK6I2S#sOF0BJp_k7C+&@UE7%#nq6-6NpTm<gsj,J2UjdoY_fteLm,&77Lo^+s$&4DfTC[t>_,[(k.)j'MZ9%Df<otc'>-0SI^]kZ86UZ3L@$"E@*Br+ikOFfgUSjK]KGCFX2T5h/&bq^RgG/am.@qa%[RKECt#:B@=%cJi/n[4.<9=GG9QN^'O^PtJ..p^e>N;)j('a)[N!s1LCnEKQSUG]dg@IHR@md>fW]m@du8n.$k7%Fk%:sTV^W+",fg_/rZ4e*G_&<$OETg;3>l'`bpW,YfBB]cj((3.-XV>kER5!jagm(J?>"2YXr3Vg&8q`rlRa_/.L,MIl@+10CqTLq]cm@4UQG*7;HMS2OtcO]GsJn8#JaM[Rf~>endstream
endobj
10 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1246
>>
stream
GatU3>uTcA'Rf.Ggm?<j*U6i-N:U#WKJ^$/d);d8RCjq=G(aFbQ'F'$qd);!l0cL.J]WZfqkA.Zn'sE7!\V2XoDMJeHqrYPCBDG,1]n@M!MiX2V.\]2a=]7=S1*n?!c\o9,`*0341sr=[rm.S/!+pO>c-:/kV`fpG.I5!%G*+*)(a')kdWi/4q/)S_3IA($N1:Z7nA-<Y^bWt&9O#H'DbmHo>iFg)ij-Rqhgsj+eJErpW5MEDcBkDf[koPr?r#DaQj((U?9\:*Y*.A2P>sEg%+]aOT4j>?f"IH.GW/pF3I'0n@aB?qQ[nF-Q7Z/UnY*,ho.$X3<]ki6,VsdFuGf,FQ*,bFZP+r152dNGh@Y2QQ/@T>b)%TA%9tq5DU@K/Q;m'#Gi<AN"l)F1CugJJ^js/G9]@Nm,K.7#l+Im<)Y-fNf;AmW\^JEg0Da^C8bQP&!^_84$1_:9TE08%8Jjp&li7NCWXf>5"!=f>h\gac<bO$YO!*CR]Ajf@WD/D@eTp2<L-mB&J@<!b]9`.2Gp2X*&)fF;4?*%oo_NphLed\)h*<!VQ7g+5AgQaG@#6M2c>q]qZo2lD[?W,)k`V8+8l#6.'Wf2%@+eLK3&0mp#No:o-djV<+AXq(56$$mq5NU"^*:rQ;iKApp"&X_Hjf<nN"K`HTB-A/R4,-_$"5)bSG8(2^\#1blA[p<fan8HDCt_K>bLURBg5)%ld9#`g=$T5L[_f#.J*0O(S-Mha]h?+c+pKJ8_gG=Q?!R_9a2#UTg"d^+`mf^^[r;i8/15n_0uTQ_9fdj^M`CL`M$%OP@Y=CYM@@!q1C!hdH(FC!@"f9GQk+G<Xi`du.5J*X(9^6NhS%!,VPdp;V`8P2aqXZdFginBnX7'F(,ql,TS/36b+DW':60FbP!r*BO1DMPcZU"FM#g0#s_.e8PsB."g@*r';9@iqS'3ZJcNj26;J4H.@P,kIqT\[67Ze)#[[email protected]*_269>c`;.(8^h8j;n:"><&``UU/g,@7_f3)*&[..#j.oT0S:n;h%#,_A[\WG2]G^UESPGW6t_%rYEF+bs`SRJP08^>q;`@^TeXl]Z,YGUa6Arkk'[m`H\85<h4Wit?,4!JrmR9WWX1!`$+)g\)MB<=M`)*a05jJ>XKD]q'b_Nf6=Hap]3T>,'5H!*_fV/"@uOjP.Wn4*,5h3!/If*'JnCeeubSr-(ML.2t@P%E1n5[77jS'/(h4(MLLAZL[JVr(")IZi:#a\ktH~>endstream
endobj
xref
0 11
0000000000 65535 f
0000000073 00000 n
0000000114 00000 n
0000000221 00000 n
0000000333 00000 n
0000000536 00000 n
0000000740 00000 n
0000000808 00000 n
0000001091 00000 n
0000001156 00000 n
0000002586 00000 n
trailer
<<
/ID
[<57b70d3f10df9c66391a696d364a248d><57b70d3f10df9c66391a696d364a248d>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 7 0 R
/Root 6 0 R
/Size 11
>>
startxref
3924
%%EOF
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
const OrdrestyringService = require('./backend/src/services/ordrestyringService');
async function testBearerAuth() {
console.log('🧪 Testing Ordrestyring API with Bearer token authentication...\n');
const service = new OrdrestyringService();
try {
console.log('📡 Testing connection...');
const testResult = await service.testConnection();
if (testResult.success) {
console.log('✅ Connection successful!');
console.log('📊 Response:', testResult);
} else {
console.log('❌ Connection failed');
console.log('🔍 Error details:', testResult);
}
} catch (error) {
console.log('💥 Unexpected error:', error.message);
if (error.response) {
console.log('📜 Response status:', error.response.status);
console.log('📜 Response data:', error.response.data);
}
}
console.log('\n🏁 Test completed');
}
testBearerAuth();
+75
View File
@@ -0,0 +1,75 @@
-- Sample data for testing Ordrestyring integration
-- Run this to create test data for development
INSERT INTO imported_quotes (
external_id, external_case_number, customer_name, customer_email,
customer_phone, customer_address, project_name, project_description,
total_amount_excl_vat, total_amount_incl_vat, currency, status,
case_type, valid_until, created_at, updated_at, source, imported_at
) VALUES
(1001, 'CASE-2025-001', 'Hans Hansen', '[email protected]', '21412544', 'Hansegade 12, 4600 Køge',
'Kunda a/s Tagrenovering', 'Udskiftning af b7 tag til nyt b7 tag på erhvervsbygning',
134662.50, 168328.13, 'DKK', 'Accepteret', 'Tagrenovering', '2025-10-17',
'2025-08-15 10:30:00', '2025-09-15 14:22:00', 'ordrestyring', NOW()),
(1002, 'CASE-2025-002', 'Karen Andersen', '[email protected]', '23456789', 'Vestergade 45, 4000 Roskilde',
'Villa Vinduesudskiftning', 'Udskiftning af 12 vinduer i 1-plans villa',
85000.00, 106250.00, 'DKK', 'Under behandling', 'Vinduer og døre', '2025-11-01',
'2025-08-20 09:15:00', '2025-09-10 16:45:00', 'ordrestyring', NOW()),
(1003, 'CASE-2025-003', 'Peter Nielsen', '[email protected]', '87654321', 'Nygade 8, 4180 Sorø',
'Terrasse og carport', 'Nyt træ terrasse 40m² og carport til 2 biler',
125000.00, 156250.00, 'DKK', 'Sendt tilbud', 'Terrasser og carporte', '2025-10-30',
'2025-09-01 11:20:00', '2025-09-12 13:15:00', 'ordrestyring', NOW()),
(1004, 'CASE-2025-004', 'Lene Larsen', '[email protected]', '45678901', 'Birkevej 23, 4700 Næstved',
'Køkken totalrenovering', 'Komplet køkken med skabsløsninger i massiv eg',
180000.00, 225000.00, 'DKK', 'Accepteret', 'Køkken og bad', '2025-11-15',
'2025-08-25 14:45:00', '2025-09-08 10:30:00', 'ordrestyring', NOW()),
(1005, 'CASE-2025-005', 'Thomas Thomsen', '[email protected]', '56789012', 'Skovvej 67, 4600 Køge',
'Tilbygning til villa', 'Nyt værelse og bad - 25m² tilbygning',
220000.00, 275000.00, 'DKK', 'Under behandling', 'Tilbygninger', '2025-12-01',
'2025-09-05 16:00:00', '2025-09-14 12:20:00', 'ordrestyring', NOW()),
(1006, 'CASE-2025-006', 'Marie Madsen', '[email protected]', '67890123', 'Østergade 34, 4000 Roskilde',
'Badeværelse renovering', 'Komplet badeværelse med fliser og VVS',
95000.00, 118750.00, 'DKK', 'Sendt tilbud', 'Køkken og bad', '2025-10-25',
'2025-08-30 08:30:00', '2025-09-16 15:45:00', 'ordrestyring', NOW()),
(1007, 'CASE-2025-007', 'Jakob Jensen', '[email protected]', '78901234', 'Fredensgade 12, 4180 Sorø',
'Entreprise rækkehus', 'Fuld renovering af rækkehus inkl. tag og facade',
450000.00, 562500.00, 'DKK', 'Afvist', 'Renovering', '2025-09-30',
'2025-07-15 13:20:00', '2025-08-20 09:10:00', 'ordrestyring', NOW()),
(1008, 'CASE-2025-008', 'Susanne Sørensen', '[email protected]', '89012345', 'Hovedgade 89, 4700 Næstved',
'Velux vinduer installation', 'Installation af 4 Velux tagvinduer',
65000.00, 81250.00, 'DKK', 'Accepteret', 'Tagvinduer', '2025-11-10',
'2025-09-10 11:45:00', '2025-09-17 14:30:00', 'ordrestyring', NOW()),
(1009, 'CASE-2025-009', 'Michael Mikkelsen', '[email protected]', '90123456', 'Strandvej 56, 4600 Køge',
'Sommerhus renovering', 'Udskiftning af gulve og maling af vægge',
75000.00, 93750.00, 'DKK', 'Under behandling', 'Renovering', '2025-11-20',
'2025-09-12 10:15:00', '2025-09-16 16:20:00', 'ordrestyring', NOW()),
(1010, 'CASE-2025-010', 'Anne Andersen', '[email protected]', '01234567', 'Parkvej 78, 4000 Roskilde',
'Garage og redskabsskur', 'Nyt dobbelt garage med tilbygget redskabsskur',
165000.00, 206250.00, 'DKK', 'Sendt tilbud', 'Garager og skure', '2025-12-05',
'2025-09-14 12:30:00', '2025-09-17 11:45:00', 'ordrestyring', NOW());
-- Add some older data for trend analysis
INSERT INTO imported_quotes (
external_id, external_case_number, customer_name, customer_email,
customer_phone, customer_address, project_name, project_description,
total_amount_excl_vat, total_amount_incl_vat, currency, status,
case_type, valid_until, created_at, updated_at, source, imported_at
) VALUES
(2001, 'CASE-2024-045', 'Gamle Hansen', '[email protected]', '11111111', 'Gammel Vej 1, 4600 Køge',
'Historisk projekt 2024', 'Afsluttet projekt fra sidste år',
150000.00, 187500.00, 'DKK', 'Accepteret', 'Tagrenovering', '2024-12-31',
'2024-11-15 10:00:00', '2024-12-20 15:30:00', 'ordrestyring', NOW()),
(2002, 'CASE-2024-046', 'Tidigare Andersen', '[email protected]', '22222222', 'Forrige Gade 2, 4000 Roskilde',
'Sidste års vinduer', 'Vinduer fra forrige sæson',
95000.00, 118750.00, 'DKK', 'Accepteret', 'Vinduer og døre', '2024-12-15',
'2024-10-20 14:20:00', '2024-11-25 11:10:00', 'ordrestyring', NOW());
+113
View File
File diff suppressed because one or more lines are too long
+80
View File
@@ -0,0 +1,80 @@
{
"id": 31,
"project_name": "Kunda a/s",
"customer_name": "Hans Hansen",
"customer_email": "[email protected]",
"customer_phone": "21412544",
"customer_address": "hansegade 12",
"project_description": "udskiftning af b7 tag til nyt b7 tag",
"project_status": "calculation_ready",
"created_at": "2025-09-17T07:12:49.000Z",
"updated_at": "2025-09-17T07:50:53.000Z",
"project_type_id": null,
"geometry_count": 1,
"labor_count": 1,
"materials_count": 1,
"quotes_count": 3
}
{
"success": true,
"geometry": {
"id": 46,
"project_id": 31,
"roof_type": "skraat_tag",
"total_area": "342.24",
"roof_pitch": "45.00",
"roof_height": "11.00",
"complexity_factor": "1.30",
"length_main": "11.00",
"width_main": "22.00",
"has_dormers": 0,
"has_chimneys": 0,
"has_skylights": 0,
"access_difficulty": "medium",
"estimated_work_hours": null,
"estimated_carpenters": null,
"notes": "",
"created_at": "2025-09-17T08:18:32.000Z",
"updated_at": "2025-09-17T08:18:32.000Z",
"roof_type_id": null
}
}
{
"success": true,
"labor": [
{
"id": 31,
"project_id": 31,
"carpenter_count": 2,
"estimated_hours_per_carpenter": "1.00",
"total_work_hours": "2.00",
"hourly_rate": "580.00",
"total_labor_cost": "1160.00",
"work_breakdown": "[{\"task\":\"Nedrivning af eksisterende\",\"hours\":2,\"rate\":580,\"cost\":1160,\"notes\":\"Fjernelse af gamle materialer\"}]",
"allocated_hours_breakdown": null,
"special_conditions": null,
"notes": "1 arbejdsopgaver registreret",
"created_at": "2025-09-17T08:18:39.000Z",
"updated_at": "2025-09-17T08:18:39.000Z"
}
]
}
{
"success": true,
"materials": [
{
"id": 34,
"project_id": 31,
"material_name": "B7 tagplader",
"material_category": "tagmaterialer",
"quantity": "1.000",
"unit": "m2",
"unit_price": "285.00",
"total_price": "285.00",
"supplier": "Bygma",
"material_source": "manual",
"notes": "Fra database - tagmaterialer",
"created_at": "2025-09-17T07:13:46.000Z"
}
]
}
+48
View File
@@ -0,0 +1,48 @@
{
"company_info": {
"name": "Tømrer- og Snedkermester Mikael Holck ApS",
"phone": "42468110",
"email": "[email protected]",
"website": "www.mikaelholck.dk",
"cvr": "38178059",
"address": "Hornumvej 7",
"city": "4600 Køge"
},
"project": {
"customer_name": "Hans Hansen",
"project_name": "Kunda a/s",
"project_description": "udskiftning af b7 tag til nyt b7 tag"
},
"geometry": {
"roof_type": "skraat_tag",
"total_area": 342.24
},
"labor": {
"total_work_hours": 205,
"carpenter_count": 2,
"hourly_rate": 580
},
"materials": [
{
"material_name": "Tagplader B7",
"quantity": 350,
"unit": "m²",
"unit_price": 85,
"total_price": 29750
},
{
"material_name": "Taglægter C18",
"quantity": 50,
"unit": "stk",
"unit_price": 45,
"total_price": 2250
}
],
"calculation": {
"total_labor_cost": 118900,
"total_material_cost": 32000,
"subtotal": 150900,
"vat_amount": 37725,
"total_incl_vat": 188625
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"project": {
"id": 31,
"project_name": "Kunda a/s",
"customer_name": "Hans Hansen",
"customer_email": "[email protected]",
"customer_phone": "21412544",
"customer_address": "hansegade 12",
"project_description": "udskiftning af b7 tag til nyt b7 tag"
},
"geometry": {
"roof_type": "skraat_tag",
"total_area": 342.24,
"roof_pitch": 25,
"complexity_factor": 1.2
},
"labor": {
"carpenter_count": 2,
"total_work_hours": 82,
"allocated_hours": 75,
"hourly_rate": 580
},
"materials": [
{
"material_name": "B7 Tagplader",
"quantity": 350,
"unit": "m²",
"unit_price": 125.50,
"total_price": 43925.00,
"supplier": "Bygma",
"category": "tagmaterialer"
},
{
"material_name": "C18 Taglægter",
"quantity": 85,
"unit": "stk",
"unit_price": 45.00,
"total_price": 3825.00,
"supplier": "Bygma",
"category": "tagmaterialer"
},
{
"material_name": "Tagrender 125mm",
"quantity": 24,
"unit": "m",
"unit_price": 185.00,
"total_price": 4440.00,
"supplier": "Bygma",
"category": "tagmaterialer"
}
],
"calculation": {
"total_labor_cost": 47560.00,
"total_material_cost": 52190.00,
"subtotal": 99750.00,
"overhead_percentage": 15,
"overhead_amount": 14962.50,
"profit_percentage": 20,
"profit_amount": 19950.00,
"total_excl_vat": 134662.50,
"vat_percentage": 25,
"vat_amount": 33665.63,
"total_incl_vat": 168328.13
}
}
+467 -346
View File
@@ -81,7 +81,7 @@ const csvUpload = multer({
});
// Initialize backend services
let openaiService, databaseService, webPriceService, dynamicImportService, orderStatusService, quoteTemplateService, pdfGenerationService;
let openaiService, databaseService, webPriceService, dynamicImportService, orderStatusService, quoteTemplateService, pdfGenerationService, ordrestyringService;
const initializeBackend = async () => {
try {
@@ -95,9 +95,12 @@ const initializeBackend = async () => {
const OrderStatusService = require('./backend/src/services/orderStatusService');
const QuoteTemplateService = require('./backend/src/services/quoteTemplateService');
const PdfGenerationService = require('./backend/src/services/pdfGenerationService');
const OrdrestyringService = require('./backend/src/services/ordrestyringService');
dynamicImportService = new DynamicImportService(databaseService);
orderStatusService = new OrderStatusService();
ordrestyringService = new OrdrestyringService();
orderStatusService = new OrderStatusService();
quoteTemplateService = new QuoteTemplateService();
pdfGenerationService = new PdfGenerationService();
@@ -3052,343 +3055,116 @@ app.post('/api/customer-projects/:projectId/quote', async (req, res) => {
});
}
// Get related data for quote generation
const geometry = await databaseService.query(
'SELECT * FROM roof_geometry WHERE project_id = ?',
// Get latest calculation for this project
const [calculation] = await databaseService.query(
'SELECT * FROM project_calculations WHERE project_id = ? ORDER BY created_at DESC LIMIT 1',
[projectId]
);
const labor = await databaseService.query(
'SELECT * FROM project_labor WHERE project_id = ?',
[projectId]
);
const materials = await databaseService.query(
'SELECT * FROM project_materials WHERE project_id = ?',
[projectId]
);
// Calculate totals
const laborTotal = labor.reduce((sum, item) => sum + parseFloat(item.total_cost || 0), 0);
const materialsTotal = materials.reduce((sum, item) => sum + parseFloat(item.total_price || 0), 0);
const subtotal = laborTotal + materialsTotal;
const vat = subtotal * 0.25; // 25% Danish VAT
const total = subtotal + vat;
// Company information configuration - easy to customize
const COMPANY_INFO = {
name: "Tømrer- og Snedkermester Mikael Holck ApS",
phone: "42468110",
cvr: "38178059",
address: "Hornumvej 7",
city: "4600 Køge",
bank: "Nordea",
email: "[email protected]",
account: "2558 6284200630",
website: "www.mikaelholck.dk"
};
// Professional quote generator matching exact PDF format
function generateProfessionalQuote(data) {
const { project, geometry, labor, materials, totals } = data;
const currentDate = new Date().toLocaleDateString('da-DK');
// Generate a quote number (timestamp-based)
const quoteNumber = `${Date.now().toString().slice(-4)}`;
// Format materials list to match PDF style
const formattedMaterials = materials.map(m => {
const description = m.material_name;
const amount = (parseFloat(m.unit_price) * parseFloat(m.quantity)).toFixed(2);
return {
description: description,
amount: `${parseFloat(amount).toLocaleString('da-DK', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`
};
if (!calculation) {
return res.status(400).json({
success: false,
error: 'Ingen prisberegning fundet for dette projekt. Lav en prisberegning først.'
});
// Add labor items in same format
const laborItems = [];
if (labor && labor.length > 0) {
const workDescription = geometry?.roof_type === 'skraat_tag' ? 'Tag m²' : 'Arbejdsløn';
laborItems.push({
description: workDescription,
amount: totals.laborTotal.toLocaleString('da-DK', {minimumFractionDigits: 2, maximumFractionDigits: 2})
});
}
// Combine all items
const allItems = [...laborItems, ...formattedMaterials];
// Create itemized list for the quote
const itemizedList = allItems.map(item =>
`${item.description.padEnd(50)} ${item.amount.padStart(12)}`
).join('\n');
// Format customer address properly
const customerAddress = project.project_address || 'Kunde adresse';
return `TILBUD
Tilbudnr. .......... ${quoteNumber}
Tilbudsdato ........ ${currentDate}
Kundenr. ........... ${project.id}
Side ............... 1/1
Rekvirent: ${project.customer_name}
Reference:
${customerAddress}
${project.project_name}
${project.project_description || 'Renovering og vedligeholdelse'}
Stillads opstilles til opgaven
${geometry?.roof_type === 'skraat_tag' ? 'Eksisterende tagmaterialer demonteres' : ''}
${materials.some(m => m.material_name?.toLowerCase().includes('lægte')) ? 'Lægter demonteres, og nye godkendte T1 taglægter monteres.' : ''}
${materials.some(m => m.material_name?.toLowerCase().includes('tagplade')) ? 'Nye tagplader leveres og monteres efter forskrifter.' : ''}
${materials.some(m => m.material_name?.toLowerCase().includes('rende')) ? 'Tagrender udskiftes til nye inkl. rendejern og nedløb.' : ''}
${geometry ? `Areal: ${geometry.total_area}` : ''}
Varebeskrivelse Beløb
${itemizedList}
Momsifrit beløb: 0,00 - Momspligtigt beløb: ${totals.subtotal.toLocaleString('da-DK', {minimumFractionDigits: 2, maximumFractionDigits: 2})} Subtotal: ${totals.subtotal.toLocaleString('da-DK', {minimumFractionDigits: 2, maximumFractionDigits: 2})}
25,00% Moms: ${totals.vat.toLocaleString('da-DK', {minimumFractionDigits: 2, maximumFractionDigits: 2})}
Total DKK: ${totals.total.toLocaleString('da-DK', {minimumFractionDigits: 2, maximumFractionDigits: 2})}
${COMPANY_INFO.name} Tlf.: ${COMPANY_INFO.phone} cvr: ${COMPANY_INFO.cvr}
${COMPANY_INFO.address} Bank: ${COMPANY_INFO.bank} mail: ${COMPANY_INFO.email}
${COMPANY_INFO.city} Kontonr.: ${COMPANY_INFO.account} ${COMPANY_INFO.website}
--- Side 2 ---
Med mange års erfaring inden for snedker- og tømrerarbejde kan vi udføre en bred vifte af
opgaver Store som små. Vi brænder for at realisere drømme, og har gennem årene hjulpet
privatpersoner, virksomheder og offentlige institutioner i mål med deres drømme.
Som professionelt tømrerfirma værner vi omkring høj kvalitet og god service. Vi er af den
overbevisning at de bedste resultater skabes gennem tæt kunde. Derfor bliver dine ønsker og
behov altid hørt hos os. Ud fra disse, og med vores brede faglighed, finder vi sammen den
bedste løsning til den absolut bedste pris.
Hos ${COMPANY_INFO.name} går vi aldrig kompromis med kvaliteten af
vores arbejde. Hertil er vi medlem af Byg Garanti, hvilket er din garanti for veludført
håndværk. Hos os er du en prioritet, og ikke bare endnu en ordre i kalenderen. Vi kommer
derfor altid til tiden, og rydder selvfølgelig op efter os selv, når opgaven er udført.
Vores tid er vigtig
Vi bruger hver dag mange timer at regne tilbud, og derfor vil vi sætte stor pris at du
vender tilbage med feedback det tilbud vi har givet dig.
Det er vigtigt for os med din feedback, og derfor vender vi også selv tilbage hvis vi ikke har
hørt noget fra dig. Dette gør vi for at yde den bedste service til alle vi er i dialog med.
Husk at det "bedste tilbud" ikke altid er billigste. Det skal udføres korrekt og til den rigtige tid.
Du skal kunne stole din tømrer såvel som snedker Og det kan du hos os.
Anmeld Håndværker
Vi er medlem af Anmeld Håndværker, og der har vi mærket "ELITE HÅNDVÆRKER"
baggrund af vores mange gode anmeldelser.
Anmeld håndværker har du mulighed for at se anmeldelser opgaver vi har udført.
Læs vores anmeldelser her: Anmeld Håndværker
${COMPANY_INFO.name} Tlf.: ${COMPANY_INFO.phone} cvr: ${COMPANY_INFO.cvr}
${COMPANY_INFO.address} Bank: ${COMPANY_INFO.bank} mail: ${COMPANY_INFO.email}
${COMPANY_INFO.city} Kontonr.: ${COMPANY_INFO.account} ${COMPANY_INFO.website}`;
}
// Simple static quote generator (legacy format)
function generateSimpleStaticQuote(data) {
const { project, geometry, labor, materials, totals } = data;
const currentDate = new Date().toLocaleDateString('da-DK');
const materialsList = materials.map(m =>
`${m.material_name}: ${m.quantity} ${m.unit} à ${m.unit_price} kr = ${m.total_price} kr`
).join('\n');
return `TILBUD FRA TILBUDGIVERN
Til: ${project.customer_name}
Projekt: ${project.project_name}
Dato: ${currentDate}
Kære ${project.customer_name},
Vi sender hermed tilbud ${project.project_name}.
PROJEKTDETALJER:
${geometry ? `• Tag type: ${geometry.roof_type}` : ''}
${geometry ? `• Total areal: ${geometry.total_area}` : ''}
${project.project_description ? `• Beskrivelse: ${project.project_description}` : ''}
ARBEJDE:
${labor && labor.length > 0 ? `• Tømrerarbejde: ${labor[0].total_work_hours} timer à ${labor[0].hourly_rate} kr/time` : ''}
${labor && labor.length > 0 ? `• Antal tømrere: ${labor[0].carpenter_count}` : ''}
MATERIALER:
${materialsList}
PRISSAMMENDRAG:
Arbejdsløn: ${totals.laborTotal.toLocaleString('da-DK')} kr
Materialer: ${totals.materialsTotal.toLocaleString('da-DK')} kr
Subtotal: ${totals.subtotal.toLocaleString('da-DK')} kr
Moms (25%): ${totals.vat.toLocaleString('da-DK')} kr
TOTAL: ${totals.total.toLocaleString('da-DK')} kr
Tilbuddet er gældende i 30 dage.
Med venlig hilsen,
Tilbudgivern ApS`;
}
// Generate quote using simplified approach
let quote;
let quoteText; // Declare at function scope
// Use the proper ProjectQuoteGenerationService
let result;
if (quoteType === 'ai' && openaiService) {
try {
// Use OpenAI service directly for AI quotes
quote = await openaiService.generateQuote({
project,
geometry: geometry[0] || null,
labor,
materials,
totals: {
laborTotal,
materialsTotal,
subtotal,
vat,
total
}
});
quoteText = quote.quote || quote.text || quote;
} catch (aiError) {
console.error('AI quote generation failed, falling back to professional format:', aiError);
// Fallback to professional quote if AI fails
quote = generateProfessionalQuote({
project,
geometry: geometry[0] || null,
labor,
materials,
totals: {
laborTotal,
materialsTotal,
subtotal,
vat,
total
}
});
quoteText = quote;
}
} else {
// Generate professional quote matching PDF format
quote = generateProfessionalQuote({
project,
geometry: geometry[0] || null,
labor,
materials,
totals: {
laborTotal,
materialsTotal,
subtotal,
vat,
total
}
});
quoteText = quote;
}
// Ensure quoteText is defined and not empty
if (!quoteText || typeof quoteText !== 'string') {
throw new Error('Quote generation failed - empty or invalid quote text');
}
// Prepare quote data for saving
const quoteData = {
project,
geometry: geometry[0] || null,
labor,
materials,
totals: {
laborTotal,
materialsTotal,
subtotal,
vat,
total
}
};
// Create a basic calculation record first (required for foreign key)
let calculationId;
try {
const calculationResult = await databaseService.query(`
INSERT INTO project_calculations (
project_id, total_area, total_work_hours, carpenter_count,
total_labor_cost, total_material_cost, material_count,
subtotal, overhead_amount, profit_amount, total_excl_vat,
vat_amount, total_incl_vat, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
`, [
projectId,
geometry && geometry[0] ? geometry[0].total_area : 0,
labor && labor.length > 0 ? labor[0].total_work_hours : 0,
labor && labor.length > 0 ? labor[0].carpenter_count : 1,
laborTotal,
materialsTotal,
materials.length,
subtotal,
subtotal * 0.15, // 15% overhead
subtotal * 0.20, // 20% profit
subtotal * 1.35, // subtotal + overhead + profit
vat,
total
]);
calculationId = calculationResult.insertId;
console.log(`✅ Created calculation record with ID: ${calculationId}`);
} catch (calcError) {
console.error('Error creating calculation record:', calcError);
throw new Error(`Failed to create calculation: ${calcError.message}`);
if (quoteType === 'ai' && openaiService && openaiService.openai) {
// Use AI quote generation with proper data
const ProjectQuoteGenerationService = require('./backend/src/services/projectQuoteGenerationService');
const projectQuoteService = new ProjectQuoteGenerationService(databaseService, openaiService);
result = await projectQuoteService.generateProfessionalQuote(projectId, calculation.id, {
quoteStyle: 'professional',
includeBreakdown: true,
language: 'danish'
});
} else {
// Use static quote generation (fallback if no AI available)
const ProjectQuoteGenerationService = require('./backend/src/services/projectQuoteGenerationService');
const projectQuoteService = new ProjectQuoteGenerationService(databaseService, openaiService);
result = await projectQuoteService.generateStaticQuote(projectId, calculation.id);
}
} catch (aiError) {
console.log('AI quote generation failed, falling back to static:', aiError.message);
// Fallback to static quote if AI fails
const ProjectQuoteGenerationService = require('./backend/src/services/projectQuoteGenerationService');
const projectQuoteService = new ProjectQuoteGenerationService(databaseService, null); // No AI service
result = await projectQuoteService.generateStaticQuote(projectId, calculation.id);
}
// Save quote to database (without PDF initially)
try {
const result = await databaseService.query(`
INSERT INTO generated_quotes (
project_id, calculation_id, quote_text, quote_format,
ai_tokens_used, ai_cost, ai_model, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
`, [
projectId,
calculationId, // Use the created calculation ID
quoteText,
'text', // Start as text, can generate PDF later
quoteType === 'ai' ? (quote && quote.tokens ? quote.tokens : 100) : 0,
quoteType === 'ai' ? (quote && quote.cost ? quote.cost : 0.01) : 0.00,
quoteType === 'ai' ? 'gpt-4' : 'static'
]);
console.log(`✅ Created quote record with ID: ${result.insertId}`);
res.json({
success: true,
quoteId: result.insertId,
calculationId: calculationId,
quote: quoteText,
message: 'Tilbud genereret succesfuldt'
});
} catch (quoteError) {
console.error('Error saving quote to database:', quoteError);
throw new Error(`Failed to save quote: ${quoteError.message}`);
}
res.json({
success: true,
data: {
quoteId: result.quoteId,
quoteText: result.quoteText,
tokensUsed: result.tokensUsed || 0,
cost: result.cost || 0,
method: quoteType === 'ai' ? 'ai' : 'static'
}
});
} catch (error) {
console.error('Error generating quote:', error);
res.status(500).json({
success: false,
error: `Fejl ved generering af tilbud: ${error.message}`
error: error.message || 'Fejl ved generering af tilbud'
});
}
});
// Get work hour statistics for AI estimation
app.get('/api/work-hour-statistics/:roofType', async (req, res) => {
try {
const { roofType } = req.params;
const { areaMin, areaMax } = req.query;
const RoofGeometryService = require('./backend/src/services/roofGeometryService');
const roofService = new RoofGeometryService(databaseService);
const areaRange = areaMin && areaMax ? [parseFloat(areaMin), parseFloat(areaMax)] : undefined;
const statistics = await roofService.getWorkHourStatistics(roofType, areaRange);
res.json({
success: true,
data: statistics
});
} catch (error) {
console.error('Error getting work hour statistics:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Estimate work hours using AI/historical data
app.post('/api/estimate-work-hours', async (req, res) => {
try {
const projectData = req.body;
const RoofGeometryService = require('./backend/src/services/roofGeometryService');
const roofService = new RoofGeometryService(databaseService);
const estimatedHours = await roofService.estimateWorkHoursWithAI(projectData);
res.json({
success: true,
data: {
estimatedHours,
method: 'ai_historical'
}
});
} catch (error) {
console.error('Error estimating work hours:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
@@ -3502,7 +3278,89 @@ app.get('/api/quotes/:quoteId/pdf', async (req, res) => {
}
});
// Generate PDF for existing quote
// PDF generation service with Python backend
class PythonPDFService {
constructor() {
this.tempDir = '/tmp/tilbudgivern-pdfs';
this.ensureTempDir();
}
ensureTempDir() {
const fs = require('fs');
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
}
async generatePDF(quoteData, outputPath) {
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
// Create temporary JSON file
const tempJsonPath = path.join(this.tempDir, `quote_${Date.now()}.json`);
try {
// Write quote data to temporary JSON file
fs.writeFileSync(tempJsonPath, JSON.stringify(quoteData, null, 2));
return new Promise((resolve, reject) => {
const pythonProcess = spawn('python3', [
'/home/alex/git/tilbudgivern/backend/pdf_generator.py',
tempJsonPath,
outputPath
]);
let stdout = '';
let stderr = '';
pythonProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
pythonProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
pythonProcess.on('close', (code) => {
// Cleanup temp JSON file
try {
fs.unlinkSync(tempJsonPath);
} catch (e) {
console.warn('Could not delete temp file:', tempJsonPath);
}
if (code === 0) {
console.log('✅ PDF generated successfully:', stdout.trim());
resolve({
success: true,
message: stdout.trim(),
outputPath
});
} else {
console.error('❌ PDF generation failed:', stderr);
reject(new Error(`PDF generation failed: ${stderr || 'Unknown error'}`));
}
});
pythonProcess.on('error', (error) => {
console.error('Failed to start Python PDF generator:', error);
reject(error);
});
});
} catch (error) {
// Cleanup temp file on error
try {
fs.unlinkSync(tempJsonPath);
} catch (e) {}
throw error;
}
}
}
const pythonPDFService = new PythonPDFService();
// Generate PDF for existing quote
app.post('/api/quotes/:quoteId/generate-pdf', async (req, res) => {
try {
const { quoteId } = req.params;
@@ -3546,41 +3404,132 @@ app.post('/api/quotes/:quoteId/generate-pdf', async (req, res) => {
[projectId]
);
// Calculate totals from existing calculation
const totals = {
laborTotal: parseFloat(quote.total_labor_cost || 0),
materialsTotal: parseFloat(quote.total_material_cost || 0),
subtotal: parseFloat(quote.subtotal || 0),
vat: parseFloat(quote.vat_amount || 0),
total: parseFloat(quote.total_incl_vat || 0)
// Prepare data for Python PDF generator
const pdfData = {
project: project,
geometry: geometry[0] || null,
labor: labor[0] || null,
materials: materials,
calculation: {
total_labor_cost: parseFloat(quote.total_labor_cost || 0),
total_material_cost: parseFloat(quote.total_material_cost || 0),
subtotal: parseFloat(quote.subtotal || 0),
vat_amount: parseFloat(quote.vat_amount || 0),
total_incl_vat: parseFloat(quote.total_incl_vat || 0)
}
};
// Generate PDF
const pdfBuffer = await pdfGenerationService.generateQuotePdf({
project,
geometry: geometry[0] || null,
labor,
materials,
totals
// Generate PDF using Python script (fast!)
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
// Write data to temp JSON file
const tempJsonPath = `/tmp/quote_data_${quoteId}_${Date.now()}.json`;
const tempPdfPath = `/tmp/quote_${quoteId}_${Date.now()}.pdf`;
fs.writeFileSync(tempJsonPath, JSON.stringify(pdfData, null, 2));
// Run Python PDF generator
const pythonProcess = spawn('python3', [
path.join(__dirname, 'backend/pdf_generator.py'),
tempJsonPath,
tempPdfPath
]);
let pythonOutput = '';
let pythonError = '';
pythonProcess.stdout.on('data', (data) => {
pythonOutput += data.toString();
});
// Update database with new PDF
await databaseService.query(
'UPDATE generated_quotes SET pdf_data = ?, quote_format = ? WHERE id = ?',
[pdfBuffer, 'pdf', quoteId]
);
pythonProcess.stderr.on('data', (data) => {
pythonError += data.toString();
});
pythonProcess.on('close', async (code) => {
try {
// Clean up temp JSON file
fs.unlinkSync(tempJsonPath);
if (code === 0 && fs.existsSync(tempPdfPath)) {
// Read PDF file and store in database
const pdfBuffer = fs.readFileSync(tempPdfPath);
// Update database with PDF
await databaseService.query(
'UPDATE generated_quotes SET pdf_data = ?, quote_format = ? WHERE id = ?',
[pdfBuffer, 'pdf', quoteId]
);
// Clean up temp PDF file
fs.unlinkSync(tempPdfPath);
console.log(`✅ PDF generated successfully for quote ${quoteId}`);
console.log(`📊 Python output: ${pythonOutput}`);
} else {
console.error(`❌ PDF generation failed for quote ${quoteId}, exit code: ${code}`);
console.error(`📊 Python output: ${pythonOutput}`);
console.error(`📊 Python error: ${pythonError}`);
console.error(`📊 Temp PDF exists: ${fs.existsSync(tempPdfPath)}`);
}
} catch (error) {
console.error('Error in PDF generation cleanup:', error);
}
});
// Send immediate success response (Python is fast enough)
res.json({
success: true,
message: 'PDF genereret og gemt succesfuldt',
message: 'PDF genereret succesfuldt!',
quoteId: quoteId
});
} catch (error) {
console.error('Error generating PDF for existing quote:', error);
console.error('Error generating PDF:', error);
res.status(500).json({
success: false,
error: `Fejl ved generering af PDF: ${error.message}`
error: 'Fejl ved PDF generering: ' + error.message
});
}
});
// Check PDF generation status for a quote
app.get('/api/quotes/:quoteId/pdf-status', async (req, res) => {
try {
const { quoteId } = req.params;
const [quote] = await databaseService.query(
'SELECT quote_format, pdf_data, notes FROM generated_quotes WHERE id = ?',
[quoteId]
);
if (!quote) {
return res.status(404).json({
success: false,
error: 'Tilbud ikke fundet'
});
}
const hasPDF = quote.quote_format === 'pdf' && quote.pdf_data !== null;
const hasError = quote.notes && quote.notes.includes('PDF generation failed');
res.json({
success: true,
data: {
hasPDF: hasPDF,
status: hasError ? 'failed' : (hasPDF ? 'completed' : 'pending'),
format: quote.quote_format,
error: hasError ? quote.notes : null
}
});
} catch (error) {
console.error('Error checking PDF status:', error);
res.status(500).json({
success: false,
error: 'Fejl ved tjek af PDF status'
});
}
});
@@ -3940,6 +3889,178 @@ app.get('/api/customer-projects/:projectId/quotes', async (req, res) => {
}
});
// Ordrestyring Integration Endpoints
// Test Ordrestyring API connection
app.get('/api/ordrestyring/test', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const testResult = await ordrestyringService.testConnection();
res.json({
success: true,
message: 'Ordrestyring API forbindelse succesfuld',
data: testResult
});
} catch (error) {
console.error('Ordrestyring test error:', error);
res.status(500).json({
success: false,
error: 'Fejl ved test af Ordrestyring API: ' + error.message
});
}
});
// Import all cases from Ordrestyring
app.post('/api/ordrestyring/import', async (req, res) => {
try {
if (!ordrestyringService || !databaseService) {
return res.status(503).json({
success: false,
error: 'Services ikke initialiseret'
});
}
console.log('Starting Ordrestyring import...');
const importResult = await ordrestyringService.importCasesToDatabase(databaseService);
res.json({
success: true,
message: 'Import fuldført succesfuldt',
data: importResult
});
} catch (error) {
console.error('Import error:', error);
res.status(500).json({
success: false,
error: 'Fejl ved import af data: ' + error.message
});
}
});
// Get import statistics
app.get('/api/ordrestyring/statistics', async (req, res) => {
try {
if (!ordrestyringService || !databaseService) {
return res.status(503).json({
success: false,
error: 'Services ikke initialiseret'
});
}
const statistics = await ordrestyringService.generateStatistics(databaseService);
res.json({
success: true,
data: statistics
});
} catch (error) {
console.error('Statistics error:', error);
res.status(500).json({
success: false,
error: 'Fejl ved generering af statistikker: ' + error.message
});
}
});
// Get imported quotes with pagination
app.get('/api/ordrestyring/quotes', async (req, res) => {
try {
if (!databaseService) {
return res.status(503).json({
success: false,
error: 'Database service ikke initialiseret'
});
}
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const offset = (page - 1) * limit;
const search = req.query.search || '';
// Build where clause for search
let whereClause = "WHERE source = 'ordrestyring'";
const params = [];
if (search) {
whereClause += " AND (customer_name LIKE ? OR project_name LIKE ? OR external_case_number LIKE ?)";
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
// Get total count
const [countResult] = await databaseService.query(
`SELECT COUNT(*) as total FROM imported_quotes ${whereClause}`,
params
);
// Get quotes
const quotes = await databaseService.query(
`SELECT * FROM imported_quotes ${whereClause}
ORDER BY created_at DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset]
);
res.json({
success: true,
data: {
quotes: quotes,
pagination: {
current_page: page,
per_page: limit,
total: countResult.total,
total_pages: Math.ceil(countResult.total / limit)
}
}
});
} catch (error) {
console.error('Error getting imported quotes:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af importerede tilbud'
});
}
});
// Get detailed case from Ordrestyring by ID
app.get('/api/ordrestyring/case/:caseId', async (req, res) => {
try {
if (!ordrestyringService) {
return res.status(503).json({
success: false,
error: 'Ordrestyring service ikke initialiseret'
});
}
const { caseId } = req.params;
const caseData = await ordrestyringService.getCaseById(caseId);
if (!caseData) {
return res.status(404).json({
success: false,
error: 'Case ikke fundet'
});
}
res.json({
success: true,
data: caseData
});
} catch (error) {
console.error('Error getting case details:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af case detaljer: ' + error.message
});
}
});
// Start server
const startServer = async () => {
const backendReady = await initializeBackend();
+93
View File
@@ -0,0 +1,93 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 9 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/Contents 10 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
>>
endobj
7 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20250917114535+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20250917114535+00'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
8 0 obj
<<
/Count 2 /Kids [ 4 0 R 5 0 R ] /Type /Pages
>>
endobj
9 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1339
>>
stream
Gatm;D0+\p&H88.YohF)E@ff6$f=KpjuAO\'n#b4R?!)W;>`mp6&0sIPmJe.iksh<0HNK[I@f8CZmuF7p`duSV#YariVnr>Tb+DX\<[Nm'1DB8?T7&jd\E*EdZB"*I#EYaM#2/E/6^8/Fr5a%DpT#/?$d,!k`u;?N*Ao6;hnJ(8:pooirj8[aj\/:0g*`N&2,$\/sK1V:o,j!7?RsWLft/icSFN)LJLqLP5PIP/&]3>h7m5=gHY1MN\RA2^hhUV5&W^Qf&.+&rL[K]+W/Fch;kL@ju/P$&3fWiDB>N7EpG')cOXK5<Rn.koFRnS1?$%;lQ0`Rqa%SDf@:FgDqUgQD9@040k(;=AY!Q/S?Ol\Nm)V(\=O?%1HjqfLbd.n!jA()EE[uFlcq:g<=0@A_HN0c;'mKj"meXON/X%t!ObRjad"JiR0$a3dKe#qcn^AsYM[&tQfDI^?j2pJc8_2jns_pH*5juZaKsgt(,K0[0*2oRU5uD\]iGc\hn`E./H?;(*lV$A+G&^%!:(Y$?md,\4'YukFcXFeN:XJ<`-oK\HD4`m*B$%uFG9+j4ihK7%Lu!Cp>p?PN?=njF>iq94jBb\ANm9c>_JkYLKm_V7"r`<;D@p`pJU4',1HV&L1CZBe2UB'#cn61#F=.md#D"`:>ia;<181^151^f<hca.<FT03Ao!!VNRD@,koJ(hi?_Sd,uZBo@9bBQDU)0qX9VRkc&eFke`I[Th=/!F*$`sEj''6E>74`3U:&]f"/LJDh'OTAX%Yc=#(kh_3::_DRt,f!)Q/?*O<Em@ZqF1!f+iTgH\akH,8`p,pbmS+30$.[junUhK\f/=o;uFeHllg=cM&84KK<&%lT[,k4[Au(B0bXmaKZkd)idk=EN)B4-l1=-4eTL3.K0A?81her;JV-[*C<#bq1N3PE0ic^#18&.ZMp9),O@nAH3c(+/_c"Rh&@h,J-c]5696tpK6I2S#sOF0BJp_k7C+&@UE7%#nq6-6NpTm<gsj,J2UjdoY_fteLm,&77Lo^+s$&4DfTC[t>_,[(k.)j'MZ9%Df<otc'>-0SI^]kZ86UZ3L@$"E@*Br+ikOFfgUSjK]KGCFX2T5h/&bq^RgG/am.@qa%[RKECt#:B@=%cJi/n[4.<9=GG9QN^'O^PtJ..p^e>N;)j('a)[N!s1LCnEKQSUG]dg@IHR@md>fW]m@du8n.$k7%Fk%:sTV^W+",fg_/rZ4e*G_&<$OETg;3>l'`bpW,YfBB]cj((3.-XV>kER5!jagm(J?>"2YXr3Vg&8q`rlRa_/.L,MIl@+10CqTLq]cm@4UQG*7;HMS2OtcO]GsJn8#JaM[Rf~>endstream
endobj
10 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1246
>>
stream
GatU3gN)%,&:O:Slq:u]N6;dFML)99bGln0m7OIDcKGEe5RJi/\0*&Xs1Sk5V4*?V;>03?O^-odmOldf'6*J1RdZ"Ei,]6tXpGXMdL8\)"HZAePb.hp7WsoVk<t/n&;[+6+\@pVhr/F]\ZPHZ>_08?i\KV=6+:"DZceosDRa"mb$]3a6H=4u=aV&1=Fs2$/en-aUlUUd[((VFlO\6$]Fk$EC%KCr?5npoT%h+08SCor_>E;Qn2IAK/T1I<^)oe;'7'Oq8_![D\;(1ATFL*/7^g5t+QS$jk*J$U;$qNRaW-KV%=E*POFF8bfSKf@-`JM1mrraY>#%B$C!^h1^R^AH0O=IfH_$0>SP8!W1*6-sj<_hX&!popiY_+U_@N@G;%$BTb!Ba].jIKsiX>rdBje?bmbJ/1'O/*Q+%"i#-e4FfZQis:Xm/UEOu?B^U`*uMfgB?(m=$)='IH>Nnd;96?AU/8ZaF?+@\4fR.5#K61hSJPYuc+-2U0dH#I-79#I/UVKdo`u_$Gl>,\B'eAt(2G0b0R7fI5![TR+^%)=Kj@_AtkV*RiR"Tc^`ESNAJaa;ZR$NdZSNZ*si<`XU;=i!KGR.8"El@RHtGgu&nJ7QibNh`M7t):_h&W't-]_Y]a#9gMfPZ6e$D(Ok`%[W!&_4n1PM:jY4CUFf`,q8!2CMTciW>;mnuN+RU"#cgI%3^$Aqp-E0FlV(G8fE[on%-,OajWE_t1kXO;bX]#:ad5j8=U65G.,ZVX2:f;#C+qJ7#:XJ#Q7n3K4`CMl4h,]>A\>Hd`$OWE7_.1(11CJr2rqD$qqC`UUqf'`qH"M^<Ef"2!)pkJ`19]ulL\OcZ3LChH5D.g-32f7[_;2RTVVATctr@7%"*.K&qrVaHhoI2"&AGc1oelO?!X.p@Y^!DSt]qq0EL'bX3:%3#;r#>#g-O621NsWS2c"kJo#[c;@P-"CL6[Aj10(295RIj-q:u*hO%B7'jR)Ko[7\'$B"6K]93[f4MaZe?#`-rb_R)&"&@=.Y6t090KnL7$iHb:*Jr^;B;ulep[@L]#9HWsDo3NZ<6-5O?)E)BQS3X0X]V]n_trHJ4eBg6Yi#5]`a(TXkN?L@A6`U2+<YZ:NJWqh?,2:4CYM$?Z)Zd)b!Ts=(\%(GYd[Q8erasn6^^5m>t^Em$Xd`uBSH&2P'jm/@qUhH)FpI6K;1qUdlPBlNrO'3/nJPU[@oRZWRe#WH/_7=l2n)s;-*[KT)WW8E:tqfO\s4~>endstream
endobj
xref
0 11
0000000000 65535 f
0000000073 00000 n
0000000114 00000 n
0000000221 00000 n
0000000333 00000 n
0000000536 00000 n
0000000740 00000 n
0000000808 00000 n
0000001091 00000 n
0000001156 00000 n
0000002586 00000 n
trailer
<<
/ID
[<360299b949a2a7c0b219585cf228e12d><360299b949a2a7c0b219585cf228e12d>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 7 0 R
/Root 6 0 R
/Size 11
>>
startxref
3924
%%EOF