feat: persist carpenter workflow status and price freshness

This commit is contained in:
alexpolo1
2026-03-29 12:27:06 +00:00
parent 3b7772c697
commit 10d94c7ff1
20 changed files with 3854 additions and 1659 deletions
+21 -1
View File
@@ -6,16 +6,36 @@ CREATE TABLE IF NOT EXISTS customer_projects (
id INT AUTO_INCREMENT PRIMARY KEY,
project_name VARCHAR(255) NOT NULL,
customer_name VARCHAR(255) NOT NULL,
customer_number VARCHAR(50),
customer_email VARCHAR(255),
customer_phone VARCHAR(50),
customer_address TEXT,
project_description TEXT,
project_status ENUM('draft', 'geometry_pending', 'labor_pending', 'materials_pending', 'calculation_ready', 'quote_generated', 'sent', 'accepted', 'rejected') DEFAULT 'draft',
selected_packages JSON,
suggestion_snapshot JSON,
input_normalization_log JSON,
project_status ENUM(
'draft',
'geometry_complete',
'smart_package_complete',
'review_pending',
'ready_for_ordrestyring',
'sent_to_ordrestyring',
'accepted',
'rejected',
'geometry_pending',
'labor_pending',
'materials_pending',
'calculation_ready',
'quote_generated',
'sent'
) DEFAULT 'draft',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (project_status),
INDEX idx_customer (customer_name),
INDEX idx_customer_number (customer_number),
INDEX idx_created (created_at)
);
@@ -0,0 +1,81 @@
const MaterialPriceStatusService = require('../services/materialPriceStatusService');
describe('MaterialPriceStatusService', () => {
test('selects the newest available source as primary source', async () => {
const db = {
query: jest.fn(async (sql) => {
if (sql.includes('FROM bygma_import_log')) {
return [{
started_at: '2026-03-25T08:00:00.000Z',
completed_at: '2026-03-25T08:10:00.000Z',
status: 'completed',
imported_by: 'bygma-user',
filename: 'bygma.csv',
updated_prices: 120,
successful_rows: 900
}];
}
if (sql.includes('FROM bygma_price_history')) {
return [{ latest_price_at: '2026-03-25T08:00:00.000Z' }];
}
if (sql.includes('FROM import_logs')) {
return [{
started_at: '2026-03-20T08:00:00.000Z',
completed_at: '2026-03-20T08:10:00.000Z',
status: 'completed',
imported_by: 'stark-user',
filename: 'stark.csv',
updated_items: 45,
total_items: 300
}];
}
if (sql.includes('FROM stark_materials_cache')) {
return [{ latest_price_at: '2026-03-20T08:00:00.000Z' }];
}
throw new Error(`Unexpected SQL: ${sql}`);
})
};
const service = new MaterialPriceStatusService(db);
const result = await service.getStatus();
expect(result.primarySource).toMatchObject({
source: 'Bygma',
importStatus: 'completed',
filename: 'bygma.csv'
});
expect(result.sources).toHaveLength(2);
expect(result.sources[0].statusLabel || result.sources[1].statusLabel).toBeTruthy();
});
test('returns missing status when import tables are unavailable', async () => {
const db = {
query: jest.fn(async () => {
throw new Error("Table 'tilbudgivern.bygma_import_log' doesn't exist");
})
};
const service = new MaterialPriceStatusService(db);
const result = await service.getStatus();
expect(result.primarySource).toBeNull();
expect(result.sources).toEqual(
expect.arrayContaining([
expect.objectContaining({
source: 'Bygma',
available: false,
freshness: 'missing'
}),
expect.objectContaining({
source: 'Stark',
available: false,
freshness: 'missing'
})
])
);
});
});
@@ -0,0 +1,249 @@
const OrderSuggestionService = require('../services/orderSuggestionService');
describe('OrderSuggestionService', () => {
test('builds a prefilled recommendation from local history and ordrestyring', async () => {
const execute = jest.fn(async (sql) => {
if (sql.includes('FROM customer_projects cp') && sql.includes('WHERE cp.id = ?')) {
return [[{
id: 7,
project_name: 'Nyt tag B7',
project_description: 'Udskiftning af B7 eternittag på villa',
customer_name: 'Mikael Holck',
customer_number: 'C100',
roof_type: 'skraat_tag',
total_area: 132
}]];
}
if (sql.includes('SELECT\n cp.id AS project_id')) {
return [[{
project_id: 5,
project_name: 'Historisk B7 projekt',
project_description: 'Komplet udskiftning af B7 tag med undertag',
customer_name: 'Mikael Holck',
customer_number: 'C100',
project_status: 'accepted',
selected_packages: JSON.stringify({ id: 'b7_tag_udskiftning', name: 'B7 Tag Udskiftning - Komplet' }),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
roof_type: 'skraat_tag',
total_area: 120,
total_work_hours: 42,
hourly_rate: 580,
work_breakdown: JSON.stringify([
{ task: 'Tagplader montering', description: 'Montering af B7 plader', hours: 18 }
]),
labor_notes: 'Historisk reference'
}]];
}
if (sql.includes('FROM project_materials')) {
return [[
{
project_id: 5,
material_name: 'Eternit B7 tagplader',
material_category: 'Tagmateriale',
quantity: 1.1,
unit: 'm²',
unit_price: 245
}
]];
}
if (sql.includes('FROM generated_quotes')) {
return [[
{
project_id: 5,
quote_text: 'Historisk kundevendt tilbud på B7-tag',
created_at: new Date().toISOString()
}
]];
}
if (sql.includes('FROM ordrestyring_local.cases')) {
return [[
{
case_number: '5001',
customer_number: 'C100',
customer_name: 'Mikael Holck',
description: 'Udskiftning af B7 tag på villa',
remarks: 'Komplet tagarbejde med stillads',
creation_date: new Date().toISOString(),
status: 'completed',
total_hours: 42,
hour_entries: 8
},
{
case_number: '5002',
customer_number: 'C200',
customer_name: 'Anden kunde',
description: 'Tagreparation omkring kip',
remarks: '',
creation_date: new Date().toISOString(),
status: 'completed',
total_hours: 12,
hour_entries: 2
}
]];
}
throw new Error(`Unexpected SQL: ${sql}`);
});
const service = new OrderSuggestionService({
pool: { execute }
});
const result = await service.getOrderSuggestions(7, { limit: 5 });
expect(result.customerNumber).toBe('C100');
expect(result.matches.length).toBeGreaterThanOrEqual(2);
expect(result.matches[0]).toMatchObject({
sourceType: 'local_project',
projectName: 'Historisk B7 projekt',
sameCustomer: true
});
expect(result.recommendation.confidence).toBe('high');
expect(result.recommendation.suggestedDescription).toContain('Udskiftning af B7 eternittag');
expect(result.recommendation.recommendedPackage).toMatchObject({
packageId: 'b7_tag_udskiftning'
});
expect(result.materialsDraft[0]).toMatchObject({
name: 'Eternit B7 tagplader',
unit: 'm²'
});
expect(result.laborDraft[0]).toMatchObject({
name: 'Tagplader montering'
});
});
test('returns a low-confidence fallback when no cases match', async () => {
const execute = jest.fn(async (sql) => {
if (sql.includes('FROM customer_projects cp') && sql.includes('WHERE cp.id = ?')) {
return [[{
id: 11,
project_name: 'Ny terrasse',
project_description: '',
customer_name: 'Jens Hansen',
customer_number: null,
roof_type: null,
total_area: null
}]];
}
if (sql.includes('SELECT\n cp.id AS project_id')) {
return [[]];
}
if (sql.includes('FROM ordrestyring_local.cases')) {
return [[]];
}
throw new Error(`Unexpected SQL: ${sql}`);
});
const service = new OrderSuggestionService({
pool: { execute }
});
const result = await service.getOrderSuggestions(11, { limit: 5 });
expect(result.matches).toEqual([]);
expect(result.recommendation.confidence).toBe('low');
expect(result.recommendation.averageHistoricalHours).toBe(0);
expect(result.recommendation.summary).toContain('Ingen lokale historiske sager');
expect(result.materialsDraft).toEqual([]);
expect(result.laborDraft).toEqual([]);
});
test('infers tegl smart package from ordrestyring history when no local package is stored', async () => {
const execute = jest.fn(async (sql) => {
if (sql.includes('FROM customer_projects cp') && sql.includes('WHERE cp.id = ?')) {
return [[{
id: 21,
project_name: 'Omlægning af tegltag',
project_description: 'Renovering af tegltag på villa med ny rygning',
customer_name: 'Erik Andersen',
customer_number: 'C310',
roof_type: 'skraat_tag',
total_area: 184
}]];
}
if (sql.includes('SELECT\n cp.id AS project_id')) {
return [[{
project_id: 18,
project_name: 'Historisk tegltag',
project_description: 'Omlægning af tegltag med undertag',
customer_name: 'Poul Eriksen',
customer_number: 'C311',
project_status: 'accepted',
selected_packages: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
roof_type: 'skraat_tag',
total_area: 176,
total_work_hours: 108,
hourly_rate: 580,
work_breakdown: JSON.stringify([
{ task: 'Montering af tegltag', description: 'Nye tegl og afslutninger', hours: 46 }
]),
labor_notes: 'Historisk tegl reference'
}]];
}
if (sql.includes('FROM project_materials')) {
return [[
{
project_id: 18,
material_name: 'Tegl vingetagsten',
material_category: 'Tagmateriale',
quantity: 185,
unit: 'm²',
unit_price: 145
}
]];
}
if (sql.includes('FROM generated_quotes')) {
return [[
{
project_id: 18,
quote_text: 'Historisk kundevendt tilbud på tegltag',
created_at: new Date().toISOString()
}
]];
}
if (sql.includes('FROM ordrestyring_local.cases')) {
return [[
{
case_number: '5305',
customer_number: 'C310',
customer_name: 'Erik Andersen',
description: 'Omlægning af tegltag med undertag',
remarks: 'Klassisk villa med ny rygning',
creation_date: new Date().toISOString(),
status: 'completed',
total_hours: 118,
hour_entries: 14
}
]];
}
throw new Error(`Unexpected SQL: ${sql}`);
});
const service = new OrderSuggestionService({
pool: { execute }
});
const result = await service.getOrderSuggestions(21, { limit: 5 });
expect(result.recommendation.recommendedPackage).toMatchObject({
packageId: 'tegl_tag_renovering',
name: 'Tegltag Renovering'
});
expect(result.matches[0].description).toContain('tegltag');
});
});
+74
View File
@@ -0,0 +1,74 @@
const PROJECT_STATUS = {
DRAFT: 'draft',
GEOMETRY_COMPLETE: 'geometry_complete',
SMART_PACKAGE_COMPLETE: 'smart_package_complete',
REVIEW_PENDING: 'review_pending',
READY_FOR_ORDRESTYRING: 'ready_for_ordrestyring',
SENT_TO_ORDRESTYRING: 'sent_to_ordrestyring',
ACCEPTED: 'accepted',
REJECTED: 'rejected',
// Legacy values kept for compatibility with older rows and routes.
GEOMETRY_PENDING: 'geometry_pending',
LABOR_PENDING: 'labor_pending',
MATERIALS_PENDING: 'materials_pending',
CALCULATION_READY: 'calculation_ready',
QUOTE_GENERATED: 'quote_generated',
SENT: 'sent'
};
const ACTIVE_WORKFLOW_PROJECT_STATUSES = [
PROJECT_STATUS.DRAFT,
PROJECT_STATUS.GEOMETRY_COMPLETE,
PROJECT_STATUS.SMART_PACKAGE_COMPLETE,
PROJECT_STATUS.REVIEW_PENDING,
PROJECT_STATUS.READY_FOR_ORDRESTYRING,
PROJECT_STATUS.SENT_TO_ORDRESTYRING,
PROJECT_STATUS.ACCEPTED,
PROJECT_STATUS.REJECTED
];
const LEGACY_PROJECT_STATUSES = [
PROJECT_STATUS.GEOMETRY_PENDING,
PROJECT_STATUS.LABOR_PENDING,
PROJECT_STATUS.MATERIALS_PENDING,
PROJECT_STATUS.CALCULATION_READY,
PROJECT_STATUS.QUOTE_GENERATED,
PROJECT_STATUS.SENT
];
const ALL_PROJECT_STATUSES = [
...ACTIVE_WORKFLOW_PROJECT_STATUSES,
...LEGACY_PROJECT_STATUSES
];
const QUOTE_READY_PROJECT_STATUSES = [
PROJECT_STATUS.REVIEW_PENDING,
PROJECT_STATUS.READY_FOR_ORDRESTYRING,
PROJECT_STATUS.SENT_TO_ORDRESTYRING,
PROJECT_STATUS.QUOTE_GENERATED,
PROJECT_STATUS.SENT,
PROJECT_STATUS.ACCEPTED
];
const OFFER_HISTORY_PROJECT_STATUSES = [
PROJECT_STATUS.READY_FOR_ORDRESTYRING,
PROJECT_STATUS.SENT_TO_ORDRESTYRING,
PROJECT_STATUS.QUOTE_GENERATED,
PROJECT_STATUS.SENT,
PROJECT_STATUS.ACCEPTED
];
function buildProjectStatusEnumSql() {
return `ENUM(${ALL_PROJECT_STATUSES.map((status) => `'${status}'`).join(', ')})`;
}
module.exports = {
PROJECT_STATUS,
ACTIVE_WORKFLOW_PROJECT_STATUSES,
LEGACY_PROJECT_STATUSES,
ALL_PROJECT_STATUSES,
QUOTE_READY_PROJECT_STATUSES,
OFFER_HISTORY_PROJECT_STATUSES,
buildProjectStatusEnumSql
};
+158 -12
View File
@@ -7,10 +7,16 @@ const ProjectMaterialService = require('../services/projectMaterialService');
const ProjectCalculationService = require('../services/projectCalculationService');
const ProjectQuoteGenerationService = require('../services/projectQuoteGenerationService');
const ProjectExperienceService = require('../services/projectExperienceService');
const OrderSuggestionService = require('../services/orderSuggestionService');
const MaterialPriceStatusService = require('../services/materialPriceStatusService');
const bygmaScraperService = require('../services/bygmaScraperService');
const databaseService = require('../services/databaseService');
const openaiService = require('../services/openaiService');
const logger = require('../utils/logger');
const {
PROJECT_STATUS,
ALL_PROJECT_STATUSES
} = require('../constants/projectStatuses');
// Initialize services
const customerProjectService = new CustomerProjectService(databaseService);
@@ -20,6 +26,8 @@ const projectMaterialService = new ProjectMaterialService(databaseService);
const projectCalculationService = new ProjectCalculationService(databaseService);
const projectQuoteGenerationService = new ProjectQuoteGenerationService(databaseService, openaiService);
const projectExperienceService = new ProjectExperienceService(databaseService);
const orderSuggestionService = new OrderSuggestionService(databaseService);
const materialPriceStatusService = new MaterialPriceStatusService(databaseService);
// ==================== KUNDE PROJEKT ENDPOINTS ====================
@@ -118,18 +126,98 @@ router.get('/projects/:id', async (req, res) => {
}
});
// Hent Ordrestyring-baserede forslag til nyt tilbud
router.get('/projects/:id/order-suggestions', async (req, res) => {
try {
const projectId = parseInt(req.params.id, 10);
if (!projectId) {
return res.status(400).json({
success: false,
error: 'Ugyldigt projekt ID'
});
}
const result = await orderSuggestionService.getOrderSuggestions(projectId, {
limit: req.query.limit
});
res.json({
success: true,
...result
});
} catch (error) {
const status = error.status || 500;
logger.error('Error getting order suggestions:', error);
res.status(status).json({
success: false,
error: status === 404 ? error.message : 'Fejl ved hentning af Ordrestyring forslag'
});
}
});
router.post('/projects/:id/apply-suggestion', async (req, res) => {
try {
const projectId = parseInt(req.params.id, 10);
const {
suggestionSnapshot = {},
recommendedPackage = null,
projectDescription = null,
inputNormalizationLog = null
} = req.body || {};
if (!projectId) {
return res.status(400).json({
success: false,
error: 'Ugyldigt projekt ID'
});
}
const selectedPackages = recommendedPackage
? {
id: recommendedPackage.packageId || recommendedPackage.id || null,
name: recommendedPackage.name || null,
source: recommendedPackage.source || 'historical_suggestion'
}
: null;
const updated = await customerProjectService.updateProjectFields(projectId, {
project_description: projectDescription,
selected_packages: selectedPackages,
suggestion_snapshot: suggestionSnapshot,
input_normalization_log: inputNormalizationLog
});
if (!updated) {
return res.status(404).json({
success: false,
error: 'Projekt ikke fundet'
});
}
const result = await customerProjectService.getProjectWithDetails(projectId);
res.json({
success: true,
project: result?.project || null,
message: 'Historisk forslag gemt på projektet'
});
} catch (error) {
logger.error('Error applying order suggestion:', error);
res.status(500).json({
success: false,
error: 'Fejl ved gemning af forslag'
});
}
});
// Opdater projekt status
router.patch('/projects/:id/status', async (req, res) => {
try {
const projectId = parseInt(req.params.id);
const { status } = req.body;
const validStatuses = [
'draft', 'geometry_pending', 'labor_pending', 'materials_pending',
'calculation_ready', 'quote_generated', 'sent', 'accepted', 'rejected'
];
if (!validStatuses.includes(status)) {
if (!ALL_PROJECT_STATUSES.includes(status)) {
return res.status(400).json({
success: false,
error: 'Ugyldig status'
@@ -151,6 +239,23 @@ router.patch('/projects/:id/status', async (req, res) => {
}
});
router.get('/material-price-status', async (req, res) => {
try {
const status = await materialPriceStatusService.getStatus();
res.json({
success: true,
...status
});
} catch (error) {
logger.error('Error fetching material price status:', error);
res.status(500).json({
success: false,
error: 'Fejl ved hentning af materialepris status'
});
}
});
// ==================== TAG GEOMETRI ENDPOINTS ====================
// Gem tag geometri
@@ -196,7 +301,7 @@ router.post('/projects/:id/geometry', async (req, res) => {
console.log('✅ [GEOMETRY] Saved successfully:', result);
// Opdater projekt status
await customerProjectService.updateProjectStatus(projectId, 'labor_pending');
await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.GEOMETRY_COMPLETE);
res.json({
success: true,
@@ -273,7 +378,7 @@ router.post('/projects/:id/labor', async (req, res) => {
const result = await projectLaborService.saveProjectLabor(projectId, laborData);
// Opdater projekt status
await customerProjectService.updateProjectStatus(projectId, 'materials_pending');
await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.SMART_PACKAGE_COMPLETE);
res.json({
success: true,
@@ -482,7 +587,7 @@ router.post('/projects/:id/materials/bulk', async (req, res) => {
const result = await projectMaterialService.bulkAddMaterials(projectId, materials);
// Opdater projekt status til calculation_ready hvis alle materialer er tilføjet
await customerProjectService.updateProjectStatus(projectId, 'calculation_ready');
await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.SMART_PACKAGE_COMPLETE);
res.json({
success: true,
@@ -682,7 +787,7 @@ router.post('/projects/:id/calculate', async (req, res) => {
const result = await projectCalculationService.calculateProjectQuote(projectId, options);
// Opdater projekt status
await customerProjectService.updateProjectStatus(projectId, 'quote_generated');
await customerProjectService.updateProjectStatus(projectId, PROJECT_STATUS.REVIEW_PENDING);
res.json({
success: true,
@@ -725,6 +830,48 @@ router.get('/projects/:id/calculation', async (req, res) => {
}
});
router.post('/projects/:id/generate-quote-draft', async (req, res) => {
try {
const projectId = parseInt(req.params.id, 10);
const {
mode = 'ai',
quoteData = null,
suggestionData = null,
customInstructions = ''
} = req.body || {};
if (!projectId) {
return res.status(400).json({
success: false,
error: 'Ugyldigt projekt ID'
});
}
const result = await projectQuoteGenerationService.generateStructuredQuoteDraft(projectId, {
mode,
quoteData,
suggestionData,
customInstructions
});
await customerProjectService.updateProjectFields(projectId, {
input_normalization_log: result.normalizationLog || null,
project_status: PROJECT_STATUS.REVIEW_PENDING
});
res.json({
success: true,
...result
});
} catch (error) {
logger.error('Error generating quote draft:', error);
res.status(500).json({
success: false,
error: error.message || 'Fejl ved generering af tilbudskladde'
});
}
});
// ==================== HJÆLPE ENDPOINTS ====================
// Hent tag typer og muligheder
@@ -1654,4 +1801,3 @@ router.get('/:projectId/with-totals', async (req, res) => {
});
module.exports = router;
+92
View File
@@ -0,0 +1,92 @@
/**
* Bootstrap and wire core backend services in one place.
* Keeps startup wiring explicit and avoids ad-hoc requires in handlers.
*/
async function bootstrapServiceContainer({ logger, db, healthPool }) {
const databaseService = require('./databaseService');
const openaiService = require('./openaiService');
const webPriceService = require('./webPriceService');
const DynamicImportService = require('./dynamicImportService');
const OrderStatusService = require('./orderStatusService');
const QuoteTemplateService = require('./quoteTemplateService');
const PdfGenerationService = require('./pdfGenerationService');
const RealDataProjectSuggestionService = require('./realDataProjectSuggestionService');
const EnhancedOrderDataService = require('./enhancedOrderDataService');
const MaterialPackageService = require('./materialPackageService');
const SmartPackageManagementService = require('./smartPackageManagementService');
const PlanningService = require('./planningService');
const OrdrestyringSyncService = require('./ordrestyringSyncService');
// Shared constructors/services used in route handlers
const PriceImportService = require('./priceImportService').PriceImportService;
const BygmaPrisbogImportService = require('./bygmaPrisbogImportService');
const PackageService = require('./packageService');
const ProjectQuoteGenerationService = require('./projectQuoteGenerationService');
const RoofGeometryService = require('./roofGeometryService');
const BygmaImportService = require('./bygmaImportService');
const ordrestyringService = require('./ordrestyringService');
const graphqlClient = require('./graphqlClient');
let LocalOrdrestyringAnalyzer = null;
try {
LocalOrdrestyringAnalyzer = require('../../local_ordrestyring_analyzer');
} catch (error) {
logger.warn('Local Ordrestyring analyzer is unavailable:', error.message);
}
const dynamicImportService = new DynamicImportService(databaseService);
const orderStatusService = new OrderStatusService();
const quoteTemplateService = new QuoteTemplateService();
const pdfGenerationService = new PdfGenerationService();
const realDataProjectSuggestionService = new RealDataProjectSuggestionService(databaseService);
const enhancedOrderDataService = new EnhancedOrderDataService(databaseService);
const materialPackageService = new MaterialPackageService(databaseService);
const smartPackageManagementService = new SmartPackageManagementService(databaseService);
const planningService = new PlanningService(databaseService);
const ordrestyringSyncService = new OrdrestyringSyncService();
await databaseService.initialize();
logger.info('Database initialized successfully');
try {
await openaiService.initialize();
logger.info('OpenAI service initialized successfully');
} catch (error) {
logger.warn('OpenAI service initialization failed:', error.message);
// Continue without OpenAI - fallback to static quotes only
}
return {
databaseService,
openaiService,
webPriceService,
dynamicImportService,
orderStatusService,
quoteTemplateService,
pdfGenerationService,
realDataProjectSuggestionService,
enhancedOrderDataService,
materialPackageService,
smartPackageManagementService,
planningService,
ordrestyringSyncService,
// constructor-style deps used by handlers
PriceImportService,
BygmaPrisbogImportService,
PackageService,
ProjectQuoteGenerationService,
RoofGeometryService,
BygmaImportService,
LocalOrdrestyringAnalyzer,
ordrestyringService,
graphqlClient,
// infra pools
db,
healthPool
};
}
module.exports = {
bootstrapServiceContainer
};
+100 -14
View File
@@ -13,6 +13,7 @@ class CustomerProjectService {
const {
projectName,
customerName,
customerNumber,
customerEmail,
customerPhone,
customerAddress,
@@ -32,21 +33,49 @@ class CustomerProjectService {
projectDescription
});
const query = `
INSERT INTO customer_projects (
project_name, customer_name, customer_email,
customer_phone, customer_address, project_description
) VALUES (?, ?, ?, ?, ?, ?)
`;
let result;
const [result] = await this.db.pool.execute(query, [
projectName,
customerName,
customerEmail || null,
customerPhone || null,
address || null,
projectDescription || null
]);
try {
const query = `
INSERT INTO customer_projects (
project_name, customer_name, customer_number, customer_email,
customer_phone, customer_address, project_description
) VALUES (?, ?, ?, ?, ?, ?, ?)
`;
[result] = await this.db.pool.execute(query, [
projectName,
customerName,
customerNumber || null,
customerEmail || null,
customerPhone || null,
address || null,
projectDescription || null
]);
} catch (error) {
const isMissingCustomerNumberColumn = String(error.message || '').includes("Unknown column 'customer_number'");
if (!isMissingCustomerNumberColumn) {
throw error;
}
logger.warn('customer_number column missing in customer_projects - using legacy insert fallback');
const legacyQuery = `
INSERT INTO customer_projects (
project_name, customer_name, customer_email,
customer_phone, customer_address, project_description
) VALUES (?, ?, ?, ?, ?, ?)
`;
[result] = await this.db.pool.execute(legacyQuery, [
projectName,
customerName,
customerEmail || null,
customerPhone || null,
address || null,
projectDescription || null
]);
}
console.log('✅ Project inserted with ID:', result.insertId);
@@ -62,6 +91,8 @@ class CustomerProjectService {
projectName,
customer_name: customerName,
customerName,
customer_number: customerNumber || null,
customerNumber: customerNumber || null,
customer_email: customerEmail,
customerEmail,
customer_phone: customerPhone,
@@ -159,6 +190,61 @@ class CustomerProjectService {
}
}
async updateProjectFields(projectId, updates = {}) {
try {
const fieldMap = {
project_name: 'project_name',
project_description: 'project_description',
customer_name: 'customer_name',
customer_number: 'customer_number',
customer_email: 'customer_email',
customer_phone: 'customer_phone',
customer_address: 'customer_address',
project_status: 'project_status',
selected_packages: 'selected_packages',
suggestion_snapshot: 'suggestion_snapshot',
input_normalization_log: 'input_normalization_log'
};
const setClauses = [];
const values = [];
Object.entries(updates).forEach(([key, value]) => {
const column = fieldMap[key];
if (!column) {
return;
}
setClauses.push(`${column} = ?`);
values.push(
['selected_packages', 'suggestion_snapshot', 'input_normalization_log'].includes(column) &&
value &&
typeof value === 'object'
? JSON.stringify(value)
: value
);
});
if (setClauses.length === 0) {
return false;
}
values.push(projectId);
const [result] = await this.db.pool.execute(
`UPDATE customer_projects
SET ${setClauses.join(', ')}, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
values
);
return result.affectedRows > 0;
} catch (error) {
logger.error('Error updating project fields:', error);
throw error;
}
}
// Hent alle projekter med pagination
async getProjects(page = 1, limit = 20, status = null) {
try {
+64 -1
View File
@@ -1,11 +1,49 @@
const mysql = require('mysql2/promise');
const logger = require('../utils/logger');
const { ALL_PROJECT_STATUSES } = require('../constants/projectStatuses');
class DatabaseService {
constructor() {
this.pool = null;
}
async ensureCustomerProjectsColumn(columnName, alterSql) {
const [columns] = await this.pool.query(
`SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'customer_projects'
AND COLUMN_NAME = ?`,
[columnName]
);
if (columns.length === 0) {
await this.pool.query(alterSql);
}
}
async ensureCustomerProjectsStatusEnum(projectStatusEnumSql) {
const [columns] = await this.pool.query(
`SELECT COLUMN_TYPE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'customer_projects'
AND COLUMN_NAME = 'project_status'`
);
const currentType = String(columns[0]?.COLUMN_TYPE || '').toLowerCase();
const missingStatuses = ALL_PROJECT_STATUSES.filter(
(status) => !currentType.includes(`'${status.toLowerCase()}'`)
);
if (missingStatuses.length > 0) {
await this.pool.query(`
ALTER TABLE customer_projects
MODIFY COLUMN project_status ENUM(${projectStatusEnumSql}) DEFAULT 'draft'
`);
}
}
async initialize() {
try {
// SECURITY: Require database password in environment variables
@@ -659,25 +697,50 @@ class DatabaseService {
`);
// Create customer projects system tables
const projectStatusEnumSql = ALL_PROJECT_STATUSES.map((status) => `'${status}'`).join(', ');
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS customer_projects (
id INT AUTO_INCREMENT PRIMARY KEY,
project_name VARCHAR(255) NOT NULL,
customer_name VARCHAR(255) NOT NULL,
customer_number VARCHAR(50),
customer_email VARCHAR(255),
customer_phone VARCHAR(50),
customer_address TEXT,
project_description TEXT,
project_status ENUM('draft', 'geometry_pending', 'labor_pending', 'materials_pending', 'calculation_ready', 'quote_generated', 'sent', 'accepted', 'rejected') DEFAULT 'draft',
selected_packages JSON,
suggestion_snapshot JSON,
input_normalization_log JSON,
project_status ENUM(${projectStatusEnumSql}) DEFAULT 'draft',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (project_status),
INDEX idx_customer (customer_name),
INDEX idx_customer_number (customer_number),
INDEX idx_created (created_at)
)
`);
await this.ensureCustomerProjectsColumn(
'customer_number',
'ALTER TABLE customer_projects ADD COLUMN customer_number VARCHAR(50) AFTER customer_name'
);
await this.ensureCustomerProjectsColumn(
'selected_packages',
'ALTER TABLE customer_projects ADD COLUMN selected_packages JSON AFTER project_description'
);
await this.ensureCustomerProjectsColumn(
'suggestion_snapshot',
'ALTER TABLE customer_projects ADD COLUMN suggestion_snapshot JSON AFTER selected_packages'
);
await this.ensureCustomerProjectsColumn(
'input_normalization_log',
'ALTER TABLE customer_projects ADD COLUMN input_normalization_log JSON AFTER suggestion_snapshot'
);
await this.ensureCustomerProjectsStatusEnum(projectStatusEnumSql);
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS roof_geometry (
id INT AUTO_INCREMENT PRIMARY KEY,
@@ -0,0 +1,180 @@
const logger = require('../utils/logger');
class MaterialPriceStatusService {
constructor(databaseService) {
this.db = databaseService;
}
async getStatus() {
const [bygma, stark] = await Promise.all([
this.getBygmaStatus(),
this.getStarkStatus()
]);
const sources = [bygma, stark].filter(Boolean);
const primarySource = sources
.filter((source) => source.lastImportAt || source.latestPriceAt)
.sort((left, right) => {
const leftTs = new Date(left.lastImportAt || left.latestPriceAt || 0).getTime();
const rightTs = new Date(right.lastImportAt || right.latestPriceAt || 0).getTime();
return rightTs - leftTs;
})[0] || null;
return {
generatedAt: new Date().toISOString(),
primarySource,
sources
};
}
async getBygmaStatus() {
const latestImport = await this.safeFirst(`
SELECT
started_at,
completed_at,
status,
imported_by,
filename,
updated_prices,
successful_rows
FROM bygma_import_log
ORDER BY COALESCE(completed_at, started_at) DESC
LIMIT 1
`);
const latestPricePoint = await this.safeFirst(`
SELECT MAX(import_date) AS latest_price_at
FROM bygma_price_history
`);
if (!latestImport && !latestPricePoint?.latest_price_at) {
return {
source: 'Bygma',
available: false,
freshness: 'missing',
statusLabel: 'Ingen Bygma prisliste fundet'
};
}
return this.buildSourceStatus('Bygma', latestImport, latestPricePoint?.latest_price_at);
}
async getStarkStatus() {
const latestImport = await this.safeFirst(`
SELECT
started_at,
completed_at,
status,
imported_by,
file_name AS filename,
updated_items,
total_items
FROM import_logs
WHERE source_system = 'Stark'
ORDER BY COALESCE(completed_at, started_at) DESC
LIMIT 1
`);
const latestPricePoint = await this.safeFirst(`
SELECT MAX(last_updated) AS latest_price_at
FROM stark_materials_cache
`);
if (!latestImport && !latestPricePoint?.latest_price_at) {
return {
source: 'Stark',
available: false,
freshness: 'missing',
statusLabel: 'Ingen Stark prisliste fundet'
};
}
return this.buildSourceStatus('Stark', latestImport, latestPricePoint?.latest_price_at);
}
buildSourceStatus(source, latestImport, latestPriceAt) {
const lastImportAt = latestImport?.completed_at || latestImport?.started_at || latestPriceAt || null;
const ageDays = this.calculateAgeDays(lastImportAt);
const freshness = this.getFreshness(ageDays);
return {
source,
available: true,
freshness,
statusLabel: this.getStatusLabel(source, freshness, lastImportAt),
importStatus: latestImport?.status || 'unknown',
lastImportAt,
latestPriceAt: latestPriceAt || null,
importedBy: latestImport?.imported_by || null,
filename: latestImport?.filename || null,
changeCount: latestImport?.updated_prices ?? latestImport?.updated_items ?? null,
rowCount: latestImport?.successful_rows ?? latestImport?.total_items ?? null,
ageDays
};
}
calculateAgeDays(value) {
if (!value) {
return null;
}
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) {
return null;
}
return Math.floor((Date.now() - timestamp) / 86400000);
}
getFreshness(ageDays) {
if (ageDays === null) {
return 'missing';
}
if (ageDays <= 7) {
return 'fresh';
}
if (ageDays <= 30) {
return 'aging';
}
return 'stale';
}
getStatusLabel(source, freshness, lastImportAt) {
if (!lastImportAt) {
return `Ingen ${source} prisliste fundet`;
}
if (freshness === 'fresh') {
return `${source} prisliste er opdateret`;
}
if (freshness === 'aging') {
return `${source} prisliste er ved at blive gammel`;
}
return `${source} prisliste trænger til opdatering`;
}
async safeFirst(query, params = []) {
try {
const rows = await this.db.query(query, params);
return rows[0] || null;
} catch (error) {
const message = String(error.message || '').toLowerCase();
if (
message.includes('doesn\'t exist') ||
message.includes('unknown table') ||
message.includes('unknown column')
) {
return null;
}
logger.warn('Failed to read material price status source', {
error: error.message
});
return null;
}
}
}
module.exports = MaterialPriceStatusService;
@@ -0,0 +1,931 @@
const logger = require('../utils/logger');
const { OFFER_HISTORY_PROJECT_STATUSES } = require('../constants/projectStatuses');
class OrderSuggestionService {
constructor(databaseService) {
this.db = databaseService;
}
tokenize(value) {
return (value || '')
.toLowerCase()
.split(/[\s,.;:/()_-]+/)
.map(token => token.trim())
.filter(token => token.length >= 3);
}
normalizeText(value) {
return (value || '').toLowerCase().trim();
}
parseDate(value) {
const date = value ? new Date(value) : null;
return Number.isNaN(date?.getTime()) ? null : date;
}
parseJson(value, fallback = null) {
if (!value) {
return fallback;
}
if (typeof value === 'object') {
return value;
}
try {
return JSON.parse(value);
} catch (error) {
return fallback;
}
}
normalizeRoofType(value) {
const normalized = this.normalizeText(value)
.replace(/æ/g, 'ae')
.replace(/ø/g, 'oe')
.replace(/å/g, 'aa');
if (!normalized) {
return '';
}
if (normalized.includes('tagpap') || normalized.includes('flad')) {
return 'tagpap';
}
if (normalized.includes('b7') || normalized.includes('eternit')) {
return 'b7';
}
if (normalized.includes('tegl') || normalized.includes('betontegl') || normalized.includes('vingetegl')) {
return 'tegl';
}
if (normalized.includes('skorsten') || normalized.includes('inddaekning')) {
return 'skorsten';
}
if (normalized.includes('velux') || normalized.includes('tagvindue')) {
return 'velux';
}
if (normalized.includes('facade') || normalized.includes('beklaedning')) {
return 'facade';
}
if (normalized.includes('sadeltag') || normalized.includes('skraat')) {
return 'skraat_tag';
}
return normalized;
}
getPackageCatalog() {
return [
{
packageId: 'komplet_tagrenovering',
name: 'Komplet Tagrenovering',
keywords: ['nyt tag', 'komplet', 'tagrenovering', 'udskiftning af tag', 'renovering af tag']
},
{
packageId: 'b7_tag_udskiftning',
name: 'B7 Tag Udskiftning - Komplet',
keywords: ['b7', 'eternit', 'tagplader']
},
{
packageId: 'tegl_tag_renovering',
name: 'Tegltag Renovering',
keywords: ['tegl', 'tegltag', 'betontegl', 'vingetegl', 'rygning']
},
{
packageId: 'tagpap_renovering',
name: 'Tagpap Renovering',
keywords: ['tagpap', 'pap', 'fladt tag', 'fladtag']
},
{
packageId: 'tagreparation_skorsten',
name: 'Tagreparation Ved Skorsten',
keywords: ['skorsten', 'inddækning', 'inddaekning', 'tagreparation', 'utæt', 'utæthed', 'kip']
},
{
packageId: 'velux_tagvindue',
name: 'Velux / Tagvindue',
keywords: ['velux', 'tagvindue', 'ovenlys']
},
{
packageId: 'facade_træ',
name: 'Facade Træbeklædning',
keywords: ['facade', 'beklædning', 'træbeklædning']
}
];
}
inferRecommendedPackage(project, localTopMatch, orderTopMatch) {
const localPackages = this.parseJson(localTopMatch?.selected_packages, null);
const selectedPackage = Array.isArray(localPackages) ? localPackages[0] : localPackages;
if (selectedPackage?.id || selectedPackage?.name) {
return {
packageId: selectedPackage.id || null,
name: selectedPackage.name || 'Historisk valgt pakke',
source: 'historical_project',
reason: 'Brugt på lignende lokalt projekt'
};
}
const text = [
project.project_name,
project.project_description,
localTopMatch?.project_name,
localTopMatch?.project_description,
orderTopMatch?.description,
orderTopMatch?.remarks
].join(' ').toLowerCase();
const inferredRoofType = this.normalizeRoofType([
project.roof_type,
project.project_name,
project.project_description,
localTopMatch?.project_description,
orderTopMatch?.description
].join(' '));
const roofTypeAliases = {
b7: ['b7_tag_udskiftning'],
tegl: ['tegl_tag_renovering'],
tagpap: ['tagpap_renovering'],
skorsten: ['tagreparation_skorsten'],
velux: ['velux_tagvindue']
};
const directRoofTypeMatch = this.getPackageCatalog().find(item =>
roofTypeAliases[inferredRoofType]?.includes(item.packageId)
);
if (directRoofTypeMatch) {
return {
packageId: directRoofTypeMatch.packageId,
name: directRoofTypeMatch.name,
source: 'inferred',
reason: 'Udledt fra tagtype og historiske nøgleord'
};
}
const match = this.getPackageCatalog().find(item =>
item.keywords.some(keyword => text.includes(keyword))
);
if (!match) {
return null;
}
return {
packageId: match.packageId,
name: match.name,
source: 'inferred',
reason: 'Udledt fra projekttype og historiske matches'
};
}
buildSuggestionConfidence(topMatch, matchCount) {
if (!topMatch) {
return 'low';
}
if (topMatch.sourceType === 'local_project' && topMatch.relevanceScore >= 65) {
return 'high';
}
if (topMatch.sameCustomer && matchCount >= 2) {
return 'high';
}
if (topMatch.relevanceScore >= 40) {
return 'medium';
}
return 'low';
}
roundQuantity(value) {
return Math.round((parseFloat(value) || 0) * 100) / 100;
}
scaleFactor(targetArea, sourceArea) {
const target = parseFloat(targetArea) || 0;
const source = parseFloat(sourceArea) || 0;
if (!target || !source || source <= 0) {
return 1;
}
const ratio = target / source;
return Math.min(Math.max(ratio, 0.35), 3.5);
}
scaleMaterialQuantity(material, factor) {
const unit = this.normalizeText(material.unit);
if (unit.includes('pakke') || unit.includes('stk')) {
return Math.max(1, Math.round((parseFloat(material.quantity) || 0) * factor));
}
return this.roundQuantity((parseFloat(material.quantity) || 0) * factor);
}
normalizeMaterialDraft(material, factor = 1, reason = 'Fra historisk match') {
return {
name: material.name || material.material_name || 'Historisk materiale',
quantity: this.scaleMaterialQuantity(material, factor),
unit: material.unit || 'stk',
unitPrice: parseFloat(material.unitPrice ?? material.unit_price ?? 0) || 0,
category: material.category || material.material_category || 'Historik',
calculation: reason
};
}
normalizeLaborDraft(task, factor = 1, fallbackRate = 580) {
const totalHours = this.roundQuantity((parseFloat(task.totalHours ?? task.total_work_hours ?? task.fixedHours ?? task.hours ?? 0) || 0) * factor);
const rate = parseFloat(task.rate ?? task.hourly_rate ?? fallbackRate) || fallbackRate;
return {
name: task.name || task.task || 'Historisk opgave',
description: task.description || task.notes || 'Udledt fra historisk tilbud',
totalHours,
rate,
totalCost: this.roundQuantity(totalHours * rate),
category: task.category || 'Historik'
};
}
buildLaborTasksFromBreakdown(laborRow) {
if (!laborRow) {
return [];
}
const hourlyRate = parseFloat(laborRow.hourly_rate) || 580;
const breakdown = this.parseJson(laborRow.work_breakdown, []);
if (Array.isArray(breakdown) && breakdown.length > 0) {
return breakdown.map(item => this.normalizeLaborDraft({
name: item.task || item.name || item.category,
description: item.description || item.remark,
totalHours: item.totalHours ?? item.estimatedHours ?? item.hours,
rate: hourlyRate,
category: 'Historik'
}, 1, hourlyRate));
}
const totalWorkHours = parseFloat(laborRow.total_work_hours) || 0;
if (totalWorkHours <= 0) {
return [];
}
return [this.normalizeLaborDraft({
name: 'Samlet arbejdsestimat',
description: laborRow.notes || 'Opsummeret fra historisk projekt',
totalHours: totalWorkHours,
rate: hourlyRate,
category: 'Historik'
}, 1, hourlyRate)];
}
summarizeHistoryContext(localTopMatch, orderTopMatch) {
const sourceProjectIds = localTopMatch ? [localTopMatch.projectId] : [];
const sourceCaseNumbers = orderTopMatch ? [orderTopMatch.caseNumber] : [];
return {
sourceProjectIds,
sourceCaseNumbers,
localProjectName: localTopMatch?.projectName || null,
orderCaseNumber: orderTopMatch?.caseNumber || null
};
}
async getProjectContext(projectId) {
const [rows] = await this.db.pool.execute(
`
SELECT
cp.id,
cp.project_name,
cp.project_description,
cp.customer_name,
cp.customer_number,
cp.selected_packages,
cp.suggestion_snapshot,
rg.roof_type,
rg.total_area,
rg.roof_pitch
FROM customer_projects cp
LEFT JOIN roof_geometry rg ON rg.project_id = cp.id
WHERE cp.id = ?
`,
[projectId]
);
if (!rows[0]) {
const error = new Error('Projekt ikke fundet');
error.status = 404;
throw error;
}
return rows[0];
}
async getLocalCandidateProjects(project, tokens, candidateLimit) {
const params = [project.id];
const whereClauses = [];
if (project.customer_number) {
whereClauses.push('cp.customer_number = ?');
params.push(project.customer_number);
}
if (project.customer_name) {
whereClauses.push('LOWER(COALESCE(cp.customer_name, \'\')) LIKE ?');
params.push(`%${this.normalizeText(project.customer_name)}%`);
}
if (project.roof_type) {
whereClauses.push('LOWER(COALESCE(rg.roof_type, \'\')) = ?');
params.push(this.normalizeText(project.roof_type));
}
tokens.slice(0, 6).forEach(token => {
whereClauses.push(
'(LOWER(COALESCE(cp.project_name, \'\')) LIKE ? OR LOWER(COALESCE(cp.project_description, \'\')) LIKE ?)'
);
params.push(`%${token}%`, `%${token}%`);
});
if (whereClauses.length === 0) {
return [];
}
const [rows] = await this.db.pool.execute(
`
SELECT
cp.id AS project_id,
cp.project_name,
cp.project_description,
cp.customer_name,
cp.customer_number,
cp.project_status,
cp.selected_packages,
cp.created_at,
cp.updated_at,
rg.roof_type,
rg.total_area,
rg.roof_pitch,
pl.total_work_hours,
pl.hourly_rate,
pl.work_breakdown,
pl.notes AS labor_notes
FROM customer_projects cp
LEFT JOIN roof_geometry rg ON rg.project_id = cp.id
LEFT JOIN project_labor pl ON pl.project_id = cp.id
WHERE cp.id <> ? AND (${whereClauses.join(' OR ')})
ORDER BY cp.updated_at DESC
LIMIT ?
`,
[...params, candidateLimit]
);
if (rows.length === 0) {
return [];
}
const projectIds = rows.map(row => row.project_id);
const placeholders = projectIds.map(() => '?').join(', ');
const [materials] = await this.db.pool.execute(
`
SELECT
project_id,
material_name,
material_category,
quantity,
unit,
unit_price
FROM project_materials
WHERE project_id IN (${placeholders})
ORDER BY project_id, total_price DESC, created_at DESC
`,
projectIds
);
const [quotes] = await this.db.pool.execute(
`
SELECT project_id, quote_text, created_at
FROM generated_quotes
WHERE project_id IN (${placeholders})
ORDER BY created_at DESC
`,
projectIds
);
const materialsByProject = new Map();
materials.forEach(material => {
if (!materialsByProject.has(material.project_id)) {
materialsByProject.set(material.project_id, []);
}
materialsByProject.get(material.project_id).push(material);
});
const quoteByProject = new Map();
quotes.forEach(quote => {
if (!quoteByProject.has(quote.project_id)) {
quoteByProject.set(quote.project_id, quote);
}
});
return rows.map(row => ({
...row,
materials: materialsByProject.get(row.project_id) || [],
quote_text: quoteByProject.get(row.project_id)?.quote_text || '',
latest_quote_at: quoteByProject.get(row.project_id)?.created_at || null,
laborTasks: this.buildLaborTasksFromBreakdown(row)
}));
}
calculateLocalCandidateScore(project, candidate, tokens) {
const projectText = [
candidate.project_name,
candidate.project_description,
candidate.quote_text
].join(' ').toLowerCase();
const materialText = (candidate.materials || [])
.map(material => `${material.material_name || ''} ${material.material_category || ''}`)
.join(' ')
.toLowerCase();
const sourceRoofType = this.normalizeRoofType(candidate.roof_type);
const targetRoofType = this.normalizeRoofType(project.roof_type);
const sameCustomer = Boolean(
project.customer_number &&
candidate.customer_number &&
project.customer_number === candidate.customer_number
);
let score = 0;
const reasons = [];
if (sameCustomer) {
score += 35;
reasons.push('Samme kunde i lokal historik');
} else if (
this.normalizeText(project.customer_name) &&
this.normalizeText(candidate.customer_name).includes(this.normalizeText(project.customer_name))
) {
score += 12;
reasons.push('Kundenavn matcher');
}
if (targetRoofType && sourceRoofType && targetRoofType === sourceRoofType) {
score += 24;
reasons.push('Samme tagtype');
}
const targetArea = parseFloat(project.total_area) || 0;
const sourceArea = parseFloat(candidate.total_area) || 0;
if (targetArea > 0 && sourceArea > 0) {
const delta = Math.abs(targetArea - sourceArea) / Math.max(targetArea, 1);
if (delta <= 0.2) {
score += 18;
reasons.push('Lignende areal');
} else if (delta <= 0.4) {
score += 8;
}
}
let keywordHits = 0;
tokens.forEach(token => {
if (projectText.includes(token)) {
score += 6;
keywordHits += 1;
}
if (materialText.includes(token)) {
score += 4;
}
});
if (keywordHits > 0) {
reasons.push(`${keywordHits} nøgleord matcher`);
}
if ((candidate.materials || []).length > 0) {
score += 10;
reasons.push('Har materialehistorik');
}
if ((candidate.laborTasks || []).length > 0 || (parseFloat(candidate.total_work_hours) || 0) > 0) {
score += 10;
reasons.push('Har timehistorik');
}
if (OFFER_HISTORY_PROJECT_STATUSES.includes(candidate.project_status)) {
score += 8;
reasons.push('Projektet blev brugt som tilbud');
}
const latestActivity = this.parseDate(candidate.latest_quote_at || candidate.updated_at || candidate.created_at);
if (latestActivity) {
const ageDays = Math.floor((Date.now() - latestActivity.getTime()) / 86400000);
if (ageDays <= 180) {
score += 8;
reasons.push('Nyere projekt');
} else if (ageDays <= 365) {
score += 4;
}
}
return {
sameCustomer,
relevanceScore: score,
matchReasons: reasons
};
}
async getOrderCandidateCases(project, tokens, candidateLimit) {
const whereClauses = [];
const params = [];
if (project.customer_number) {
whereClauses.push('c.customer_number = ?');
params.push(project.customer_number);
}
if (project.customer_name) {
whereClauses.push('LOWER(COALESCE(d.customer_name, \'\')) LIKE ?');
params.push(`%${this.normalizeText(project.customer_name)}%`);
}
tokens.slice(0, 6).forEach(token => {
whereClauses.push(
'(LOWER(COALESCE(c.description, \'\')) LIKE ? OR LOWER(COALESCE(c.remarks, \'\')) LIKE ?)'
);
params.push(`%${token}%`, `%${token}%`);
});
if (whereClauses.length === 0) {
return [];
}
const [rows] = await this.db.pool.execute(
`
SELECT
c.case_number,
c.customer_number,
d.customer_name,
c.description,
c.remarks,
c.creation_date,
c.status,
COALESCE(SUM(h.duration_hours), 0) AS total_hours,
COUNT(h.id) AS hour_entries
FROM ordrestyring_local.cases c
LEFT JOIN ordrestyring_local.debtors d
ON d.customer_number = c.customer_number
LEFT JOIN ordrestyring_local.hours h
ON h.case_number = c.case_number
WHERE ${whereClauses.join(' OR ')}
GROUP BY
c.case_number,
c.customer_number,
d.customer_name,
c.description,
c.remarks,
c.creation_date,
c.status
ORDER BY c.creation_date DESC
LIMIT ?
`,
[...params, candidateLimit]
);
return rows;
}
calculateOrderCandidateScore(project, candidate, tokens) {
const descriptionText = this.normalizeText(candidate.description);
const remarksText = this.normalizeText(candidate.remarks);
const combinedText = `${descriptionText} ${remarksText}`.trim();
const customerName = this.normalizeText(candidate.customer_name);
const projectCustomerName = this.normalizeText(project.customer_name);
let score = 0;
const reasons = [];
const sameCustomer = Boolean(
project.customer_number &&
candidate.customer_number &&
project.customer_number === candidate.customer_number
);
if (sameCustomer) {
score += 50;
reasons.push('Samme kunde i Ordrestyring');
} else if (projectCustomerName && customerName && customerName.includes(projectCustomerName)) {
score += 15;
reasons.push('Kundenavn matcher');
}
let keywordHits = 0;
tokens.forEach(token => {
if (combinedText.includes(token)) {
score += 8;
keywordHits += 1;
}
});
if (keywordHits > 0) {
reasons.push(`${keywordHits} nøgleord matcher`);
}
const creationDate = this.parseDate(candidate.creation_date);
if (creationDate) {
const ageDays = Math.floor((Date.now() - creationDate.getTime()) / 86400000);
if (ageDays <= 180) {
score += 10;
reasons.push('Nyere sag');
} else if (ageDays <= 365) {
score += 5;
}
}
if ((parseFloat(candidate.total_hours) || 0) > 0) {
score += 8;
reasons.push('Har timehistorik');
}
return {
sameCustomer,
relevanceScore: score,
matchReasons: reasons
};
}
buildSuggestedDescription(project, localTopMatch, orderTopMatch) {
const currentDescription = (project.project_description || '').trim();
if (currentDescription) {
return currentDescription;
}
if (localTopMatch?.project_description) {
return localTopMatch.project_description.trim();
}
if (orderTopMatch?.description) {
return orderTopMatch.description.trim();
}
return `Tilbud på ${project.project_name || 'projekt'}`;
}
buildSuggestionSummary(localMatches, orderMatches, topMatch) {
if (!topMatch) {
return 'Ingen lokale historiske sager matcher godt nok endnu.';
}
const localCount = localMatches.length;
const orderCount = orderMatches.length;
const pieces = [];
if (localCount > 0) {
pieces.push(`${localCount} lokale projekter`);
}
if (orderCount > 0) {
pieces.push(`${orderCount} Ordrestyring-sager`);
}
const customerPart = topMatch.sameCustomer ? ' fra samme kunde' : '';
return `Baseret på ${pieces.join(' og ')}${customerPart}.`;
}
buildClarifications(project, recommendedPackage, materialsDraft, laborDraft) {
const clarifications = [];
if (!project.roof_type) {
clarifications.push('Vælg tagtype for mere præcis pakkeanbefaling.');
}
if (!(parseFloat(project.total_area) || 0)) {
clarifications.push('Indtast tagmål eller areal for bedre skalering af materialer og timer.');
}
if (!recommendedPackage) {
clarifications.push('Vælg en Smart Pakke manuelt hvis ingen historik matcher tydeligt.');
}
if (materialsDraft.length === 0) {
clarifications.push('Tilføj eller bekræft materialer før tilbuddet sendes.');
}
if (laborDraft.length === 0) {
clarifications.push('Tilføj eller bekræft timer/opgaver før tilbuddet sendes.');
}
return clarifications.slice(0, 3);
}
buildWarnings(topMatch, materialsDraft, laborDraft) {
const warnings = [];
if (!topMatch) {
warnings.push('Ingen stærk historisk reference fundet endnu.');
}
if (topMatch?.sourceType === 'orderstyring_case' && materialsDraft.length === 0) {
warnings.push('Ordrestyring-match giver primært tekst og timer, ikke komplet materialeliste.');
}
if (laborDraft.length === 0) {
warnings.push('Timer er ikke udfyldt automatisk og bør dobbelttjekkes.');
}
return warnings;
}
mapMatch(match) {
if (match.sourceType === 'local_project') {
return {
sourceType: 'local_project',
projectId: match.projectId,
projectName: match.projectName,
customerName: match.customerName || '',
description: match.projectDescription || '',
creationDate: match.creationDate || null,
totalHours: this.roundQuantity(match.totalHours),
sameCustomer: match.sameCustomer,
relevanceScore: match.relevanceScore,
matchReasons: match.matchReasons
};
}
return {
sourceType: 'orderstyring_case',
caseNumber: match.caseNumber,
customerNumber: match.customerNumber,
customerName: match.customerName || '',
description: match.description || '',
remarks: match.remarks || '',
status: match.status || '',
creationDate: match.creationDate || null,
totalHours: this.roundQuantity(match.totalHours),
hourEntries: parseInt(match.hourEntries, 10) || 0,
sameCustomer: match.sameCustomer,
relevanceScore: match.relevanceScore,
matchReasons: match.matchReasons
};
}
async getOrderSuggestions(projectId, options = {}) {
const limit = Math.min(parseInt(options.limit, 10) || 5, 10);
const candidateLimit = Math.max(limit * 4, 12);
try {
const project = await this.getProjectContext(projectId);
const tokens = this.tokenize(
`${project.project_name || ''} ${project.project_description || ''} ${project.roof_type || ''}`
);
const [localCandidates, orderCandidates] = await Promise.all([
this.getLocalCandidateProjects(project, tokens, candidateLimit),
this.getOrderCandidateCases(project, tokens, candidateLimit)
]);
const localMatches = localCandidates
.map(candidate => {
const scored = this.calculateLocalCandidateScore(project, candidate, tokens);
return {
sourceType: 'local_project',
projectId: candidate.project_id,
projectName: candidate.project_name || '',
projectDescription: candidate.project_description || '',
customerName: candidate.customer_name || '',
customerNumber: candidate.customer_number || null,
projectStatus: candidate.project_status || '',
selected_packages: candidate.selected_packages || null,
creationDate: candidate.updated_at || candidate.created_at || null,
roofType: candidate.roof_type || '',
totalArea: parseFloat(candidate.total_area) || 0,
totalHours: parseFloat(candidate.total_work_hours) || 0,
hourlyRate: parseFloat(candidate.hourly_rate) || 580,
materials: candidate.materials || [],
laborTasks: candidate.laborTasks || [],
quoteText: candidate.quote_text || '',
...scored
};
})
.filter(candidate => candidate.relevanceScore > 0)
.sort((left, right) => {
if (right.relevanceScore !== left.relevanceScore) {
return right.relevanceScore - left.relevanceScore;
}
return new Date(right.creationDate || 0) - new Date(left.creationDate || 0);
})
.slice(0, limit);
const orderMatches = orderCandidates
.map(candidate => ({
sourceType: 'orderstyring_case',
caseNumber: candidate.case_number,
customerNumber: candidate.customer_number,
customerName: candidate.customer_name || '',
description: candidate.description || '',
remarks: candidate.remarks || '',
status: candidate.status || '',
creationDate: candidate.creation_date || null,
totalHours: parseFloat(candidate.total_hours) || 0,
hourEntries: parseInt(candidate.hour_entries, 10) || 0,
...this.calculateOrderCandidateScore(project, candidate, tokens)
}))
.filter(candidate => candidate.relevanceScore > 0)
.sort((left, right) => {
if (right.relevanceScore !== left.relevanceScore) {
return right.relevanceScore - left.relevanceScore;
}
return new Date(right.creationDate || 0) - new Date(left.creationDate || 0);
})
.slice(0, limit);
const combinedMatches = [...localMatches, ...orderMatches]
.sort((left, right) => {
if (right.relevanceScore !== left.relevanceScore) {
return right.relevanceScore - left.relevanceScore;
}
return new Date(right.creationDate || 0) - new Date(left.creationDate || 0);
})
.slice(0, limit);
const topMatch = combinedMatches[0] || null;
const localTopMatch = localMatches[0] || null;
const orderTopMatch = orderMatches[0] || null;
const projectScaleFactor = this.scaleFactor(project.total_area, localTopMatch?.totalArea);
const materialsDraft = localTopMatch
? localTopMatch.materials
.slice(0, 16)
.map(material => this.normalizeMaterialDraft(material, projectScaleFactor, 'Skaleret fra lignende historisk projekt'))
: [];
const laborDraft = localTopMatch
? localTopMatch.laborTasks.map(task =>
this.normalizeLaborDraft(task, projectScaleFactor, localTopMatch.hourlyRate)
)
: (orderTopMatch && orderTopMatch.totalHours > 0
? [this.normalizeLaborDraft({
name: 'Historisk arbejdsestimat',
description: `Udledt fra Ordrestyring sag ${orderTopMatch.caseNumber}`,
totalHours: orderTopMatch.totalHours,
rate: 580,
category: 'Historik'
})]
: []);
const recommendedPackage = this.inferRecommendedPackage(project, localTopMatch, orderTopMatch);
const suggestedDescription = this.buildSuggestedDescription(project, localTopMatch, orderTopMatch);
const averageHistoricalHours = combinedMatches.length > 0
? combinedMatches.reduce((sum, match) => sum + (parseFloat(match.totalHours) || 0), 0) / combinedMatches.length
: 0;
const historyContext = this.summarizeHistoryContext(localTopMatch, orderTopMatch);
const clarifications = this.buildClarifications(project, recommendedPackage, materialsDraft, laborDraft);
const warnings = this.buildWarnings(topMatch, materialsDraft, laborDraft);
return {
projectId,
customerNumber: project.customer_number || null,
recommendation: {
confidence: this.buildSuggestionConfidence(topMatch, combinedMatches.length),
summary: this.buildSuggestionSummary(localMatches, orderMatches, topMatch),
suggestedDescription,
descriptionDraft: suggestedDescription,
averageHistoricalHours: this.roundQuantity(averageHistoricalHours),
recommendedPackage,
warnings,
clarifications
},
materialsDraft,
laborDraft,
prefill: {
recommendedPackage,
suggestedDescription,
materials: materialsDraft,
laborTasks: laborDraft,
historyContext
},
historyContext,
matches: combinedMatches.map(match => this.mapMatch(match))
};
} catch (error) {
logger.error('Error building Orderstyring suggestions', {
projectId,
error: error.message
});
throw error;
}
}
}
module.exports = OrderSuggestionService;
+206 -1395
View File
File diff suppressed because it is too large Load Diff
+33 -4
View File
@@ -6,11 +6,30 @@ Use this file to keep AI assistants (Copilot, Claude, Cursor) aligned on project
**Tilbudgivern** is an AI-powered quote calculator for Danish carpenters, focused on roofing work. It uses OpenAI GPT-4 to generate professional quotes with auto-calculated materials and labor.
## Mission Memory
This is the core mission and should guide product, UX, AI, and data decisions:
- Tilbudgivern must make it possible for a carpenter to stand with the customer, note a few roof measurements and short project notes, and get to a usable quote draft as fast as possible.
- The primary use case is mobile phone on site or laptop in the van, so speed, low friction, and strong defaults matter more than long forms.
- The system should reuse as much historical data as possible from both local Tilbudgivern projects and Ordrestyring history.
- Smart Packages are the operational backbone: they should be easy to choose, easy to edit, and contain realistic carpenter tasks, hours, materials, and pricing structure.
- AI should act as a carpenter-friendly writing and cleanup assistant: fix spelling and structure, turn rough notes into sharp customer-facing offer text, but never silently invent or change factual numbers.
- Every quote flow should create reusable historical learning signals, so the system gets better the more it is used.
Short version:
> Fast field quote creation for carpenters, powered by reusable history, editable Smart Packages, and AI-assisted customer text.
## Active Focus
- [ ] Smart Packages testing and refinement
- [ ] UI/UX improvements for quote creation flow
- [ ] Ordrestyring API GraphQL migration
- [ ] 2-minute quick quote flow for field use
- [ ] Reuse historical data from local projects + Ordrestyring in Smart Package selection
- [ ] Keep Smart Packages editable but prefilled from history
- [ ] Generate structured AI quote drafts from project/package/material/labor data
- [ ] Capture learning data from each completed quote flow
- [ ] Persist project workflow status in DB: `geometry_complete -> smart_package_complete -> review_pending -> ready_for_ordrestyring -> sent_to_ordrestyring`
- [ ] Show latest material pricelist freshness from persisted import logs/API in carpenter flow
## Tech Stack Quick Reference
@@ -48,6 +67,7 @@ Pre-configured roofing packages with auto-calculation:
- [CLAUDE.md](CLAUDE.md) - Claude Code instructions
- [docs/README.md](docs/README.md) - Documentation index
- [docs/deployment/PM2_GUIDE.md](docs/deployment/PM2_GUIDE.md) - PM2 management
- [docs/MISSION.md](MISSION.md) - Product mission and North Star
### Features
- [docs/features/SMART_PACKAGES_COMPLETE_CATALOG.md](docs/features/SMART_PACKAGES_COMPLETE_CATALOG.md) - All 15 packages
@@ -102,6 +122,15 @@ Required in `backend/.env`:
Optional:
- `ORDRESTYRING_API_TOKEN`, `OPENAI_ADMIN_KEY`
Business-owned OpenAI setup:
- All OpenAI usage must use the shared business account configuration, not ad hoc personal/project credentials.
- Prefer the centralized backend config helpers over direct client initialization.
Workflow state memory:
- Final Review must not force sending. A carpenter must be able to leave a quote in `ready_for_ordrestyring` and return later.
- Frontend status displays should prefer persisted DB/API state over hardcoded assumptions.
- Material price freshness should come from persisted import logs such as Bygma/Stark import history, not from frontend-only state.
## How to Use with AI Assistants
**VS Code Copilot:**
@@ -120,4 +149,4 @@ See .cursorrules and COPILOT_MEMORY.md for project context.
```
---
*Last updated: January 2026*
*Last updated: 2026-03-29*
+99
View File
@@ -0,0 +1,99 @@
# Tilbudgivern Mission
Last updated: 2026-03-29
## North Star
Tilbudgivern exists to make quote creation so fast and reliable that a carpenter can stand with the customer, measure the roof, note a few project facts, and leave with a strong quote draft the same day.
The product should work equally well on:
- mobile phone on site
- laptop in the van
- desktop in the office
## Core Mission
1. Minimize friction in the field.
The carpenter should only need a few inputs to get started:
- customer/address
- project type
- roof type/material
- length/width/pitch or rough area
- short customer note
2. Reuse as much real data as possible.
Tilbudsgiveren should prefer real historical evidence over empty forms:
- local Tilbudgivern project history
- synced Ordrestyring history
- previously used Smart Packages
- previously accepted/sent offers
3. Make Smart Packages the operational backbone.
Smart Packages should be:
- quick to choose
- easy to edit
- grounded in real carpenter work
- structured with materials, tasks, hours, and pricing
4. Let AI help with language, not fake facts.
AI should:
- fix spelling and messy notes
- structure rough carpenter input
- produce sharp, customer-friendly offer text
- explain the work clearly
AI must not:
- invent measurements
- invent prices
- silently change quantities, rates, or totals
- fabricate work not present in project/package data
5. Make the system improve through usage.
Every completed quote flow should create reusable learning signals:
- what package was suggested
- what package was chosen
- what materials/tasks were changed
- what text was accepted
- which historical cases were used
The system should get better over time as more quotes are created.
## Product Principles
- Field-first beats backoffice-first.
- Prefill beats blank state.
- Editable beats locked automation.
- Real history beats generic AI.
- Short path to first useful draft beats perfect initial completeness.
- Structured project data beats free-text prompts.
## What “Good” Looks Like
A carpenter can:
- create a project in under 2 minutes
- get a relevant Smart Package recommendation automatically
- receive suggested materials and hours from similar jobs
- generate a customer-ready quote draft from structured data
- correct mistakes quickly before sending
- leave the quote in a clear persisted status before sending to Ordrestyring
## Implementation Direction
When choosing features, architecture, or UX, prefer work that moves the product toward:
- quick quote flow
- strong historical matching
- local sync and reuse of Ordrestyring data
- structured quote generation
- mobile-friendly editing and confirmation
- persisted workflow states that match the real carpenter journey
- visible material pricelist freshness from synced supplier/import data
- measurable learning from accepted/edited quotes
## Non-Goals
Avoid optimizing primarily for:
- long admin workflows before first quote value
- AI-generated content without structured project grounding
- rigid package systems that are hard for carpenters to override
- flows that assume office-only usage
+438 -91
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { useNotification } from '../hooks/useNotification';
import './FinalReview.css';
import { PROJECT_STATUS, getProjectStatusMeta, isFinalizedProjectStatus } from '../utils/projectStatus';
const FinalReview = ({
apiBaseUrl,
@@ -19,6 +20,9 @@ const FinalReview = ({
// Editable pricing state
const [editableMaterials, setEditableMaterials] = useState(packageData?.materials || []);
const [editableLaborTasks, setEditableLaborTasks] = useState(packageData?.laborTasks || []);
const [editableProjectDescription, setEditableProjectDescription] = useState(
packageData?.recommendedDescription || project?.project_description || project?.description || ''
);
const [materialProfitMargin, setMaterialProfitMargin] = useState(20); // 20% på materialer
const [laborProfitMargin, setLaborProfitMargin] = useState(2); // 2% på timer
@@ -32,6 +36,103 @@ const FinalReview = ({
// PDF state
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const [pdfUrl, setPdfUrl] = useState(null);
const [orderSuggestions, setOrderSuggestions] = useState(null);
const [isLoadingOrderSuggestions, setIsLoadingOrderSuggestions] = useState(false);
const [orderSuggestionError, setOrderSuggestionError] = useState('');
const [projectStatus, setProjectStatus] = useState(project?.project_status || PROJECT_STATUS.DRAFT);
const [materialPriceStatus, setMaterialPriceStatus] = useState(null);
const [isSavingReadyStatus, setIsSavingReadyStatus] = useState(false);
const getErrorMessage = React.useCallback((errorLike, fallback = 'Ukendt fejl') => {
if (!errorLike) {
return fallback;
}
if (typeof errorLike === 'string') {
return errorLike;
}
if (typeof errorLike.message === 'string') {
return errorLike.message;
}
if (typeof errorLike.error === 'string') {
return errorLike.error;
}
try {
return JSON.stringify(errorLike);
} catch (error) {
return fallback;
}
}, []);
const buildLocalStaticQuote = React.useCallback((quoteData) => {
const projectName = quoteData?.project?.project_name || quoteData?.project?.name || 'Tilbud';
const customerName = quoteData?.project?.customer_name || 'Kunde';
const address = quoteData?.project?.customer_address || 'Opmålt hos kunde';
const roofType = quoteData?.geometry?.roof_type || 'Tagarbejde';
const roofArea = quoteData?.geometry?.total_area || quoteData?.geometry?.roofArea || 0;
const packageName = quoteData?.package?.name || '';
const materials = Array.isArray(quoteData?.materials) ? quoteData.materials : [];
const labor = Array.isArray(quoteData?.labor) ? quoteData.labor : [];
const totals = quoteData?.totals || {};
const materialLines = materials.slice(0, 6).map(material =>
`${material.name || material.material_name}: ${material.quantity} ${material.unit}`
).join('\n');
const laborLines = labor.map(task =>
`${task.name || task.task_name}${task.description ? ` - ${task.description}` : ''} (${(parseFloat(task.totalHours ?? task.estimated_hours) || 0).toFixed(1)} timer)`
).join('\n');
return `${String(projectName).toUpperCase()}\n\nProjekt\n • Kunde: ${customerName}\n • Adresse: ${address}\n • Tagtype: ${roofType}\n • Tagareal: ${roofArea}${packageName ? `\n • Valgt løsning: ${packageName}` : ''}\n\nArbejdet omfatter\n${laborLines || ' • Arbejdsopgaver aftales efter endelig gennemgang'}\n\nMaterialer\n${materialLines || ' • Materialer specificeres efter endelig opmåling'}\n\nPris\n • Materialer: ${formatCurrency(totals.materials || 0)} ekskl. moms\n • Arbejde: ${formatCurrency(totals.labor || 0)} ekskl. moms\n • Subtotal: ${formatCurrency(totals.subtotal || 0)} ekskl. moms\n • Moms (25%): ${formatCurrency(totals.tax || 0)}\n • Samlet pris: ${formatCurrency(totals.total || 0)} inkl. moms`;
}, []);
const applySuggestedSolution = React.useCallback(async () => {
if (!orderSuggestions) {
return;
}
const suggestedDescription =
orderSuggestions.prefill?.suggestedDescription ||
orderSuggestions.recommendation?.suggestedDescription ||
'';
const suggestedMaterials = orderSuggestions.prefill?.materials || orderSuggestions.materialsDraft || [];
const suggestedLaborTasks = orderSuggestions.prefill?.laborTasks || orderSuggestions.laborDraft || [];
if (suggestedDescription) {
setEditableProjectDescription(suggestedDescription);
}
if (suggestedMaterials.length > 0) {
setEditableMaterials(suggestedMaterials);
}
if (suggestedLaborTasks.length > 0) {
setEditableLaborTasks(suggestedLaborTasks);
}
try {
await fetch(`${apiBaseUrl}/api/customer-projects/projects/${project.id}/apply-suggestion`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
recommendedPackage: orderSuggestions.recommendation?.recommendedPackage || null,
suggestionSnapshot: orderSuggestions,
projectDescription: suggestedDescription,
inputNormalizationLog: {
source: 'final_review_apply_suggestion',
appliedAt: new Date().toISOString()
}
})
});
} catch (error) {
console.warn('Could not persist suggestion snapshot:', error);
}
}, [apiBaseUrl, orderSuggestions, project?.id]);
// Scroll to top when component mounts
React.useEffect(() => {
@@ -65,14 +166,14 @@ const FinalReview = ({
});
// Normaliser geometri data til at håndtere begge navnekonventioner
const normalizedGeometry = geometryData ? {
const normalizedGeometry = React.useMemo(() => (geometryData ? {
width: geometryData.width || geometryData.width_main || 0,
length: geometryData.length || geometryData.length_main || 0,
wallHeight: geometryData.wallHeight || geometryData.wall_height || 0,
roofPitch: geometryData.roofPitch || geometryData.roof_pitch || 0,
roofType: geometryData.roofType || geometryData.roof_type || 'unknown',
totalArea: geometryData.totalArea || geometryData.total_area || 0
} : null;
} : null), [geometryData]);
console.log('📊 Normalized geometry:', normalizedGeometry);
@@ -84,7 +185,98 @@ const FinalReview = ({
if (packageData?.laborTasks) {
setEditableLaborTasks(packageData.laborTasks);
}
}, [packageData]);
setEditableProjectDescription(
packageData?.recommendedDescription || project?.project_description || project?.description || ''
);
}, [packageData, project]);
React.useEffect(() => {
setProjectStatus(project?.project_status || PROJECT_STATUS.DRAFT);
}, [project?.project_status]);
const persistProjectStatus = React.useCallback(async (status) => {
if (!project?.id) {
return false;
}
const response = await fetch(`${apiBaseUrl}/api/customer-projects/projects/${project.id}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ status })
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Kunne ikke opdatere projektstatus');
}
setProjectStatus(status);
return true;
}, [apiBaseUrl, project?.id]);
React.useEffect(() => {
const loadMaterialPriceStatus = async () => {
try {
const response = await fetch(`${apiBaseUrl}/api/customer-projects/material-price-status`);
const data = await response.json();
if (data.success) {
setMaterialPriceStatus(data);
}
} catch (error) {
console.warn('Could not load material price status:', error);
}
};
loadMaterialPriceStatus();
}, [apiBaseUrl]);
React.useEffect(() => {
if (!project?.id || isFinalizedProjectStatus(projectStatus) || projectStatus === PROJECT_STATUS.REVIEW_PENDING) {
return;
}
if (!editableMaterials.length && !editableLaborTasks.length) {
return;
}
persistProjectStatus(PROJECT_STATUS.REVIEW_PENDING).catch((error) => {
console.warn('Could not persist review status:', error);
});
}, [editableLaborTasks.length, editableMaterials.length, persistProjectStatus, project?.id, projectStatus]);
React.useEffect(() => {
const loadOrderSuggestions = async () => {
if (!project?.id) {
return;
}
setIsLoadingOrderSuggestions(true);
setOrderSuggestionError('');
try {
const response = await fetch(`${apiBaseUrl}/api/customer-projects/projects/${project.id}/order-suggestions`);
const data = await response.json();
if (data.success) {
setOrderSuggestions(data);
} else {
setOrderSuggestions(null);
setOrderSuggestionError(data.error || 'Kunne ikke hente Ordrestyring forslag');
}
} catch (error) {
console.error('Error loading order suggestions:', error);
setOrderSuggestions(null);
setOrderSuggestionError('Netværksfejl ved hentning af Ordrestyring forslag');
} finally {
setIsLoadingOrderSuggestions(false);
}
};
loadOrderSuggestions();
}, [apiBaseUrl, project?.id]);
// Beregn totaler baseret på editable data
const calculateMaterialTotal = () => {
@@ -105,7 +297,8 @@ const FinalReview = ({
if (onBackWithData) {
onBackWithData({
materials: editableMaterials,
laborTasks: editableLaborTasks
laborTasks: editableLaborTasks,
recommendedDescription: editableProjectDescription
});
} else {
// Fallback to regular onBack if no data handler
@@ -123,6 +316,11 @@ const FinalReview = ({
const taxRate = 0.25; // 25% moms
const taxAmount = subtotalWithProfit * taxRate;
const grandTotal = subtotalWithProfit + taxAmount;
const projectStatusMeta = getProjectStatusMeta(projectStatus);
const primaryMaterialSource = materialPriceStatus?.primarySource || null;
const formattedPriceImportDate = primaryMaterialSource?.lastImportAt
? new Date(primaryMaterialSource.lastImportAt).toLocaleString('da-DK')
: null;
const handleSubmitToOrdering = async () => {
setIsSubmitting(true);
@@ -134,7 +332,11 @@ const FinalReview = ({
id: project.id,
name: project.name,
customer: project.customer_name,
description: project.description
customerNumber: project.customer_number,
customerEmail: project.customer_email,
customerPhone: project.customer_phone,
customerAddress: project.customer_address,
description: editableProjectDescription
},
geometry: {
width: normalizedGeometry.width,
@@ -144,7 +346,7 @@ const FinalReview = ({
roofArea: calculateRoofArea(normalizedGeometry.width, normalizedGeometry.length, normalizedGeometry.roofPitch)
},
package: {
materials: packageData.materials.map(material => ({
materials: editableMaterials.map(material => ({
name: material.name,
quantity: material.quantity,
unit: material.unit,
@@ -152,7 +354,7 @@ const FinalReview = ({
total: material.quantity * material.unitPrice,
category: material.category
})),
laborTasks: packageData.laborTasks.map(task => ({
laborTasks: editableLaborTasks.map(task => ({
name: task.name,
description: task.description,
area: task.area,
@@ -193,6 +395,11 @@ const FinalReview = ({
const result = await response.json();
if (result.success) {
try {
await persistProjectStatus(PROJECT_STATUS.SENT_TO_ORDRESTYRING);
} catch (statusError) {
console.warn('Could not persist sent status:', statusError);
}
setSubmitResult({
success: true,
offerNumber: result.offerNumber, // ✅ Now returns real offer number from GraphQL!
@@ -222,6 +429,19 @@ const FinalReview = ({
}
};
const handleMarkReadyForOrdrestyring = async () => {
setIsSavingReadyStatus(true);
try {
await persistProjectStatus(PROJECT_STATUS.READY_FOR_ORDRESTYRING);
notify.success('Projektet er markeret som klar til Ordrestyring');
} catch (error) {
notify.error(getErrorMessage(error, 'Kunne ikke gemme status'));
} finally {
setIsSavingReadyStatus(false);
}
};
const calculateRoofArea = (width, length, pitch) => {
const pitchRad = (pitch * Math.PI) / 180;
const roofSlope = width / (2 * Math.cos(pitchRad));
@@ -229,61 +449,64 @@ const FinalReview = ({
};
// Generer tilbudstekst (statisk eller AI)
const handleGenerateQuote = async (type = 'static') => {
const handleGenerateQuote = React.useCallback(async (type = 'static') => {
setIsGeneratingQuote(true);
const quoteData = {
project: {
id: project.id,
name: project.project_name || project.name,
customer_name: project.customer_name,
customer_number: project.customer_number,
customer_address: project.customer_address,
description: editableProjectDescription
},
geometry: normalizedGeometry,
package: {
...packageData,
recommendedDescription: editableProjectDescription,
materials: editableMaterials,
laborTasks: editableLaborTasks
},
// Include ALL detailed materials
materials: editableMaterials.map(m => ({
material_name: m.name,
name: m.name,
quantity: parseFloat(m.quantity) || 0,
unit: m.unit,
unit_price: parseFloat(m.unitPrice) || 0,
unitPrice: parseFloat(m.unitPrice) || 0,
total_price: (parseFloat(m.quantity) || 0) * (parseFloat(m.unitPrice) || 0),
category: m.category
})),
// Include ALL detailed labor items
labor: editableLaborTasks.map(item => ({
task_name: item.name,
name: item.name,
description: item.description,
estimated_hours: parseFloat(item.totalHours) || 0,
totalHours: parseFloat(item.totalHours) || 0,
hourly_rate: parseFloat(item.rate) || 0,
rate: parseFloat(item.rate) || 0,
total_cost: (parseFloat(item.totalHours) || 0) * (parseFloat(item.rate) || 0)
})),
totals: {
materials: materialTotal,
labor: laborTotal,
subtotal: subtotal,
tax: taxAmount,
total: grandTotal,
totalHours: editableLaborTasks.reduce((sum, item) => sum + (parseFloat(item.totalHours) || 0), 0),
hourlyRate: editableLaborTasks.length > 0 ? parseFloat(editableLaborTasks[0].rate) || 580 : 580
}
};
try {
const quoteData = {
project: {
id: project.id,
name: project.project_name || project.name,
customer_name: project.customer_name,
description: project.description
},
geometry: normalizedGeometry,
package: packageData,
// Include ALL detailed materials
materials: editableMaterials.map(m => ({
material_name: m.name,
name: m.name,
quantity: parseFloat(m.quantity) || 0,
unit: m.unit,
unit_price: parseFloat(m.unitPrice) || 0,
unitPrice: parseFloat(m.unitPrice) || 0,
total_price: (parseFloat(m.quantity) || 0) * (parseFloat(m.unitPrice) || 0),
category: m.category
})),
// Include ALL detailed labor items
labor: editableLaborTasks.map(item => ({
task_name: item.name,
name: item.name,
description: item.description,
estimated_hours: parseFloat(item.totalHours) || 0,
totalHours: parseFloat(item.totalHours) || 0,
hourly_rate: parseFloat(item.rate) || 0,
rate: parseFloat(item.rate) || 0,
total_cost: (parseFloat(item.totalHours) || 0) * (parseFloat(item.rate) || 0)
})),
totals: {
materials: materialTotal,
labor: laborTotal,
subtotal: subtotal,
tax: taxAmount,
total: grandTotal,
totalHours: editableLaborTasks.reduce((sum, item) => sum + (parseFloat(item.totalHours) || 0), 0),
hourlyRate: editableLaborTasks.length > 0 ? parseFloat(editableLaborTasks[0].rate) || 580 : 580
}
};
const endpoint = type === 'ai'
? '/api/quotes/generate-ai'
: '/api/quotes/generate-static';
const response = await fetch(`${apiBaseUrl}${endpoint}`, {
const response = await fetch(`${apiBaseUrl}/api/customer-projects/projects/${project.id}/generate-quote-draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: project.id,
mode: type,
quoteData: quoteData,
suggestionData: orderSuggestions,
customInstructions: aiInstructions
})
});
@@ -296,44 +519,29 @@ const FinalReview = ({
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || `Server fejl: ${response.status}`);
throw new Error(getErrorMessage(result, `Server fejl: ${response.status}`));
}
// Handle async polling pattern (AI jobs take longer than Nginx 30s timeout)
if (result.polling && result.jobId) {
const jobId = result.jobId;
let attempts = 0;
const maxAttempts = 80; // 80 × 3s = 4 minutes max
while (attempts < maxAttempts) {
await new Promise(r => setTimeout(r, 3000));
attempts++;
const pollRes = await fetch(`${apiBaseUrl}/api/quotes/status/${jobId}`);
if (!pollRes.ok) continue;
const pollData = await pollRes.json();
if (pollData.status === 'done') {
setQuoteText(pollData.quoteText);
setShowQuoteEditor(true);
break;
} else if (pollData.status === 'error') {
throw new Error(pollData.error || 'AI generering fejlede');
}
}
if (attempts >= maxAttempts) {
throw new Error('AI generering tog for lang tid. Prøv igen.');
}
} else if (result.success) {
if (result.success) {
setQuoteText(result.quoteText);
setShowQuoteEditor(true);
} else {
notify.error('Fejl ved generering af tilbudstekst: ' + (result.error || 'Ukendt fejl'));
throw new Error(getErrorMessage(result, 'Ukendt fejl'));
}
} catch (error) {
console.error('Error generating quote:', error);
notify.error('Fejl ved generering af tilbudstekst: ' + error.message);
if (type === 'static') {
const fallbackQuote = buildLocalStaticQuote(quoteData);
setQuoteText(fallbackQuote);
setShowQuoteEditor(true);
notify.warning(`Backend-fejl ved standard tekst. Viser lokal kladde i stedet: ${getErrorMessage(error)}`);
} else {
notify.error('Fejl ved generering af tilbudstekst: ' + getErrorMessage(error));
}
} finally {
setIsGeneratingQuote(false);
}
};
}, [apiBaseUrl, project?.id, project?.project_name, project?.name, project?.customer_name, project?.customer_number, project?.customer_address, editableProjectDescription, normalizedGeometry, packageData, editableMaterials, editableLaborTasks, materialTotal, laborTotal, subtotal, taxAmount, grandTotal, orderSuggestions, aiInstructions, getErrorMessage, buildLocalStaticQuote, notify]);
// Generer og download PDF
const handleGeneratePDF = async () => {
@@ -347,7 +555,7 @@ const FinalReview = ({
name: project.name,
project_name: project.name,
customer_name: project.customer_name,
description: project.description
description: editableProjectDescription
},
geometry: {
width: normalizedGeometry.width,
@@ -504,7 +712,32 @@ const FinalReview = ({
<div className="final-review">
<div className="review-header">
<h2>🎯 Final Review - Tilbud Oversigt</h2>
<p>Gennemgå alle detaljer før indsendelse til ordrestyring</p>
<p>Gennemgå, finpuds og gem tilbuddet før du eventuelt sender det til Ordrestyring</p>
<div style={{display: 'flex', gap: '10px', flexWrap: 'wrap', marginTop: '12px'}}>
<span style={{
background: projectStatusMeta.color,
color: '#fff',
padding: '6px 12px',
borderRadius: '999px',
fontSize: '13px',
fontWeight: 600
}}>
Status: {projectStatusMeta.text}
</span>
{primaryMaterialSource && (
<span style={{
background: '#ecfeff',
color: '#155e75',
border: '1px solid #a5f3fc',
padding: '6px 12px',
borderRadius: '999px',
fontSize: '13px',
fontWeight: 600
}}>
{primaryMaterialSource.statusLabel}{formattedPriceImportDate ? ` · ${formattedPriceImportDate}` : ''}
</span>
)}
</div>
</div>
<div className="review-sections">
@@ -514,12 +747,119 @@ const FinalReview = ({
<div className="info-grid">
<div className="info-item">
<label>Projekt:</label>
<span>{project?.name || 'Ikke defineret'}</span>
<span>{project?.project_name || project?.name || 'Ikke defineret'}</span>
</div>
<div className="info-item">
<label>Kunde:</label>
<span>{project?.customer_name || 'Ikke defineret'}</span>
</div>
<div className="info-item">
<label>Kundenr.:</label>
<span>{project?.customer_number || 'Ikke koblet til Ordrestyring kunde endnu'}</span>
</div>
<div className="info-item" style={{gridColumn: '1 / -1'}}>
<label>Ordrestyring erfaring:</label>
<div style={{
border: '1px solid #cbd5e1',
borderRadius: '8px',
padding: '12px',
background: '#f8fafc'
}}>
{isLoadingOrderSuggestions && (
<div>Henter lokale Ordrestyring forslag...</div>
)}
{!isLoadingOrderSuggestions && orderSuggestionError && (
<div style={{color: '#b91c1c'}}>{orderSuggestionError}</div>
)}
{!isLoadingOrderSuggestions && !orderSuggestionError && orderSuggestions?.recommendation && (
<>
<div style={{marginBottom: '10px'}}>
<strong>{orderSuggestions.recommendation.summary}</strong>
</div>
<div style={{display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '10px', fontSize: '13px'}}>
<span>Confidence: <strong>{orderSuggestions.recommendation.confidence}</strong></span>
<span>Historiske timer: <strong>{orderSuggestions.recommendation.averageHistoricalHours || 0}</strong></span>
<span>Matches: <strong>{orderSuggestions.matches?.length || 0}</strong></span>
</div>
{orderSuggestions.recommendation.recommendedPackage && (
<div style={{marginBottom: '10px'}}>
<div style={{fontSize: '13px', color: '#475569', marginBottom: '6px'}}>Anbefalet Smart Pakke</div>
<strong>{orderSuggestions.recommendation.recommendedPackage.name}</strong>
{orderSuggestions.recommendation.recommendedPackage.reason && (
<div style={{fontSize: '13px', color: '#64748b', marginTop: '4px'}}>
{orderSuggestions.recommendation.recommendedPackage.reason}
</div>
)}
</div>
)}
{(orderSuggestions.materialsDraft?.length > 0 || orderSuggestions.laborDraft?.length > 0) && (
<div style={{display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '10px', fontSize: '13px'}}>
<span>Materialer klar: <strong>{orderSuggestions.materialsDraft?.length || 0}</strong></span>
<span>Opgaver klar: <strong>{orderSuggestions.laborDraft?.length || 0}</strong></span>
</div>
)}
{orderSuggestions.recommendation.suggestedDescription && (
<div style={{marginBottom: '10px'}}>
<div style={{fontSize: '13px', color: '#475569', marginBottom: '6px'}}>Foreslået kundebeskrivelse</div>
<div style={{whiteSpace: 'pre-wrap', lineHeight: '1.5'}}>{orderSuggestions.recommendation.suggestedDescription}</div>
</div>
)}
<div style={{display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: orderSuggestions.matches?.length ? '10px' : '0'}}>
<button
type="button"
className="action-btn secondary"
onClick={applySuggestedSolution}
>
Brug anbefalet løsning
</button>
<button
type="button"
className="action-btn secondary"
onClick={() => setEditableProjectDescription(orderSuggestions.recommendation.suggestedDescription || '')}
>
Brug foreslået beskrivelse
</button>
</div>
{orderSuggestions.matches?.length > 0 && (
<div style={{display: 'grid', gap: '8px'}}>
{orderSuggestions.matches.slice(0, 3).map((match) => (
<div
key={match.caseNumber}
style={{
borderTop: '1px solid #e2e8f0',
paddingTop: '8px',
fontSize: '13px'
}}
>
<strong>Sag {match.caseNumber}</strong> · {match.customerName || 'Ukendt kunde'} · {match.totalHours || 0} timer
<div style={{marginTop: '4px'}}>{match.description || 'Ingen beskrivelse'}</div>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
<div className="info-item" style={{gridColumn: '1 / -1'}}>
<label>Beskrivelse til kunden:</label>
<textarea
value={editableProjectDescription}
onChange={(e) => setEditableProjectDescription(e.target.value)}
style={{
width: '100%',
minHeight: '110px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
padding: '12px',
fontSize: '14px',
lineHeight: '1.5',
resize: 'vertical'
}}
/>
</div>
<div className="info-item">
<label>Beskrivelse:</label>
<span>{project?.description || 'Ingen beskrivelse'}</span>
@@ -867,11 +1207,10 @@ const FinalReview = ({
{/* Ordrestyring Integration */}
<div className="review-section ordering-section">
<h3>🚀 Send til Ordrestyring</h3>
<h3>🚀 Ordrestyring Status</h3>
<div className="ordering-info">
<p>
Når du klikker "Send til Ordrestyring", bliver tilbuddet automatisk overført til ordrestyring systemet
hvor det kan spores og administreres.
Du kan gemme tilbuddet som klart til Ordrestyring uden at sende det endnu. Send først, når tilbudet er helt klar.
</p>
<button
@@ -883,7 +1222,7 @@ const FinalReview = ({
{showOrderingDetails && (
<div className="ordering-details">
<h4>Data som sendes til ordrestyring:</h4>
<h4>Data som sendes til Ordrestyring:</h4>
<ul>
<li>Projekt information og kunde data</li>
<li>Geometri mål og beregninger</li>
@@ -923,6 +1262,14 @@ const FinalReview = ({
>
Tilbage til Smart Pakke
</button>
<button
onClick={handleMarkReadyForOrdrestyring}
className="generate-quote-btn"
disabled={isSubmitting || isSavingReadyStatus || !packageData?.materials?.length || !packageData?.laborTasks?.length}
>
{isSavingReadyStatus ? 'Gemmer status...' : '💾 Klar til Ordrestyring'}
</button>
<button
onClick={handleSubmitToOrdering}
@@ -937,4 +1284,4 @@ const FinalReview = ({
);
};
export default FinalReview;
export default FinalReview;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -631,7 +631,7 @@ const MaterialsManager = ({ apiBaseUrl, project, geometry, existingMaterials, on
'Content-Type': 'application/json',
},
body: JSON.stringify({
project_status: 'calculation_ready'
project_status: 'smart_package_complete'
})
});
+24 -11
View File
@@ -4,6 +4,7 @@ import FormField from './FormField';
import LoadingSpinner from './LoadingSpinner';
import AutosaveIndicator from './AutosaveIndicator';
import useAutosave from '../hooks/useAutosave';
import { getProjectStatusMeta } from '../utils/projectStatus';
import './FormField.css';
import './LoadingSpinner.css';
import './AutosaveIndicator.css';
@@ -16,6 +17,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
const [newProjectForm, setNewProjectForm] = useState({
projectName: '',
customerName: '',
customerNumber: '',
customerEmail: '',
customerPhone: '',
projectAddress: '',
@@ -26,6 +28,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
const [editingForm, setEditingForm] = useState({
projectName: '',
customerName: '',
customerNumber: '',
customerEmail: '',
customerPhone: '',
projectAddress: '',
@@ -149,6 +152,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
const customerData = {
customerName: customer.name,
customerNumber: customer.customerNumber || '',
customerEmail: customer.email || '',
customerPhone: customer.phone || customer.mobile || '',
projectAddress: customer.address ?
@@ -178,6 +182,21 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
} else {
// Customer cleared
setSelectedCustomer(null);
setFormData(prev => ({
...prev,
customerNumber: ''
}));
if (activeTab === 'editing' && editingProject) {
setEditingForm(prev => ({
...prev,
customerNumber: ''
}));
} else if (activeTab === 'new') {
setNewProjectForm(prev => ({
...prev,
customerNumber: ''
}));
}
}
};
@@ -273,6 +292,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
const emptyForm = {
projectName: '',
customerName: '',
customerNumber: '',
customerEmail: '',
customerPhone: '',
projectAddress: '',
@@ -306,17 +326,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
};
const getStatusBadge = (status) => {
const statusMap = {
'draft': { text: 'Kladde', color: '#6b7280' },
'created': { text: 'Oprettet', color: '#3b82f6' },
'geometry_complete': { text: 'Geometri færdig', color: '#10b981' },
'labor_complete': { text: 'Timer færdig', color: '#f59e0b' },
'materials_complete': { text: 'Materialer færdig', color: '#8b5cf6' },
'calculated': { text: 'Beregnet', color: '#ef4444' },
'quote_generated': { text: 'Tilbud klar', color: '#059669' }
};
const statusInfo = statusMap[status] || { text: status, color: '#6b7280' };
const statusInfo = getProjectStatusMeta(status);
return (
<span
@@ -359,6 +369,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
const projectData = {
projectName: project.project_name,
customerName: project.customer_name,
customerNumber: project.customer_number || '',
customerEmail: project.customer_email || '',
customerPhone: project.customer_phone || '',
projectAddress: project.customer_address || project.project_address || '',
@@ -412,6 +423,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
const emptyForm = {
projectName: '',
customerName: '',
customerNumber: '',
customerEmail: '',
customerPhone: '',
projectAddress: '',
@@ -704,6 +716,7 @@ const ProjectCreation = ({ apiBaseUrl, onProjectCreated, existingProjects, onSel
const emptyForm = {
projectName: '',
customerName: '',
customerNumber: '',
customerEmail: '',
customerPhone: '',
projectAddress: '',
+67 -70
View File
@@ -5,11 +5,8 @@ import EnhancedGeometry from './EnhancedGeometry';
import InlineSmartPackage from './InlineSmartPackage';
import FinalReview from './FinalReview';
import AdvancedDashboard from './AdvancedDashboard';
import GeometryInput from './GeometryInput';
import LaborInput from './LaborInput';
import MaterialsManager from './MaterialsManager';
import CalculationView from './CalculationView';
import { useNotification } from '../hooks/useNotification';
import { getProjectStepFromStatus } from '../utils/projectStatus';
const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
const notify = useNotification();
@@ -23,8 +20,7 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
const [calculation, setCalculation] = useState(null);
const [selectedPackages, setSelectedPackages] = useState([]);
const [projects, setProjects] = useState([]);
// eslint-disable-next-line no-unused-vars
const [selectedProjectId, setSelectedProjectId] = useState(null);
const [, setSelectedProjectId] = useState(null);
const [isLoadingProjectData, setIsLoadingProjectData] = useState(false);
// Session storage key for persistence
@@ -78,6 +74,17 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
{ id: 4, name: 'Final Review', icon: '🎯', description: 'Gennemgang & Tilbud' }
];
const persistedStep = getProjectStepFromStatus(project?.project_status);
const completedStepCount = Math.max(
[
!!project ? 1 : 0,
!!(geometry || enhancedGeometry) ? 2 : 0,
!!packageData ? 3 : 0,
!!(project && enhancedGeometry && packageData) ? 4 : 0
].reduce((maxStep, currentValue) => Math.max(maxStep, currentValue), 0),
persistedStep
);
// Load existing projects on mount
useEffect(() => {
const loadProjectsAsync = async () => {
@@ -139,12 +146,8 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
// After loading data, determine which step to start at
// Priority: If we have geometry, allow access to Smart Pakke (step 3)
const status = projectData.project.project_status;
if (status === 'draft' || status === 'created') {
setCurrentStep(2); // Go to geometry for new projects
} else {
// For existing projects, start at geometry but user can click to Smart Pakke
setCurrentStep(2);
}
const nextStep = getProjectStepFromStatus(status);
setCurrentStep(nextStep === 1 ? 2 : nextStep);
}
} catch (error) {
console.error('Error selecting project:', error);
@@ -388,44 +391,6 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
console.log('🔄 Switched to step 2 (Geometry) with project:', newProject.id);
};
// eslint-disable-next-line no-unused-vars
const handleGeometryComplete = (geometryData) => {
setGeometry(geometryData);
setCurrentStep(3);
};
// eslint-disable-next-line no-unused-vars
const handleLaborComplete = (laborData) => {
setLabor(laborData);
if (project?.id) {
saveToSession(project.id, 'labor', laborData);
}
setCurrentStep(5); // Go to Materials step after Labor is complete
};
// eslint-disable-next-line no-unused-vars
const handleMaterialsUpdate = (materialsData) => {
setMaterials(materialsData);
if (project?.id) {
saveToSession(project.id, 'materials', materialsData);
}
// Don't change step - just update the materials state
};
// eslint-disable-next-line no-unused-vars
const handleMaterialsComplete = (materialsData) => {
setMaterials(materialsData);
if (project?.id) {
saveToSession(project.id, 'materials', materialsData);
}
setCurrentStep(6); // Go to Calculation step after Materials is complete
};
// eslint-disable-next-line no-unused-vars
const handleCalculationComplete = (calculationData) => {
setCalculation(calculationData);
};
// Handle back from Final Review with data saving
const handleBackFromFinalReview = (editedData) => {
console.log('💾 Saving edited data from Final Review...', editedData);
@@ -434,7 +399,8 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
const updatedPackageData = {
...packageData,
materials: editedData.materials || packageData?.materials || [],
laborTasks: editedData.laborTasks || packageData?.laborTasks || []
laborTasks: editedData.laborTasks || packageData?.laborTasks || [],
recommendedDescription: editedData.recommendedDescription ?? packageData?.recommendedDescription ?? ''
};
saveToSession(project.id, 'packageData', updatedPackageData);
@@ -481,13 +447,6 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
}
}, [currentStep, project, geometry, labor, materials, calculation]);
// eslint-disable-next-line no-unused-vars
const goToStep = (stepNumber) => {
if (stepNumber <= currentStep) {
setCurrentStep(stepNumber);
}
};
const resetFlow = () => {
setCurrentStep(1);
setProject(null);
@@ -498,13 +457,34 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
setSelectedProjectId(null);
};
// eslint-disable-next-line no-unused-vars
const canAccessStep = (stepNumber) => {
return stepNumber <= currentStep;
};
return (
<div className="project-flow">
{!project && (
<section className="project-flow-hero">
<div className="project-flow-hero-copy">
<p className="project-flow-eyebrow">Projektflow</p>
<h1>Start i et samlet tilbudsflow</h1>
<p className="project-flow-lead">
Opret eller vælg et projekt for at låse geometri, smart pakker og final review op i den rigtige rækkefølge.
</p>
</div>
<div className="project-flow-hero-stats">
<div className="project-flow-stat">
<strong>{projects.length}</strong>
<span>Projekter klar</span>
</div>
<div className="project-flow-stat">
<strong>{steps.length}</strong>
<span>Faste trin</span>
</div>
<div className="project-flow-stat">
<strong>{isLoadingProjectData ? 'Live' : 'Klar'}</strong>
<span>Status</span>
</div>
</div>
</section>
)}
{/* Header with project info */}
{project && (
<div className="project-header">
@@ -516,6 +496,15 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
<p>👤 Kunde: {project.customer_name}</p>
<p>📝 {project.project_description}</p>
</div>
<div className="project-header-meta">
<div className="project-progress-pill">
<span>Flow status</span>
<strong>{completedStepCount}/{steps.length} trin klar</strong>
</div>
<div className="project-progress-note">
{isLoadingProjectData ? 'Synkroniserer projektdata...' : 'Projektdata er indlæst'}
</div>
</div>
<button className="reset-btn" onClick={resetFlow}>
🔄 Nyt Projekt
</button>
@@ -526,7 +515,11 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
{isLoadingProjectData && (
<div className="loading-overlay">
<div className="loading-message">
🔄 Indlæser projektdata...
<div className="loading-spinner" />
<div>
<strong>Indlæser projektdata</strong>
<p>Geometri, materialer og pakker bliver synkroniseret.</p>
</div>
</div>
</div>
)}
@@ -668,12 +661,16 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
onBackWithData={handleBackFromFinalReview}
/>
) : (
<div style={{padding: '50px', textAlign: 'center'}}>
<h3> Debug: Manglende data for Final Review</h3>
<p>Project: {project ? '✅' : '❌'}</p>
<p>Enhanced Geometry: {enhancedGeometry ? '✅' : '❌'}</p>
<p>Package Data: {packageData ? '✅' : '❌'}</p>
<button onClick={() => setCurrentStep(3)} style={{padding: '10px 20px', marginTop: '20px'}}>
<div className="flow-warning-card">
<div className="flow-warning-icon"></div>
<h3>Final Review mangler stadig data</h3>
<p>Du skal have projekt, geometri og smart pakke klar før tilbuddet kan gennemgås.</p>
<div className="flow-warning-checklist">
<span>{project ? '✅' : ''} Projekt</span>
<span>{enhancedGeometry ? '✅' : '❌'} Geometri</span>
<span>{packageData ? '✅' : '❌'} Smart pakke</span>
</div>
<button className="flow-warning-btn" onClick={() => setCurrentStep(3)}>
tilbage til Smart Pakke
</button>
</div>
+85
View File
@@ -0,0 +1,85 @@
export const PROJECT_STATUS = {
DRAFT: 'draft',
GEOMETRY_COMPLETE: 'geometry_complete',
SMART_PACKAGE_COMPLETE: 'smart_package_complete',
REVIEW_PENDING: 'review_pending',
READY_FOR_ORDRESTYRING: 'ready_for_ordrestyring',
SENT_TO_ORDRESTYRING: 'sent_to_ordrestyring',
ACCEPTED: 'accepted',
REJECTED: 'rejected',
GEOMETRY_PENDING: 'geometry_pending',
LABOR_PENDING: 'labor_pending',
MATERIALS_PENDING: 'materials_pending',
CALCULATION_READY: 'calculation_ready',
QUOTE_GENERATED: 'quote_generated',
SENT: 'sent'
};
const STATUS_META = {
[PROJECT_STATUS.DRAFT]: { text: 'Kladde', color: '#6b7280' },
[PROJECT_STATUS.GEOMETRY_COMPLETE]: { text: 'Geometri klar', color: '#2563eb' },
[PROJECT_STATUS.SMART_PACKAGE_COMPLETE]: { text: 'Smart Pakke klar', color: '#7c3aed' },
[PROJECT_STATUS.REVIEW_PENDING]: { text: 'Review', color: '#f59e0b' },
[PROJECT_STATUS.READY_FOR_ORDRESTYRING]: { text: 'Klar til Ordrestyring', color: '#0f766e' },
[PROJECT_STATUS.SENT_TO_ORDRESTYRING]: { text: 'Afsendt til Ordrestyring', color: '#059669' },
[PROJECT_STATUS.ACCEPTED]: { text: 'Accepteret', color: '#16a34a' },
[PROJECT_STATUS.REJECTED]: { text: 'Afvist', color: '#dc2626' },
[PROJECT_STATUS.GEOMETRY_PENDING]: { text: 'Manglende geometri', color: '#6b7280' },
[PROJECT_STATUS.LABOR_PENDING]: { text: 'Manglende timer', color: '#6b7280' },
[PROJECT_STATUS.MATERIALS_PENDING]: { text: 'Manglende materialer', color: '#6b7280' },
[PROJECT_STATUS.CALCULATION_READY]: { text: 'Beregnet', color: '#7c3aed' },
[PROJECT_STATUS.QUOTE_GENERATED]: { text: 'Tilbud klar', color: '#f59e0b' },
[PROJECT_STATUS.SENT]: { text: 'Sendt', color: '#059669' }
};
export function getProjectStatusMeta(status) {
return STATUS_META[status] || { text: status || 'Ukendt', color: '#6b7280' };
}
export function getProjectStepFromStatus(status) {
if (
[
PROJECT_STATUS.REVIEW_PENDING,
PROJECT_STATUS.READY_FOR_ORDRESTYRING,
PROJECT_STATUS.SENT_TO_ORDRESTYRING,
PROJECT_STATUS.ACCEPTED,
PROJECT_STATUS.REJECTED,
PROJECT_STATUS.QUOTE_GENERATED,
PROJECT_STATUS.SENT
].includes(status)
) {
return 4;
}
if (
[
PROJECT_STATUS.SMART_PACKAGE_COMPLETE,
PROJECT_STATUS.CALCULATION_READY,
PROJECT_STATUS.MATERIALS_PENDING
].includes(status)
) {
return 3;
}
if (
[
PROJECT_STATUS.GEOMETRY_COMPLETE,
PROJECT_STATUS.GEOMETRY_PENDING,
PROJECT_STATUS.LABOR_PENDING
].includes(status)
) {
return 2;
}
return 1;
}
export function isFinalizedProjectStatus(status) {
return [
PROJECT_STATUS.READY_FOR_ORDRESTYRING,
PROJECT_STATUS.SENT_TO_ORDRESTYRING,
PROJECT_STATUS.ACCEPTED,
PROJECT_STATUS.REJECTED,
PROJECT_STATUS.SENT
].includes(status);
}
+190
View File
@@ -0,0 +1,190 @@
const { chromium } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:4132';
const ARTIFACTS_DIR = path.join(__dirname, 'artifacts', 'status-flow');
const LOGIN_USERNAME = 'toemrer';
const LOGIN_PASSWORD = 'tilbud2024';
async function ensureArtifactsDir() {
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
}
async function login(page) {
await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 30000 });
const onLogin = await page.getByRole('heading', { name: /Log ind/i }).isVisible({ timeout: 5000 }).catch(() => false);
if (!onLogin) {
return;
}
await page.locator('input[type="text"]').first().fill(LOGIN_USERNAME);
await page.locator('input[type="password"]').first().fill(LOGIN_PASSWORD);
await page.getByRole('button', { name: /Log ind/i }).click();
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(1500);
}
async function getProjectSnapshot(page, projectName) {
return await page.evaluate(async ({ projectName }) => {
const token = localStorage.getItem('accessToken');
const response = await fetch('/api/customer-projects/projects', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await response.json();
const project = (data.projects || []).find((item) => item.project_name === projectName);
return project ? {
id: project.id,
status: project.project_status,
customer_name: project.customer_name
} : null;
}, { projectName });
}
async function waitForProjectSnapshot(page, projectName, maxAttempts = 10) {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const snapshot = await getProjectSnapshot(page, projectName);
if (snapshot) {
return snapshot;
}
await page.waitForTimeout(1000);
}
return null;
}
async function fillGeometry(page) {
const widthInput = page.locator('input[placeholder="10"]').first();
const lengthInput = page.locator('input[placeholder="15"]').first();
await widthInput.waitFor({ state: 'visible', timeout: 10000 });
await widthInput.fill('10');
await lengthInput.fill('12');
const saveBtn = page.getByRole('button', { name: /Gem Nu|Gem Geometri/i }).first();
await saveBtn.click();
await page.waitForTimeout(6000);
}
async function completeSmartPackage(page) {
const continueBtn = page.getByRole('button', { name: /Fortsæt til Smart Pakke/i }).first();
if (await continueBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
await continueBtn.click();
} else {
await page.locator('.step').filter({ hasText: /Smart Pakke/i }).first().click();
}
await page.waitForTimeout(3000);
const packageOptions = page.locator('.package-option');
const packageCount = await packageOptions.count();
if (packageCount === 0) {
throw new Error('Ingen smart pakker fundet i UI');
}
await packageOptions.first().click();
await page.waitForTimeout(2500);
const saveSmartPackageBtn = page.getByRole('button', { name: /Gem Smart Pakke/i }).first();
if (await saveSmartPackageBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
await saveSmartPackageBtn.click();
await page.waitForTimeout(1500);
}
}
async function goToFinalReview(page) {
const nextBtn = page.getByRole('button', { name: /Næste.*Final Review/i }).first();
if (await nextBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
await nextBtn.click();
} else {
await page.locator('.step').filter({ hasText: /Final Review/i }).first().click();
}
await page.waitForTimeout(5000);
}
async function main() {
await ensureArtifactsDir();
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1400 } });
const projectName = `Status Flow ${Date.now()}`;
try {
await login(page);
console.log('STEP login', JSON.stringify({ url: page.url() }));
const newProjectBtn = page.getByRole('button', { name: /Nyt Projekt/i });
if (await newProjectBtn.isVisible({ timeout: 10000 }).catch(() => false)) {
await newProjectBtn.click();
await page.waitForTimeout(500);
}
await page.locator('#field-projectName').fill(projectName);
await page.locator('#field-customerName').fill('Status Flow Testkunde');
await page.locator('#field-customerEmail').fill('[email protected]');
await page.locator('#field-projectDescription').fill('Test af persisted statusflow for tømrer med tagmål og smart pakke.');
await page.getByRole('button', { name: /Gem Projekt/i }).click();
await page.waitForTimeout(4000);
await page.screenshot({ path: path.join(ARTIFACTS_DIR, '01-after-create.png'), fullPage: true });
let snapshot = await waitForProjectSnapshot(page, projectName);
console.log('STEP after_create', JSON.stringify(snapshot));
await fillGeometry(page);
await page.screenshot({ path: path.join(ARTIFACTS_DIR, '02-after-geometry.png'), fullPage: true });
snapshot = await waitForProjectSnapshot(page, projectName);
console.log('STEP after_geometry', JSON.stringify(snapshot));
await completeSmartPackage(page);
await page.screenshot({ path: path.join(ARTIFACTS_DIR, '03-after-smart-package.png'), fullPage: true });
snapshot = await waitForProjectSnapshot(page, projectName);
console.log('STEP after_smart_package', JSON.stringify(snapshot));
await goToFinalReview(page);
await page.screenshot({ path: path.join(ARTIFACTS_DIR, '04-final-review.png'), fullPage: true });
snapshot = await waitForProjectSnapshot(page, projectName);
console.log('STEP after_review', JSON.stringify({
snapshot,
hasReadyButton: await page.getByRole('button', { name: /Klar til Ordrestyring/i }).isVisible().catch(() => false),
hasSendButton: await page.getByRole('button', { name: /Send til Ordrestyring/i }).isVisible().catch(() => false)
}));
const readyBtn = page.getByRole('button', { name: /Klar til Ordrestyring/i }).first();
if (await readyBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
await readyBtn.click();
await page.waitForTimeout(3000);
}
await page.screenshot({ path: path.join(ARTIFACTS_DIR, '05-after-ready.png'), fullPage: true });
snapshot = await waitForProjectSnapshot(page, projectName);
console.log('STEP after_ready', JSON.stringify(snapshot));
const sendBtn = page.getByRole('button', { name: /Send til Ordrestyring/i }).first();
let sendResult = 'button_not_found';
if (await sendBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
await sendBtn.click();
await page.waitForTimeout(15000);
const bodyText = await page.locator('body').textContent();
if (bodyText.includes('Tilbud sendt til ordrestyring')) {
sendResult = 'success';
} else if (bodyText.includes('Fejl ved indsendelse')) {
sendResult = 'error';
} else {
sendResult = 'unknown';
}
}
await page.screenshot({ path: path.join(ARTIFACTS_DIR, '06-after-send.png'), fullPage: true });
snapshot = await waitForProjectSnapshot(page, projectName);
console.log('STEP after_send', JSON.stringify({ snapshot, sendResult }));
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error('STATUS_FLOW_ERROR', error);
process.exit(1);
});