fix: strengthen tilbudsgivern flow validation and automation
- tighten carpenter AI e2e success criteria to require real ordrestyring submission - improve login field usability (labels/placeholders/autocomplete) - add final review gating for missing core quote data - normalize window task naming in carpenter API fixtures
This commit is contained in:
@@ -119,10 +119,10 @@ const projectTemplates = {
|
||||
category: '🪟 Vinduer',
|
||||
projects: [
|
||||
{
|
||||
description: 'Installation af håndværkerspil i alle vinduerne gennem huset. I alt 12 vinduer á 1.2x1.0m',
|
||||
description: 'Montering og justering af vindueskarme/lister i hele huset. I alt 12 vinduer á 1.2x1.0m',
|
||||
area: 15,
|
||||
type: 'spilinstallation',
|
||||
materials: ['Træ-spil', 'Lim', 'Skruer', 'Kraftig lak']
|
||||
type: 'vinduesmontering',
|
||||
materials: ['Vindueslister', 'Fugemasse', 'Skruer', 'Maling/lak']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,22 +15,47 @@
|
||||
* node carpenter-agent-openai.js --generate-quotes=5 [--save]
|
||||
*/
|
||||
|
||||
const OpenAI = require('openai');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
// Initialize OpenAI client
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY || ''
|
||||
});
|
||||
const CODEX_MODEL = process.env.CODEX_MODEL || 'gpt-5.4-mini';
|
||||
|
||||
// Check API key
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
console.error('❌ Error: OPENAI_API_KEY environment variable is not set!');
|
||||
console.error('\nSet it with:');
|
||||
console.error(' export OPENAI_API_KEY="sk-..."');
|
||||
process.exit(1);
|
||||
function runCodexPrompt(prompt) {
|
||||
const outputFile = path.join(
|
||||
os.tmpdir(),
|
||||
`codex-carpenter-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`
|
||||
);
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
'codex',
|
||||
[
|
||||
'exec',
|
||||
'-m',
|
||||
CODEX_MODEL,
|
||||
'--color',
|
||||
'never',
|
||||
'--output-last-message',
|
||||
outputFile,
|
||||
prompt
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024
|
||||
}
|
||||
);
|
||||
|
||||
return fs.readFileSync(outputFile, 'utf8').trim();
|
||||
} finally {
|
||||
if (fs.existsSync(outputFile)) {
|
||||
fs.unlinkSync(outputFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Carpenter personas with detailed backgrounds
|
||||
@@ -162,20 +187,15 @@ Hvordan kan jeg hjælpe dig i dag? Hvad for et projekt er du ved at planlægge?`
|
||||
try {
|
||||
console.log('\n⏳ Carpenter is thinking...');
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: getSystemPrompt(carpenter)
|
||||
},
|
||||
...conversationHistory
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000
|
||||
});
|
||||
|
||||
const assistantMessage = response.choices[0].message.content;
|
||||
const prompt = [
|
||||
getSystemPrompt(carpenter),
|
||||
'',
|
||||
'Samtalehistorik:',
|
||||
...conversationHistory.map(msg => `${msg.role.toUpperCase()}: ${msg.content}`),
|
||||
'',
|
||||
'Svar som assistent i samme samtale.'
|
||||
].join('\n');
|
||||
const assistantMessage = runCodexPrompt(prompt);
|
||||
conversationHistory.push({
|
||||
role: 'assistant',
|
||||
content: assistantMessage
|
||||
@@ -267,25 +287,14 @@ async function generateQuotesAI(count = 3, saveToDisk = true) {
|
||||
console.log('⏳ Generating...');
|
||||
|
||||
try {
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: getSystemPrompt(carpenter)
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: generateRandomProjectPrompt(carpenter)
|
||||
}
|
||||
];
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4',
|
||||
messages,
|
||||
temperature: 0.8,
|
||||
max_tokens: 1500
|
||||
});
|
||||
|
||||
const quoteText = response.choices[0].message.content;
|
||||
const quotePrompt = [
|
||||
getSystemPrompt(carpenter),
|
||||
'',
|
||||
`Kunde: ${generateRandomProjectPrompt(carpenter)}`,
|
||||
'',
|
||||
'Giv dit svar nu.'
|
||||
].join('\n');
|
||||
const quoteText = runCodexPrompt(quotePrompt);
|
||||
const quote = extractQuote(quoteText, carpenter) || {
|
||||
carpenter: {
|
||||
name: carpenter.name,
|
||||
@@ -323,6 +332,186 @@ async function generateQuotesAI(count = 3, saveToDisk = true) {
|
||||
return generatedQuotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Personality-specific feedback test prompts
|
||||
*/
|
||||
function getPersonalityFeedbackPrompts(carpenterId) {
|
||||
const prompts = {
|
||||
lars: [
|
||||
'Kunden spørger: Kan du give ærlig og kritisk feedback på min tagløsning og især opsætningen omkring skorstenen? Fokusér på fejl og brugervenlighed i vedligehold.',
|
||||
'Kunden spørger: Giv en kritisk gennemgang af opsætning og brugervenlighed for en ny træterrasse på 24 m² med lav vedligeholdelse.'
|
||||
],
|
||||
jannick: [
|
||||
'Kunden spørger: Vær kritisk på opsætning og daglig brugervenlighed i valget mellem parket og klikgulv i stue og gang med børn.',
|
||||
'Kunden spørger: Giv kritisk feedback på en garderobeløsning i et lille soveværelse med fokus på dårlig pladsudnyttelse og brugerfejl.'
|
||||
],
|
||||
alexander: [
|
||||
'Kunden spørger: Giv kritisk feedback på min opsætning af bæredygtige materialer i moderne renovering, især hvor løsningen er upraktisk i drift.',
|
||||
'Kunden spørger: Vær meget kritisk på opsætning og brugervenlighed ved nye dørinstallationer i et åbent køkken.'
|
||||
]
|
||||
};
|
||||
|
||||
return prompts[carpenterId] || prompts.lars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if response appears aligned with the carpenter personality
|
||||
*/
|
||||
function validatePersonalityFeedback(responseText, carpenter) {
|
||||
const text = (responseText || '').toLowerCase();
|
||||
const specialtyTokens = carpenter.specialties.map(s => s.toLowerCase());
|
||||
|
||||
const hasSpecialtySignal = specialtyTokens.some(token => text.includes(token));
|
||||
const hasConcreteGuidance = ['anbefal', 'foreslår', 'vigtigt', 'du bør', 'jeg vil', 'pris', 'tids'].some(token => text.includes(token));
|
||||
const hasCriticalTone = ['kritisk', 'problem', 'fejl', 'risiko', 'svaghed', 'mangel', 'ikke optimalt', 'bør undgå'].some(token => text.includes(token));
|
||||
const likelyDanish = ['jeg', 'du', 'og', 'med', 'til', 'er'].filter(token => text.includes(token)).length >= 3;
|
||||
|
||||
const checks = {
|
||||
hasSpecialtySignal,
|
||||
hasConcreteGuidance,
|
||||
hasCriticalTone,
|
||||
likelyDanish
|
||||
};
|
||||
|
||||
const passed = Object.values(checks).every(Boolean);
|
||||
return { passed, checks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run personality feedback tests and summarize outcomes
|
||||
*/
|
||||
async function testPersonalityFeedback(saveToDisk = true) {
|
||||
console.log(`\n${'='.repeat(80)}`);
|
||||
console.log('🧪 TESTING FEEDBACK BY CARPENTER PERSONALITY');
|
||||
console.log(`${'='.repeat(80)}\n`);
|
||||
|
||||
const testResults = [];
|
||||
|
||||
for (const [carpenterId, carpenter] of Object.entries(CARPENTER_PERSONAS)) {
|
||||
console.log(`\n🔨 Testing: ${carpenter.name} (${carpenter.specialty})`);
|
||||
const prompts = getPersonalityFeedbackPrompts(carpenterId);
|
||||
|
||||
for (let i = 0; i < prompts.length; i++) {
|
||||
const userPrompt = prompts[i];
|
||||
console.log(` 📝 Prompt ${i + 1}/${prompts.length}`);
|
||||
|
||||
try {
|
||||
const feedbackPrompt = [
|
||||
getSystemPrompt(carpenter),
|
||||
'',
|
||||
`${userPrompt}`,
|
||||
'Krav til svar:',
|
||||
'- Vær kritisk og peg tydeligt på svagheder i opsætning og brugervenlighed.',
|
||||
'- Nævn mindst 2 konkrete fejl/risici.',
|
||||
'- Giv derefter konkrete forbedringsforslag.',
|
||||
'- Svar i 5-8 sætninger uden quote-format.'
|
||||
].join('\n');
|
||||
const assistantText = runCodexPrompt(feedbackPrompt);
|
||||
const validation = validatePersonalityFeedback(assistantText, carpenter);
|
||||
|
||||
testResults.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
carpenterId,
|
||||
carpenterName: carpenter.name,
|
||||
specialty: carpenter.specialty,
|
||||
prompt: userPrompt,
|
||||
response: assistantText,
|
||||
validation
|
||||
});
|
||||
|
||||
console.log(` ${validation.passed ? '✅' : '⚠️'} Feedback test ${validation.passed ? 'passed' : 'needs review'}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
} catch (error) {
|
||||
console.error(` ❌ Failed: ${error.message}`);
|
||||
testResults.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
carpenterId,
|
||||
carpenterName: carpenter.name,
|
||||
specialty: carpenter.specialty,
|
||||
prompt: userPrompt,
|
||||
error: error.message,
|
||||
validation: { passed: false, checks: { apiCall: false } }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary = createFeedbackTestSummary(testResults);
|
||||
printFeedbackTestSummary(summary);
|
||||
|
||||
if (saveToDisk) {
|
||||
const outputDir = 'test-results/carpenter-openai-data';
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const summaryFile = path.join(outputDir, `personality-feedback-summary-${Date.now()}.json`);
|
||||
const detailsFile = path.join(outputDir, `personality-feedback-results-${Date.now()}.json`);
|
||||
|
||||
fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2));
|
||||
fs.writeFileSync(detailsFile, JSON.stringify(testResults, null, 2));
|
||||
|
||||
console.log(`\n💾 Summary saved: ${summaryFile}`);
|
||||
console.log(`💾 Detailed results saved: ${detailsFile}`);
|
||||
}
|
||||
|
||||
return { summary, testResults };
|
||||
}
|
||||
|
||||
function createFeedbackTestSummary(results) {
|
||||
const total = results.length;
|
||||
const passed = results.filter(r => r.validation && r.validation.passed).length;
|
||||
const failed = total - passed;
|
||||
|
||||
const byCarpenter = {};
|
||||
for (const result of results) {
|
||||
if (!byCarpenter[result.carpenterId]) {
|
||||
byCarpenter[result.carpenterId] = {
|
||||
carpenterName: result.carpenterName,
|
||||
specialty: result.specialty,
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0
|
||||
};
|
||||
}
|
||||
|
||||
byCarpenter[result.carpenterId].total += 1;
|
||||
if (result.validation && result.validation.passed) {
|
||||
byCarpenter[result.carpenterId].passed += 1;
|
||||
} else {
|
||||
byCarpenter[result.carpenterId].failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
totalTests: total,
|
||||
passed,
|
||||
failed,
|
||||
passRate: total > 0 ? `${((passed / total) * 100).toFixed(1)}%` : '0.0%',
|
||||
byCarpenter
|
||||
};
|
||||
}
|
||||
|
||||
function printFeedbackTestSummary(summary) {
|
||||
console.log(`\n${'='.repeat(80)}`);
|
||||
console.log('📊 PERSONALITY FEEDBACK TEST SUMMARY');
|
||||
console.log(`${'='.repeat(80)}`);
|
||||
console.log(`Total tests: ${summary.totalTests}`);
|
||||
console.log(`Passed: ${summary.passed}`);
|
||||
console.log(`Failed: ${summary.failed}`);
|
||||
console.log(`Pass rate: ${summary.passRate}`);
|
||||
console.log('\nPer carpenter:');
|
||||
|
||||
for (const [carpenterId, data] of Object.entries(summary.byCarpenter)) {
|
||||
console.log(`- ${data.carpenterName} (${carpenterId})`);
|
||||
console.log(` Specialty: ${data.specialty}`);
|
||||
console.log(` Passed/Total: ${data.passed}/${data.total}`);
|
||||
}
|
||||
|
||||
console.log(`${'='.repeat(80)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random project prompts for AI carpenter
|
||||
*/
|
||||
@@ -363,6 +552,7 @@ async function main() {
|
||||
const carpenterId = carpenterArg ? carpenterArg.split('=')[1] : 'lars';
|
||||
const generateQuotesArg = args.find(a => a.startsWith('--generate-quotes='));
|
||||
const quoteCount = generateQuotesArg ? parseInt(generateQuotesArg.split('=')[1]) : 0;
|
||||
const runFeedbackTests = args.includes('--test-personality-feedback');
|
||||
const shouldSave = args.includes('--save') || args.includes('-s');
|
||||
|
||||
try {
|
||||
@@ -397,6 +587,8 @@ async function main() {
|
||||
if (q.price) console.log(` Price: ${q.price}`);
|
||||
if (q.timeline) console.log(` Timeline: ${q.timeline}`);
|
||||
});
|
||||
} else if (runFeedbackTests) {
|
||||
await testPersonalityFeedback(shouldSave);
|
||||
} else {
|
||||
// Default: Show help
|
||||
console.log(`
|
||||
@@ -409,19 +601,24 @@ Usage:
|
||||
# Generate 5 quotes automatically
|
||||
node carpenter-agent-openai.js --generate-quotes=5 --save
|
||||
|
||||
# Test personality-based feedback quality
|
||||
node carpenter-agent-openai.js --test-personality-feedback --save
|
||||
|
||||
# Available carpenters: lars, jannick, alexander
|
||||
|
||||
Options:
|
||||
--interactive, -i Interactive conversation mode
|
||||
--carpenter=NAME Choose carpenter (lars, jannick, alexander)
|
||||
--generate-quotes=N Generate N quotes programmatically
|
||||
--save, -s Save generated quotes to disk
|
||||
--test-personality-feedback Run personality-specific feedback tests
|
||||
--save, -s Save generated data to disk
|
||||
|
||||
Examples:
|
||||
node carpenter-agent-openai.js -i --carpenter=jannick
|
||||
node carpenter-agent-openai.js --generate-quotes=10 --save
|
||||
node carpenter-agent-openai.js --test-personality-feedback --save
|
||||
|
||||
Requires: OPENAI_API_KEY environment variable
|
||||
Optional: CODEX_MODEL environment variable (default: gpt-5.4-mini)
|
||||
`);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,24 +1,49 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNotification } from '../hooks/useNotification';
|
||||
import './FinalReview.css';
|
||||
import '../styles/statusFlow.css';
|
||||
import { PROJECT_STATUS, getProjectStatusMeta, isFinalizedProjectStatus } from '../utils/projectStatus';
|
||||
import { FLOW_STEPS, getActiveStageIndex } from '../utils/statusFlow';
|
||||
|
||||
const FinalReview = ({
|
||||
apiBaseUrl,
|
||||
project,
|
||||
geometryData,
|
||||
packageData,
|
||||
ordrestyringMetadata,
|
||||
onSubmitToOrdering,
|
||||
onBack,
|
||||
onBackWithData
|
||||
}) => {
|
||||
const isRentalItem = React.useCallback((item = {}) => {
|
||||
const unit = String(item?.unit || '').toLowerCase();
|
||||
const category = String(item?.category || item?.material_category || '').toLowerCase();
|
||||
const name = String(item?.name || item?.material_name || '').toLowerCase();
|
||||
return unit.includes('/dag')
|
||||
|| unit.includes('day')
|
||||
|| unit.includes('dag')
|
||||
|| unit.includes('/time')
|
||||
|| unit.includes('time')
|
||||
|| unit.includes('/uge')
|
||||
|| unit.includes('week')
|
||||
|| category.includes('udlej')
|
||||
|| category.includes('rental')
|
||||
|| category.includes('leje')
|
||||
|| name.includes('udlej')
|
||||
|| name.includes('leje');
|
||||
}, []);
|
||||
|
||||
const notify = useNotification();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitResult, setSubmitResult] = useState(null);
|
||||
const [showOrderingDetails, setShowOrderingDetails] = useState(false);
|
||||
|
||||
// Editable pricing state
|
||||
const [editableMaterials, setEditableMaterials] = useState(packageData?.materials || []);
|
||||
const initialMaterials = (packageData?.materials || []).filter((item) => !isRentalItem(item));
|
||||
const initialRentals = (packageData?.rentals || []).length > 0
|
||||
? packageData.rentals
|
||||
: (packageData?.materials || []).filter((item) => isRentalItem(item));
|
||||
const [editableMaterials, setEditableMaterials] = useState(initialMaterials);
|
||||
const [editableRentalItems, setEditableRentalItems] = useState(initialRentals);
|
||||
const [editableLaborTasks, setEditableLaborTasks] = useState(packageData?.laborTasks || []);
|
||||
const [editableProjectDescription, setEditableProjectDescription] = useState(
|
||||
packageData?.recommendedDescription || project?.project_description || project?.description || ''
|
||||
@@ -30,18 +55,74 @@ const FinalReview = ({
|
||||
const [quoteText, setQuoteText] = useState('');
|
||||
const [isGeneratingQuote, setIsGeneratingQuote] = useState(false);
|
||||
const [quoteType, setQuoteType] = useState('static'); // 'static' eller 'ai'
|
||||
const [includeRentalInQuote, setIncludeRentalInQuote] = useState(true);
|
||||
const [aiInstructions, setAiInstructions] = useState('');
|
||||
const [aiInstructionsTouched, setAiInstructionsTouched] = useState(false);
|
||||
const [showQuoteEditor, setShowQuoteEditor] = useState(false);
|
||||
|
||||
// PDF state
|
||||
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
|
||||
const [pdfUrl, setPdfUrl] = useState(null);
|
||||
const [isPdfStale, setIsPdfStale] = useState(false);
|
||||
const [pdfGeneratedAt, setPdfGeneratedAt] = 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 [metadataNote, setMetadataNote] = useState('');
|
||||
const metadataSyncItems = [
|
||||
{
|
||||
label: 'Prislistesync',
|
||||
value: ordrestyringMetadata?.last_price_list_synced_at
|
||||
},
|
||||
{
|
||||
label: 'Materialer sync',
|
||||
value: ordrestyringMetadata?.last_material_sync_at
|
||||
}
|
||||
];
|
||||
const activeStageIndex = getActiveStageIndex({
|
||||
projectStatus,
|
||||
metadataStatus: ordrestyringMetadata?.last_status,
|
||||
hasSmartPackage: true
|
||||
});
|
||||
useEffect(() => {
|
||||
const defaultNote = ordrestyringMetadata?.notes || '';
|
||||
if (!project?.id) {
|
||||
setMetadataNote(defaultNote);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
setMetadataNote(defaultNote);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = sessionStorage.getItem(`ordrestyring-note-${project.id}`);
|
||||
if (saved !== null) {
|
||||
setMetadataNote(saved);
|
||||
} else {
|
||||
setMetadataNote(defaultNote);
|
||||
}
|
||||
} catch (error) {
|
||||
setMetadataNote(defaultNote);
|
||||
}
|
||||
}, [project?.id, ordrestyringMetadata?.notes]);
|
||||
|
||||
const handleMetadataNoteChange = (value) => {
|
||||
setMetadataNote(value);
|
||||
if (!project?.id || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(`ordrestyring-note-${project.id}`, value);
|
||||
} catch (error) {
|
||||
console.warn('Could not persist Ordrestyring note:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getErrorMessage = React.useCallback((errorLike, fallback = 'Ukendt fejl') => {
|
||||
if (!errorLike) {
|
||||
@@ -72,21 +153,25 @@ const FinalReview = ({
|
||||
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 roofArea = quoteData?.geometry?.roofCoveringArea || quoteData?.geometry?.roof_covering_area || quoteData?.geometry?.total_area || quoteData?.geometry?.roofArea || 0;
|
||||
const packageName = quoteData?.package?.name || '';
|
||||
const materials = Array.isArray(quoteData?.materials) ? quoteData.materials : [];
|
||||
const rentals = Array.isArray(quoteData?.rentals) ? quoteData.rentals : [];
|
||||
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 rentalLines = rentals.slice(0, 6).map(rental =>
|
||||
` • ${rental.name || rental.material_name}: ${rental.quantity} ${rental.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} m²${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`;
|
||||
return `${String(projectName).toUpperCase()}\n\nProjekt\n • Kunde: ${customerName}\n • Adresse: ${address}\n • Tagtype: ${roofType}\n • Tagareal: ${roofArea} m²${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\nUdlejning\n${rentalLines || ' • Ingen udlejning i dette tilbud'}\n\nPris\n • Materialer: ${formatCurrency(totals.materials || 0)} ekskl. moms\n • Udlejning: ${formatCurrency(totals.rentals || 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 () => {
|
||||
@@ -106,7 +191,8 @@ const FinalReview = ({
|
||||
}
|
||||
|
||||
if (suggestedMaterials.length > 0) {
|
||||
setEditableMaterials(suggestedMaterials);
|
||||
setEditableMaterials(suggestedMaterials.filter((item) => !isRentalItem(item)));
|
||||
setEditableRentalItems(suggestedMaterials.filter((item) => isRentalItem(item)));
|
||||
}
|
||||
|
||||
if (suggestedLaborTasks.length > 0) {
|
||||
@@ -132,7 +218,7 @@ const FinalReview = ({
|
||||
} catch (error) {
|
||||
console.warn('Could not persist suggestion snapshot:', error);
|
||||
}
|
||||
}, [apiBaseUrl, orderSuggestions, project?.id]);
|
||||
}, [apiBaseUrl, isRentalItem, orderSuggestions, project?.id]);
|
||||
|
||||
// Scroll to top when component mounts
|
||||
React.useEffect(() => {
|
||||
@@ -155,7 +241,7 @@ const FinalReview = ({
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [editableMaterials, editableLaborTasks]);
|
||||
}, [editableMaterials, editableRentalItems, editableLaborTasks]);
|
||||
|
||||
// Debug log for at se hvad vi får som props
|
||||
console.log('🔍 FinalReview props debug:', {
|
||||
@@ -172,7 +258,8 @@ const FinalReview = ({
|
||||
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
|
||||
baseArea: geometryData.baseArea || geometryData.totalArea || geometryData.total_area || 0,
|
||||
roofCoveringArea: geometryData.roofCoveringArea || geometryData.roof_covering_area || geometryData.totalArea || geometryData.total_area || 0
|
||||
} : null), [geometryData]);
|
||||
|
||||
console.log('📊 Normalized geometry:', normalizedGeometry);
|
||||
@@ -180,7 +267,12 @@ const FinalReview = ({
|
||||
// Sync editable state when packageData changes
|
||||
React.useEffect(() => {
|
||||
if (packageData?.materials) {
|
||||
setEditableMaterials(packageData.materials);
|
||||
setEditableMaterials(packageData.materials.filter((item) => !isRentalItem(item)));
|
||||
setEditableRentalItems(
|
||||
(packageData?.rentals || []).length > 0
|
||||
? packageData.rentals
|
||||
: packageData.materials.filter((item) => isRentalItem(item))
|
||||
);
|
||||
}
|
||||
if (packageData?.laborTasks) {
|
||||
setEditableLaborTasks(packageData.laborTasks);
|
||||
@@ -188,7 +280,36 @@ const FinalReview = ({
|
||||
setEditableProjectDescription(
|
||||
packageData?.recommendedDescription || project?.project_description || project?.description || ''
|
||||
);
|
||||
}, [packageData, project]);
|
||||
}, [isRentalItem, packageData, project]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (aiInstructionsTouched) {
|
||||
return;
|
||||
}
|
||||
|
||||
const materialPreview = editableMaterials
|
||||
.slice(0, 6)
|
||||
.map((item) => `${item.name || 'Materiale'} (${parseFloat(item.quantity) || 0} ${item.unit || 'stk'})`)
|
||||
.join(', ');
|
||||
const rentalPreview = editableRentalItems
|
||||
.slice(0, 6)
|
||||
.map((item) => `${item.name || 'Udlejning'} (${parseFloat(item.quantity) || 0} ${item.unit || 'dag'})`)
|
||||
.join(', ');
|
||||
const taskPreview = editableLaborTasks
|
||||
.slice(0, 6)
|
||||
.map((item) => `${item.name || 'Opgave'} (${(parseFloat(item.totalHours) || 0).toFixed(1)}t)`)
|
||||
.join(', ');
|
||||
|
||||
const autoDirective = [
|
||||
'Brug en konkret, professionel tone.',
|
||||
editableProjectDescription ? `Beskrivelse: ${editableProjectDescription}` : '',
|
||||
materialPreview ? `Materialer: ${materialPreview}` : '',
|
||||
rentalPreview ? `Udlejning: ${rentalPreview}` : '',
|
||||
taskPreview ? `Opgaver: ${taskPreview}` : ''
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
setAiInstructions(autoDirective);
|
||||
}, [aiInstructionsTouched, editableMaterials, editableRentalItems, editableLaborTasks, editableProjectDescription]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setProjectStatus(project?.project_status || PROJECT_STATUS.DRAFT);
|
||||
@@ -238,14 +359,14 @@ const FinalReview = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editableMaterials.length && !editableLaborTasks.length) {
|
||||
if (!editableMaterials.length && !editableRentalItems.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]);
|
||||
}, [editableLaborTasks.length, editableMaterials.length, editableRentalItems.length, persistProjectStatus, project?.id, projectStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadOrderSuggestions = async () => {
|
||||
@@ -284,6 +405,11 @@ const FinalReview = ({
|
||||
return sum + (parseFloat(material.quantity) || 0) * (parseFloat(material.unitPrice) || 0);
|
||||
}, 0);
|
||||
};
|
||||
const calculateRentalTotal = () => {
|
||||
return editableRentalItems.reduce((sum, rental) => {
|
||||
return sum + (parseFloat(rental.quantity) || 0) * (parseFloat(rental.unitPrice) || 0);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const calculateLaborTotal = () => {
|
||||
return editableLaborTasks.reduce((sum, task) => {
|
||||
@@ -297,6 +423,7 @@ const FinalReview = ({
|
||||
if (onBackWithData) {
|
||||
onBackWithData({
|
||||
materials: editableMaterials,
|
||||
rentals: editableRentalItems,
|
||||
laborTasks: editableLaborTasks,
|
||||
recommendedDescription: editableProjectDescription
|
||||
});
|
||||
@@ -307,16 +434,37 @@ const FinalReview = ({
|
||||
};
|
||||
|
||||
const materialTotal = calculateMaterialTotal();
|
||||
const rentalTotal = calculateRentalTotal();
|
||||
const laborTotal = calculateLaborTotal();
|
||||
const materialProfit = materialTotal * (materialProfitMargin / 100);
|
||||
const laborProfit = laborTotal * (laborProfitMargin / 100);
|
||||
const totalProfit = materialProfit + laborProfit;
|
||||
const subtotal = materialTotal + laborTotal;
|
||||
const subtotal = materialTotal + rentalTotal + laborTotal;
|
||||
const subtotalWithProfit = subtotal + totalProfit;
|
||||
const taxRate = 0.25; // 25% moms
|
||||
const taxAmount = subtotalWithProfit * taxRate;
|
||||
const grandTotal = subtotalWithProfit + taxAmount;
|
||||
const projectStatusMeta = getProjectStatusMeta(projectStatus);
|
||||
const missingCoreFields = React.useMemo(() => {
|
||||
const missing = [];
|
||||
if (!project?.project_name && !project?.name) {
|
||||
missing.push('Projekt navn');
|
||||
}
|
||||
if (!project?.customer_name) {
|
||||
missing.push('Kunde navn');
|
||||
}
|
||||
if (!editableProjectDescription || !editableProjectDescription.trim()) {
|
||||
missing.push('Beskrivelse til kunden');
|
||||
}
|
||||
if (!editableMaterials?.length && !editableRentalItems?.length) {
|
||||
missing.push('Materialer eller udlejning');
|
||||
}
|
||||
if (!editableLaborTasks?.length) {
|
||||
missing.push('Arbejdsopgaver/timer');
|
||||
}
|
||||
return missing;
|
||||
}, [project, editableProjectDescription, editableMaterials, editableRentalItems, editableLaborTasks]);
|
||||
const isCoreDataValid = missingCoreFields.length === 0;
|
||||
const primaryMaterialSource = materialPriceStatus?.primarySource || null;
|
||||
const formattedPriceImportDate = primaryMaterialSource?.lastImportAt
|
||||
? new Date(primaryMaterialSource.lastImportAt).toLocaleString('da-DK')
|
||||
@@ -330,7 +478,7 @@ const FinalReview = ({
|
||||
const orderData = {
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
name: project.project_name || project.name,
|
||||
customer: project.customer_name,
|
||||
customerNumber: project.customer_number,
|
||||
customerEmail: project.customer_email,
|
||||
@@ -343,7 +491,8 @@ const FinalReview = ({
|
||||
length: normalizedGeometry.length,
|
||||
wallHeight: normalizedGeometry.wallHeight,
|
||||
roofPitch: normalizedGeometry.roofPitch,
|
||||
roofArea: calculateRoofArea(normalizedGeometry.width, normalizedGeometry.length, normalizedGeometry.roofPitch)
|
||||
baseArea: normalizedGeometry.baseArea,
|
||||
roofArea: normalizedGeometry.roofCoveringArea || calculateRoofArea(normalizedGeometry.width, normalizedGeometry.length, normalizedGeometry.roofPitch)
|
||||
},
|
||||
package: {
|
||||
materials: editableMaterials.map(material => ({
|
||||
@@ -354,6 +503,14 @@ const FinalReview = ({
|
||||
total: material.quantity * material.unitPrice,
|
||||
category: material.category
|
||||
})),
|
||||
rentals: editableRentalItems.map(rental => ({
|
||||
name: rental.name,
|
||||
quantity: rental.quantity,
|
||||
unit: rental.unit,
|
||||
unitPrice: rental.unitPrice,
|
||||
total: rental.quantity * rental.unitPrice,
|
||||
category: rental.category
|
||||
})),
|
||||
laborTasks: editableLaborTasks.map(task => ({
|
||||
name: task.name,
|
||||
description: task.description,
|
||||
@@ -364,6 +521,7 @@ const FinalReview = ({
|
||||
})),
|
||||
totals: {
|
||||
materials: materialTotal,
|
||||
rentals: rentalTotal,
|
||||
labor: laborTotal,
|
||||
subtotal: subtotal,
|
||||
materialProfitMargin: materialProfitMargin,
|
||||
@@ -452,6 +610,7 @@ const FinalReview = ({
|
||||
const handleGenerateQuote = React.useCallback(async (type = 'static') => {
|
||||
setIsGeneratingQuote(true);
|
||||
const quoteData = {
|
||||
includeRental: includeRentalInQuote,
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.project_name || project.name,
|
||||
@@ -465,6 +624,7 @@ const FinalReview = ({
|
||||
...packageData,
|
||||
recommendedDescription: editableProjectDescription,
|
||||
materials: editableMaterials,
|
||||
rentals: editableRentalItems,
|
||||
laborTasks: editableLaborTasks
|
||||
},
|
||||
// Include ALL detailed materials
|
||||
@@ -478,6 +638,16 @@ const FinalReview = ({
|
||||
total_price: (parseFloat(m.quantity) || 0) * (parseFloat(m.unitPrice) || 0),
|
||||
category: m.category
|
||||
})),
|
||||
rentals: editableRentalItems.map(r => ({
|
||||
material_name: r.name,
|
||||
name: r.name,
|
||||
quantity: parseFloat(r.quantity) || 0,
|
||||
unit: r.unit,
|
||||
unit_price: parseFloat(r.unitPrice) || 0,
|
||||
unitPrice: parseFloat(r.unitPrice) || 0,
|
||||
total_price: (parseFloat(r.quantity) || 0) * (parseFloat(r.unitPrice) || 0),
|
||||
category: r.category
|
||||
})),
|
||||
// Include ALL detailed labor items
|
||||
labor: editableLaborTasks.map(item => ({
|
||||
task_name: item.name,
|
||||
@@ -491,6 +661,7 @@ const FinalReview = ({
|
||||
})),
|
||||
totals: {
|
||||
materials: materialTotal,
|
||||
rentals: rentalTotal,
|
||||
labor: laborTotal,
|
||||
subtotal: subtotal,
|
||||
tax: taxAmount,
|
||||
@@ -505,6 +676,7 @@ const FinalReview = ({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mode: type,
|
||||
includeRental: includeRentalInQuote,
|
||||
quoteData: quoteData,
|
||||
suggestionData: orderSuggestions,
|
||||
customInstructions: aiInstructions
|
||||
@@ -525,6 +697,9 @@ const FinalReview = ({
|
||||
if (result.success) {
|
||||
setQuoteText(result.quoteText);
|
||||
setShowQuoteEditor(true);
|
||||
if (pdfUrl) {
|
||||
setIsPdfStale(true);
|
||||
}
|
||||
} else {
|
||||
throw new Error(getErrorMessage(result, 'Ukendt fejl'));
|
||||
}
|
||||
@@ -541,9 +716,9 @@ const FinalReview = ({
|
||||
} 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]);
|
||||
}, [apiBaseUrl, project?.id, project?.project_name, project?.name, project?.customer_name, project?.customer_number, project?.customer_address, editableProjectDescription, normalizedGeometry, packageData, editableMaterials, editableRentalItems, editableLaborTasks, materialTotal, rentalTotal, laborTotal, subtotal, taxAmount, grandTotal, includeRentalInQuote, orderSuggestions, aiInstructions, getErrorMessage, buildLocalStaticQuote, notify]);
|
||||
|
||||
// Generer og download PDF
|
||||
// Generer PDF til preview + valgfri download
|
||||
const handleGeneratePDF = async () => {
|
||||
setIsGeneratingPDF(true);
|
||||
try {
|
||||
@@ -552,8 +727,8 @@ const FinalReview = ({
|
||||
projectId: project.id,
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
project_name: project.name,
|
||||
name: project.project_name || project.name,
|
||||
project_name: project.project_name || project.name,
|
||||
customer_name: project.customer_name,
|
||||
description: editableProjectDescription
|
||||
},
|
||||
@@ -562,7 +737,8 @@ const FinalReview = ({
|
||||
length: normalizedGeometry.length,
|
||||
wallHeight: normalizedGeometry.wallHeight,
|
||||
roofPitch: normalizedGeometry.roofPitch,
|
||||
roofArea: calculateRoofArea(normalizedGeometry.width, normalizedGeometry.length, normalizedGeometry.roofPitch)
|
||||
baseArea: normalizedGeometry.baseArea,
|
||||
roofArea: normalizedGeometry.roofCoveringArea || calculateRoofArea(normalizedGeometry.width, normalizedGeometry.length, normalizedGeometry.roofPitch)
|
||||
},
|
||||
materials: editableMaterials.map(material => ({
|
||||
name: material.name,
|
||||
@@ -573,6 +749,15 @@ const FinalReview = ({
|
||||
total: (parseFloat(material.quantity) || 0) * (parseFloat(material.unitPrice) || 0),
|
||||
category: material.category
|
||||
})),
|
||||
rentals: editableRentalItems.map(rental => ({
|
||||
name: rental.name,
|
||||
quantity: parseFloat(rental.quantity) || 0,
|
||||
unit: rental.unit,
|
||||
unitPrice: parseFloat(rental.unitPrice) || 0,
|
||||
unit_price: parseFloat(rental.unitPrice) || 0,
|
||||
total: (parseFloat(rental.quantity) || 0) * (parseFloat(rental.unitPrice) || 0),
|
||||
category: rental.category
|
||||
})),
|
||||
labor: editableLaborTasks.map(task => ({
|
||||
name: task.name,
|
||||
description: task.description,
|
||||
@@ -584,6 +769,7 @@ const FinalReview = ({
|
||||
})),
|
||||
totals: {
|
||||
materialTotal: materialTotal,
|
||||
rentalTotal: rentalTotal,
|
||||
laborTotal: laborTotal,
|
||||
subtotal: subtotal,
|
||||
materialProfitMargin: materialProfitMargin,
|
||||
@@ -614,16 +800,10 @@ const FinalReview = ({
|
||||
window.URL.revokeObjectURL(pdfUrl);
|
||||
}
|
||||
setPdfUrl(url);
|
||||
setIsPdfStale(false);
|
||||
setPdfGeneratedAt(new Date());
|
||||
|
||||
// Download automatically
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `Tilbud_${project.project_name || project.name}_${new Date().toISOString().split('T')[0]}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
notify.success('PDF genereret og downloadet! Du kan også åbne den med "Åben PDF" knappen.');
|
||||
notify.success('PDF genereret. Du kan nu åbne den eller downloade den.');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
notify.error('Fejl ved PDF generering: ' + (error.error || 'Ukendt fejl'));
|
||||
@@ -641,10 +821,30 @@ const FinalReview = ({
|
||||
// Open PDF in new window/tab
|
||||
const handleOpenPDF = () => {
|
||||
if (pdfUrl) {
|
||||
if (isPdfStale) {
|
||||
notify.warning('PDF preview er forældet efter seneste ændringer. Generer igen for opdateret visning.');
|
||||
}
|
||||
window.open(pdfUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPDF = () => {
|
||||
if (!pdfUrl) {
|
||||
notify.warning('Generer først PDF for at kunne downloade.');
|
||||
return;
|
||||
}
|
||||
if (isPdfStale) {
|
||||
notify.warning('Du downloader en ældre PDF. Generer igen for nyeste version.');
|
||||
}
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = pdfUrl;
|
||||
a.download = `Tilbud_${project.project_name || project.name}_${new Date().toISOString().split('T')[0]}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
// Cleanup blob URL when component unmounts
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
@@ -654,6 +854,20 @@ const FinalReview = ({
|
||||
};
|
||||
}, [pdfUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (pdfUrl) {
|
||||
setIsPdfStale(true);
|
||||
}
|
||||
}, [
|
||||
quoteText,
|
||||
editableProjectDescription,
|
||||
materialProfitMargin,
|
||||
laborProfitMargin,
|
||||
editableMaterials,
|
||||
editableRentalItems,
|
||||
editableLaborTasks
|
||||
]);
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount || isNaN(amount)) return '0,00 kr';
|
||||
|
||||
@@ -665,6 +879,23 @@ const FinalReview = ({
|
||||
}).format(amount).replace('DKK', 'kr').replace('kr.', 'kr');
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) {
|
||||
return 'Dato ukendt';
|
||||
}
|
||||
const parsed = new Date(dateString);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return 'Dato ukendt';
|
||||
}
|
||||
return parsed.toLocaleString('da-DK', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Tjek om vi har nødvendige data
|
||||
if (!project) {
|
||||
return (
|
||||
@@ -740,6 +971,34 @@ const FinalReview = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="status-flow">
|
||||
{FLOW_STEPS.map((step, index) => {
|
||||
const isCompleted = index <= activeStageIndex;
|
||||
const isActive = index === activeStageIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.key}
|
||||
className={`flow-step ${isCompleted ? 'completed' : ''} ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<div className="flow-step-icon">{step.icon}</div>
|
||||
<div className="flow-step-content">
|
||||
<span className="flow-step-label">{step.label}</span>
|
||||
<span className="flow-step-description">{step.description}</span>
|
||||
{isActive && (
|
||||
<small className="flow-step-meta">
|
||||
{ordrestyringMetadata?.last_status || 'Status ukendt'}
|
||||
{ordrestyringMetadata?.last_status_at && (
|
||||
<span> · {formatDate(ordrestyringMetadata.last_status_at)}</span>
|
||||
)}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="review-sections">
|
||||
{/* Projekt Information */}
|
||||
<div className="review-section project-info">
|
||||
@@ -888,8 +1147,12 @@ const FinalReview = ({
|
||||
<span>{normalizedGeometry?.roofPitch || 0}°</span>
|
||||
</div>
|
||||
<div className="geo-item total-area">
|
||||
<label>Tagareal:</label>
|
||||
<span>{normalizedGeometry ? calculateRoofArea(normalizedGeometry.width, normalizedGeometry.length, normalizedGeometry.roofPitch).toFixed(1) : 0} m²</span>
|
||||
<label>Grundareal:</label>
|
||||
<span>{normalizedGeometry ? (parseFloat(normalizedGeometry.baseArea) || 0).toFixed(1) : 0} m²</span>
|
||||
</div>
|
||||
<div className="geo-item total-area">
|
||||
<label>Tagbeklædning:</label>
|
||||
<span>{normalizedGeometry ? (parseFloat(normalizedGeometry.roofCoveringArea) || 0).toFixed(1) : 0} m²</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -955,6 +1218,63 @@ const FinalReview = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timer Oversigt */}
|
||||
<div className="review-section materials-review">
|
||||
<h3>🚜 Udlejning ({editableRentalItems?.length || 0} poster) - Redigerbar</h3>
|
||||
{editableRentalItems?.length > 0 ? (
|
||||
<div className="materials-table editable-table">
|
||||
<div className="table-header">
|
||||
<span>Udlejning</span>
|
||||
<span>Antal</span>
|
||||
<span>Enhed</span>
|
||||
<span>Enhedspris</span>
|
||||
<span>Total</span>
|
||||
</div>
|
||||
{editableRentalItems.map((rental, index) => (
|
||||
<div key={index} className="table-row editable-row">
|
||||
<span className="material-name">{rental.name}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={rental.quantity || 0}
|
||||
onChange={(e) => {
|
||||
const newRentals = [...editableRentalItems];
|
||||
newRentals[index] = {
|
||||
...rental,
|
||||
quantity: parseFloat(e.target.value) || 0
|
||||
};
|
||||
setEditableRentalItems(newRentals);
|
||||
}}
|
||||
className="editable-input quantity-input"
|
||||
/>
|
||||
<span>{rental.unit}</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rental.unitPrice || 0}
|
||||
onChange={(e) => {
|
||||
const newRentals = [...editableRentalItems];
|
||||
newRentals[index] = {
|
||||
...rental,
|
||||
unitPrice: parseFloat(e.target.value) || 0
|
||||
};
|
||||
setEditableRentalItems(newRentals);
|
||||
}}
|
||||
className="editable-input price-input"
|
||||
/>
|
||||
<span className="total">{formatCurrency((rental.quantity || 0) * (rental.unitPrice || 0))}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="table-total">
|
||||
<span>Udlejning Total:</span>
|
||||
<span className="total-amount">{formatCurrency(rentalTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-data">Ingen udlejning defineret</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timer Oversigt */}
|
||||
<div className="review-section labor-review">
|
||||
<h3>⏱️ Timer & Opgaver ({editableLaborTasks?.length || 0} opgaver) - Redigerbar</h3>
|
||||
@@ -1042,6 +1362,9 @@ const FinalReview = ({
|
||||
<span>Fortjeneste: {formatCurrency(materialTotal * (materialProfitMargin / 100))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontSize: '13px', color: '#666', marginTop: '-10px', marginBottom: '18px'}}>
|
||||
Udlejning (ingen fortjenesteslider): {formatCurrency(rentalTotal)}
|
||||
</div>
|
||||
|
||||
{/* Labor Markup Slider */}
|
||||
<div style={{marginBottom: '20px'}}>
|
||||
@@ -1095,6 +1418,10 @@ const FinalReview = ({
|
||||
<span>Timer:</span>
|
||||
<span>{formatCurrency(laborTotal)}</span>
|
||||
</div>
|
||||
<div className="financial-row">
|
||||
<span>Udlejning:</span>
|
||||
<span>{formatCurrency(rentalTotal)}</span>
|
||||
</div>
|
||||
<div className="financial-row subtotal">
|
||||
<span>Subtotal:</span>
|
||||
<span>{formatCurrency(subtotal)}</span>
|
||||
@@ -1143,13 +1470,26 @@ const FinalReview = ({
|
||||
<span>🤖 AI Genereret Tilbudstekst</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="quote-type-selector">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeRentalInQuote}
|
||||
onChange={(e) => setIncludeRentalInQuote(e.target.checked)}
|
||||
/>
|
||||
<span>Inkluder udlejning i tilbuddet</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{quoteType === 'ai' && (
|
||||
<div className="ai-instructions">
|
||||
<label>AI Direktiv (valgfrit):</label>
|
||||
<textarea
|
||||
value={aiInstructions}
|
||||
onChange={(e) => setAiInstructions(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setAiInstructionsTouched(true);
|
||||
setAiInstructions(e.target.value);
|
||||
}}
|
||||
placeholder="F.eks. 'Brug professionel tone' eller 'Fremhæv økologiske materialer'"
|
||||
rows="3"
|
||||
/>
|
||||
@@ -1170,22 +1510,40 @@ const FinalReview = ({
|
||||
<label>Tilbudstekst (kan redigeres):</label>
|
||||
<textarea
|
||||
value={quoteText}
|
||||
onChange={(e) => setQuoteText(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setQuoteText(e.target.value);
|
||||
if (pdfUrl) {
|
||||
setIsPdfStale(true);
|
||||
}
|
||||
}}
|
||||
rows="15"
|
||||
className="quote-textarea"
|
||||
/>
|
||||
|
||||
<div className="quote-preview-card">
|
||||
<div className="quote-preview-header">
|
||||
<h4>Tilbudsvisning (kundeformat)</h4>
|
||||
<span>Forhåndsvisning før PDF</span>
|
||||
</div>
|
||||
<div className="quote-preview-body">
|
||||
{quoteText.split('\n').map((line, idx) => (
|
||||
<p key={`quote-line-${idx}`}>{line || '\u00A0'}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quote-actions">
|
||||
<button
|
||||
onClick={handleGeneratePDF}
|
||||
className="generate-pdf-btn"
|
||||
disabled={isGeneratingPDF || !quoteText}
|
||||
title="Download PDF til din computer"
|
||||
title="Generer/Opdater PDF preview"
|
||||
>
|
||||
{isGeneratingPDF ? '⏳ Genererer PDF...' : '📄 Download PDF'}
|
||||
{isGeneratingPDF ? '⏳ Genererer PDF...' : (pdfUrl ? '🔄 Opdater PDF' : '📄 Generer PDF')}
|
||||
</button>
|
||||
|
||||
{pdfUrl && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleOpenPDF}
|
||||
className="view-pdf-btn"
|
||||
@@ -1193,8 +1551,32 @@ const FinalReview = ({
|
||||
>
|
||||
👁️ Åben PDF
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownloadPDF}
|
||||
className="download-pdf-btn"
|
||||
title="Download den genererede PDF"
|
||||
>
|
||||
⬇️ Download PDF
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{pdfUrl && (
|
||||
<div className={`pdf-status ${isPdfStale ? 'stale' : 'fresh'}`}>
|
||||
{isPdfStale
|
||||
? '⚠️ Preview er forældet efter dine seneste ændringer. Tryk "Opdater PDF".'
|
||||
: `✅ PDF er opdateret${pdfGeneratedAt ? ` (${pdfGeneratedAt.toLocaleTimeString('da-DK')})` : ''}.`}
|
||||
</div>
|
||||
)}
|
||||
{pdfUrl && (
|
||||
<div className="pdf-inline-preview">
|
||||
<iframe
|
||||
title="PDF forhåndsvisning"
|
||||
src={pdfUrl}
|
||||
className="pdf-preview-frame"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1207,32 +1589,53 @@ const FinalReview = ({
|
||||
|
||||
{/* Ordrestyring Integration */}
|
||||
<div className="review-section ordering-section">
|
||||
<h3>🚀 Ordrestyring Status</h3>
|
||||
<h3>🚀 Ordrestyring Levering</h3>
|
||||
{!isCoreDataValid && (
|
||||
<div className="submit-result error" style={{ marginBottom: '12px' }}>
|
||||
<h4>⚠️ Tilbud er ikke klar til afsendelse</h4>
|
||||
<p>Mangler før afsendelse: {missingCoreFields.join(', ')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="ordering-info">
|
||||
<p>
|
||||
Du kan gemme tilbuddet som klart til Ordrestyring uden at sende det endnu. Send først, når tilbudet er helt klar.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => setShowOrderingDetails(!showOrderingDetails)}
|
||||
className="details-toggle"
|
||||
>
|
||||
{showOrderingDetails ? '▼ Skjul detaljer' : '▶ Vis ordrestyring detaljer'}
|
||||
</button>
|
||||
|
||||
{showOrderingDetails && (
|
||||
<div className="ordering-details">
|
||||
<h4>Data som sendes til Ordrestyring:</h4>
|
||||
<ul>
|
||||
<li>Projekt information og kunde data</li>
|
||||
<li>Geometri mål og beregninger</li>
|
||||
<li>Komplet materialliste med priser</li>
|
||||
<li>Timer opgaver og omkostninger</li>
|
||||
<li>Total tilbudspris inkl. moms</li>
|
||||
<li>Tilbudets gyldighed (30 dage)</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="metadata-summary">
|
||||
<div className="metadata-card">
|
||||
<div className="metadata-card-row">
|
||||
<span>Case</span>
|
||||
<strong>{ordrestyringMetadata?.case_number || 'Ikke synkroniseret'}</strong>
|
||||
</div>
|
||||
<div className="metadata-card-row">
|
||||
<span>Tilbudsnummer</span>
|
||||
<strong>{ordrestyringMetadata?.offer_number || 'Ingen'}</strong>
|
||||
</div>
|
||||
<div className="metadata-card-row">
|
||||
<span>Senest opdateret</span>
|
||||
<strong>{ordrestyringMetadata?.updated_at ? formatDate(ordrestyringMetadata.updated_at) : 'Ukendt'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metadata-sync-grid">
|
||||
{metadataSyncItems.map(item => (
|
||||
<div key={item.label} className="metadata-sync-item">
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.value ? formatDate(item.value) : 'Ikke synkroniseret'}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metadata-note">
|
||||
<label>Ordrestyring noter</label>
|
||||
<textarea
|
||||
value={metadataNote}
|
||||
onChange={(e) => handleMetadataNoteChange(e.target.value)}
|
||||
placeholder="Notér hurtige observationer om status, sync eller tilbudslinjer..."
|
||||
/>
|
||||
<small>Noter gemmes kun lokalt i denne browser.</small>
|
||||
</div>
|
||||
|
||||
{submitResult && (
|
||||
@@ -1266,7 +1669,7 @@ const FinalReview = ({
|
||||
<button
|
||||
onClick={handleMarkReadyForOrdrestyring}
|
||||
className="generate-quote-btn"
|
||||
disabled={isSubmitting || isSavingReadyStatus || !packageData?.materials?.length || !packageData?.laborTasks?.length}
|
||||
disabled={isSubmitting || isSavingReadyStatus || !isCoreDataValid}
|
||||
>
|
||||
{isSavingReadyStatus ? 'Gemmer status...' : '💾 Klar til Ordrestyring'}
|
||||
</button>
|
||||
@@ -1274,7 +1677,7 @@ const FinalReview = ({
|
||||
<button
|
||||
onClick={handleSubmitToOrdering}
|
||||
className="submit-btn"
|
||||
disabled={isSubmitting || !packageData?.materials?.length || !packageData?.laborTasks?.length}
|
||||
disabled={isSubmitting || !isCoreDataValid}
|
||||
>
|
||||
{isSubmitting ? 'Sender til ordrestyring...' : '🚀 Send til Ordrestyring'}
|
||||
</button>
|
||||
|
||||
@@ -38,6 +38,10 @@ function LoginForm() {
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="Indtast brugernavn"
|
||||
aria-label="Brugernavn"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={isLoading}
|
||||
@@ -49,6 +53,10 @@ function LoginForm() {
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Indtast password"
|
||||
aria-label="Password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const OpenAI = require('openai');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
/**
|
||||
* 🔨 CARPENTER AGENT - OpenAI Browser Automation
|
||||
*
|
||||
* An intelligent Playwright test that uses OpenAI to:
|
||||
* An intelligent Playwright test that uses Codex CLI to:
|
||||
* - Understand the UI dynamically
|
||||
* - Make intelligent decisions about interactions
|
||||
* - Fill forms naturally based on carpenter persona
|
||||
@@ -15,13 +16,44 @@ const path = require('path');
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || 'http://localhost:4032';
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
const CODEX_MODEL = process.env.CODEX_MODEL || 'gpt-5.4-mini';
|
||||
const TEST_USERNAME = process.env.TEST_USERNAME || 'admin';
|
||||
const TEST_PASSWORD = process.env.TEST_PASSWORD || 'admin123';
|
||||
|
||||
if (!OPENAI_API_KEY) {
|
||||
throw new Error('OPENAI_API_KEY environment variable is required');
|
||||
function runCodexPrompt(prompt) {
|
||||
const outputFile = path.join(
|
||||
os.tmpdir(),
|
||||
`codex-carpenter-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`
|
||||
);
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
'codex',
|
||||
[
|
||||
'exec',
|
||||
'-m',
|
||||
CODEX_MODEL,
|
||||
'--color',
|
||||
'never',
|
||||
'--output-last-message',
|
||||
outputFile,
|
||||
prompt
|
||||
],
|
||||
{
|
||||
cwd: path.resolve(__dirname, '..'),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024
|
||||
}
|
||||
);
|
||||
|
||||
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
|
||||
return fs.readFileSync(outputFile, 'utf8').trim();
|
||||
} finally {
|
||||
if (fs.existsSync(outputFile)) {
|
||||
fs.unlinkSync(outputFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Carpenter personas
|
||||
const CARPENTERS = {
|
||||
@@ -89,7 +121,7 @@ async function capturePageState(page) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask OpenAI what to do next based on page state and carpenter persona
|
||||
* Ask Codex what to do next based on page state and carpenter persona
|
||||
*/
|
||||
async function decideNextAction(carpenter, pageState, context = '') {
|
||||
const prompt = `Du er ${carpenter.name}, en erfaren tømrer specialiseret i ${carpenter.specialty}.
|
||||
@@ -109,14 +141,7 @@ ACTION: [click button "..."] eller [fill input "..." with "..."]
|
||||
|
||||
VIGTIG: Svar ALTID på dansk med præcise instruktioner baseret på hvad du ser på siden.`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.5,
|
||||
max_tokens: 200
|
||||
});
|
||||
|
||||
return response.choices[0].message.content;
|
||||
return runCodexPrompt(prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +152,7 @@ async function executeAction(page, action) {
|
||||
|
||||
// Parse action
|
||||
const clickMatch = action.match(/click button "([^"]+)"/i) || action.match(/click "([^"]+)"/i);
|
||||
const clickInputMatch = action.match(/click input "([^"]+)"/i) || action.match(/click field "([^"]+)"/i);
|
||||
const fillMatch = action.match(/fill input "([^"]+)" with "([^"]*)"/i) || action.match(/fill "([^"]+)" with "([^"]*)"/i);
|
||||
|
||||
if (clickMatch) {
|
||||
@@ -141,12 +167,24 @@ async function executeAction(page, action) {
|
||||
console.warn(` ⚠️ Button "${buttonText}" not found`);
|
||||
return false;
|
||||
}
|
||||
} else if (clickInputMatch) {
|
||||
const fieldText = clickInputMatch[1];
|
||||
const input = await page.locator(
|
||||
`input[placeholder*="${fieldText}" i], textarea[placeholder*="${fieldText}" i], input[name*="${fieldText}" i], input[id*="${fieldText}" i], textarea[id*="${fieldText}" i], label:has-text("${fieldText}") + input, label:has-text("${fieldText}") + textarea`
|
||||
).first();
|
||||
if (await input.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await input.click();
|
||||
await page.waitForTimeout(500);
|
||||
return true;
|
||||
}
|
||||
console.warn(` ⚠️ Clickable field "${fieldText}" not found`);
|
||||
return false;
|
||||
} else if (fillMatch) {
|
||||
const fieldText = fillMatch[1];
|
||||
const value = fillMatch[2];
|
||||
|
||||
const input = await page.locator(
|
||||
`input[placeholder*="${fieldText}" i], textarea[placeholder*="${fieldText}" i], input[name*="${fieldText}" i]`
|
||||
`input[placeholder*="${fieldText}" i], textarea[placeholder*="${fieldText}" i], input[name*="${fieldText}" i], input[id*="${fieldText}" i], textarea[id*="${fieldText}" i], input[type="text"], textarea`
|
||||
).first();
|
||||
|
||||
if (await input.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
@@ -171,6 +209,7 @@ test.describe('🔨 CARPENTER AGENT - OpenAI Powered Browser Automation', () =>
|
||||
const testName = `${carpenter.name}: ${project.substring(0, 40)}...`;
|
||||
|
||||
test(testName, async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
console.log(`\n${'='.repeat(80)}`);
|
||||
console.log(`🔨 CARPENTER: ${carpenter.name}`);
|
||||
console.log(`📋 PROJECT: ${project}`);
|
||||
@@ -181,21 +220,27 @@ test.describe('🔨 CARPENTER AGENT - OpenAI Powered Browser Automation', () =>
|
||||
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Handle login
|
||||
// Handle login deterministically
|
||||
const loginBtn = page.locator('button:has-text("Log ind")');
|
||||
if (await loginBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
console.log('🔐 Logging in...');
|
||||
await page.fill('input[type="text"]', 'admin');
|
||||
await page.fill('input[type="password"]', 'admin123');
|
||||
await page.fill('input[name="username"], input#username, input[type="text"]', TEST_USERNAME);
|
||||
await page.fill('input[name="password"], input#password, input[type="password"]', TEST_PASSWORD);
|
||||
await loginBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
const stillOnLogin = await page.locator('h2:has-text("Log ind"), form input[type="password"]').first().isVisible({ timeout: 1000 }).catch(() => false);
|
||||
if (stillOnLogin) {
|
||||
throw new Error('Login lykkedes ikke. Tjek TEST_USERNAME/TEST_PASSWORD eller auth backend.');
|
||||
}
|
||||
|
||||
// AI-guided navigation (max 10 steps)
|
||||
let stepCount = 0;
|
||||
const maxSteps = 10;
|
||||
let currentContext = `Carpenter wants to create quote for: ${project}`;
|
||||
let quoteGenerated = false;
|
||||
let strictQuoteSuccess = false;
|
||||
|
||||
while (stepCount < maxSteps && !quoteGenerated) {
|
||||
stepCount++;
|
||||
@@ -204,12 +249,20 @@ test.describe('🔨 CARPENTER AGENT - OpenAI Powered Browser Automation', () =>
|
||||
// Capture current page state
|
||||
const pageState = await capturePageState(page);
|
||||
console.log(` Current page: ${pageState.title}`);
|
||||
if ((pageState.title || '').toLowerCase().includes('error')) {
|
||||
throw new Error(`Applikationen er i fejltilstand (titel: "${pageState.title}") før tilbudsflow kunne startes`);
|
||||
}
|
||||
|
||||
// Ask AI what to do
|
||||
console.log(' 🤖 AI thinking...');
|
||||
const action = await decideNextAction(carpenter, pageState, currentContext);
|
||||
console.log(` AI Decision: ${action}`);
|
||||
|
||||
// Prevent AI from getting stuck re-filling login fields.
|
||||
if (/log ind|brugernavn|password|indtast password|indtast brugernavn/i.test(action)) {
|
||||
throw new Error('AI forsøger login-handlinger efter login-fasen. Stopper for at undgå falske loops.');
|
||||
}
|
||||
|
||||
// Execute action
|
||||
const success = await executeAction(page, action);
|
||||
|
||||
@@ -217,11 +270,19 @@ test.describe('🔨 CARPENTER AGENT - OpenAI Powered Browser Automation', () =>
|
||||
console.log(' ⚠️ Action failed, trying alternative...');
|
||||
}
|
||||
|
||||
// Check if quote was generated (look for success indicators)
|
||||
const pageText = await page.textContent('body');
|
||||
if (pageText.includes('Tilbud') || pageText.includes('tilbud') || pageText.includes('DKK') || pageText.includes('kr')) {
|
||||
// Strict success criteria: actual submission feedback must exist
|
||||
const successBanner = page.locator('.submit-result.success:has-text("Tilbud sendt til ordrestyring")');
|
||||
const submitButton = page.locator('button:has-text("Send til Ordrestyring")');
|
||||
const hasSuccessBanner = await successBanner.isVisible({ timeout: 500 }).catch(() => false);
|
||||
const submitVisible = await submitButton.isVisible({ timeout: 500 }).catch(() => false);
|
||||
|
||||
if (hasSuccessBanner) {
|
||||
strictQuoteSuccess = true;
|
||||
quoteGenerated = true;
|
||||
console.log('\n✅ Quote appears to be generated!');
|
||||
console.log('\n✅ Tilbud faktisk oprettet og sendt til Ordrestyring.');
|
||||
} else if (!submitVisible && stepCount >= maxSteps) {
|
||||
// Fail-safe for flows that never reach actionable submit state
|
||||
quoteGenerated = false;
|
||||
}
|
||||
|
||||
// Update context for next step
|
||||
@@ -253,6 +314,8 @@ test.describe('🔨 CARPENTER AGENT - OpenAI Powered Browser Automation', () =>
|
||||
const dataFile = `test-results/carpenter-openai-${carpenterId}-${projectIdx}.json`;
|
||||
fs.writeFileSync(dataFile, JSON.stringify(testData, null, 2));
|
||||
|
||||
expect(strictQuoteSuccess, 'Tilbud blev ikke faktisk sendt til Ordrestyring').toBe(true);
|
||||
|
||||
console.log(`\n✅ Test completed - Data saved to ${dataFile}`);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user