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:
alexpolo1
2026-05-13 09:25:51 +00:00
parent 002e05bfc7
commit 83a5af07d8
5 changed files with 813 additions and 142 deletions

View File

@@ -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']
}
]
}

View File

@@ -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) {

View File

@@ -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}${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}${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} </span>
<label>Grundareal:</label>
<span>{normalizedGeometry ? (parseFloat(normalizedGeometry.baseArea) || 0).toFixed(1) : 0} </span>
</div>
<div className="geo-item total-area">
<label>Tagbeklædning:</label>
<span>{normalizedGeometry ? (parseFloat(normalizedGeometry.roofCoveringArea) || 0).toFixed(1) : 0} </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>

View File

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

View File

@@ -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}`);
});
}