Files
tilbudgivern/frontend/src/components/LaborInput.js
T
alexpolo1 8773d96e94 feat: Add AI-Powered Visual Testing Suite using Selenium and OpenAI Vision API
- Implemented VisualAITester class for automated UI/UX testing
- Configured environment variables for API keys and paths
- Developed methods for browser setup, login, screenshot capture, and image encoding
- Integrated OpenAI Vision API for visual quality analysis with detailed feedback
- Created tests for various application pages including homepage, project flow, geometry form, and more
- Generated comprehensive HTML report summarizing test results and AI analysis
- Added functionality to copy AI prompts for automated issue resolution
2025-11-14 20:27:19 +00:00

1045 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { useNotification } from '../hooks/useNotification';
import './ProjectFlow.css';
const defaultWorkBreakdown = [
{ task: 'Forberedelse og opstilling', hours: '', allocatedHours: '', description: 'Afdækning, værktøj, sikkerhed' },
{ task: 'Nedrivning af eksisterende', hours: '', allocatedHours: '', description: 'Fjernelse af gamle materialer' },
{ task: 'Montering af nye lægter', hours: '', allocatedHours: '', description: 'C18 taglægter efter forskrifter' },
{ task: 'Montering af tagplader', hours: '', allocatedHours: '', description: 'Nye tagplader inkl. skæring' },
{ task: 'Rygning og afslutning', hours: '', allocatedHours: '', description: 'Ventileret rygning og finish' },
{ task: 'Oprydning og bortkørsel', hours: '', allocatedHours: '', description: 'Rengøring og affaldsbortkørsel' }
];
const LaborInput = ({ apiBaseUrl, project, geometry, existingLabor, onComplete }) => {
const notify = useNotification();
const [formData, setFormData] = useState({
carpenterCount: 2,
totalWorkHours: '',
totalAllocatedHours: '',
hourlyRate: 580, // Fixed rate
workBreakdown: defaultWorkBreakdown,
specialConditions: '',
notes: ''
});
const [historicalData, setHistoricalData] = useState(null);
const [tagExperienceSuggestions, setTagExperienceSuggestions] = useState([]);
const [showHistorical, setShowHistorical] = useState(true);
const [showTagExperience, setShowTagExperience] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Load historical data for similar projects
useEffect(() => {
const loadHistoricalData = async () => {
try {
const response = await fetch(`${apiBaseUrl}/api/customer-projects/${project.id}/historical-data?category=tagreparation&roofType=${geometry?.roof_type || ''}`);
const data = await response.json();
if (data.success) {
setHistoricalData(data);
}
} catch (error) {
console.error('Error loading historical labor data:', error);
}
};
if (project?.id) {
loadHistoricalData();
}
}, [apiBaseUrl, project?.id, geometry?.roof_type]);
// Load tag experience suggestions
useEffect(() => {
const loadTagExperience = async () => {
try {
const response = await fetch(`${apiBaseUrl}/api/customer-projects/tag-experience-suggestions`);
const data = await response.json();
if (data.success) {
setTagExperienceSuggestions(data.suggestions || []);
}
} catch (error) {
console.error('Error loading tag experience suggestions:', error);
}
};
loadTagExperience();
}, [apiBaseUrl]);
// Load existing labor data if available
useEffect(() => {
if (existingLabor) {
// Load work breakdown from similar ordrestyring cases
const loadWorkBreakdownFromSimilarCases = async () => {
try {
if (!geometry?.total_area || !project?.id) return defaultWorkBreakdown;
const response = await fetch(`${apiBaseUrl}/api/customer-projects/${project.id}/historical-data`);
if (response.ok) {
const data = await response.json();
if (data.success && data.historical_projects.length > 0) {
const similarProject = data.historical_projects[0];
// Extract case number from notes
const caseNumber = similarProject.notes?.includes('case')
? similarProject.notes.split('case ')[1]?.split(' ')[0]
: null;
if (caseNumber) {
const workBreakdownResponse = await fetch(`${apiBaseUrl}/api/ordrestyring/case/${caseNumber}/work-breakdown`);
if (workBreakdownResponse.ok) {
const workData = await workBreakdownResponse.json();
if (workData.success && workData.work_breakdown.length > 0) {
return workData.work_breakdown.map(item => ({
task: item.category || 'Generelt arbejde',
hours: Math.round(item.hours || 0).toString(),
allocatedHours: Math.round(item.hours || 0).toString(),
description: (item.description || 'Arbejde fra ordrestyring').replace(/\\n/g, '\n')
}));
}
}
}
}
}
} catch (error) {
console.error('❌ Error loading work breakdown from similar cases:', error);
}
return defaultWorkBreakdown;
};
const loadData = async () => {
// Load existing labor data or fetch work breakdown from similar ordrestyring cases
const workBreakdown = existingLabor.work_breakdown
? JSON.parse(existingLabor.work_breakdown)
: await loadWorkBreakdownFromSimilarCases();
const allocatedBreakdown = existingLabor.allocated_hours_breakdown ? JSON.parse(existingLabor.allocated_hours_breakdown) : null;
// Merge allocated hours into work breakdown if available
if (allocatedBreakdown) {
workBreakdown.forEach((item, index) => {
if (allocatedBreakdown[index]) {
item.allocatedHours = allocatedBreakdown[index].allocatedHours || '';
}
});
}
setFormData({
carpenterCount: existingLabor.carpenter_count || 2,
totalWorkHours: existingLabor.total_work_hours || '',
totalAllocatedHours: existingLabor.allocated_hours_breakdown ?
JSON.parse(existingLabor.allocated_hours_breakdown).reduce((sum, item) => sum + (parseInt(item.allocatedHours) || 0), 0) : '',
hourlyRate: existingLabor.hourly_rate || 580,
workBreakdown: workBreakdown,
specialConditions: existingLabor.special_conditions || '',
notes: existingLabor.notes || ''
});
};
loadData();
}
}, [existingLabor, apiBaseUrl, geometry?.total_area, project?.id]);
// Calculate estimated hours based on geometry when component loads
useEffect(() => {
if (geometry && !existingLabor) {
const area = parseFloat(geometry.total_area);
const complexity = parseFloat(geometry.complexity_factor);
if (area && complexity) {
const baseHoursPerSqm = 0.8;
const estimatedTotal = Math.round(area * baseHoursPerSqm * complexity);
// Distribute hours across tasks
const distributedBreakdown = [
{ task: 'Forberedelse og opstilling', hours: Math.round(estimatedTotal * 0.10), allocatedHours: '', description: 'Afdækning, værktøj, sikkerhed' },
{ task: 'Nedrivning af eksisterende', hours: Math.round(estimatedTotal * 0.20), allocatedHours: '', description: 'Fjernelse af gamle materialer' },
{ task: 'Montering af nye lægter', hours: Math.round(estimatedTotal * 0.25), allocatedHours: '', description: 'C18 taglægter efter forskrifter' },
{ task: 'Montering af tagplader', hours: Math.round(estimatedTotal * 0.30), allocatedHours: '', description: 'Nye tagplader inkl. skæring' },
{ task: 'Rygning og afslutning', hours: Math.round(estimatedTotal * 0.10), allocatedHours: '', description: 'Ventileret rygning og finish' },
{ task: 'Oprydning og bortkørsel', hours: Math.round(estimatedTotal * 0.05), allocatedHours: '', description: 'Rengøring og affaldsbortkørsel' }
];
setFormData(prev => ({
...prev,
totalWorkHours: estimatedTotal,
workBreakdown: distributedBreakdown
}));
}
}
}, [geometry, existingLabor]);
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleBreakdownChange = (index, field, value) => {
const updatedBreakdown = [...formData.workBreakdown];
updatedBreakdown[index] = {
...updatedBreakdown[index],
[field]: (field === 'hours' || field === 'allocatedHours') ? parseInt(value) || 0 : value
};
setFormData(prev => ({
...prev,
workBreakdown: updatedBreakdown
}));
// Update total hours when breakdown changes
if (field === 'hours') {
const totalFromBreakdown = updatedBreakdown.reduce((sum, item) => sum + (parseInt(item.hours) || 0), 0);
setFormData(prev => ({
...prev,
totalWorkHours: totalFromBreakdown
}));
}
};
// Add new custom work task
const addCustomTask = () => {
const newTask = {
task: 'Ny arbejdsopgave',
hours: '',
allocatedHours: '',
description: 'Beskriv arbejdet her...'
};
setFormData(prev => ({
...prev,
workBreakdown: [...prev.workBreakdown, newTask]
}));
};
// Remove work task
const removeTask = (index) => {
const taskName = formData.workBreakdown[index]?.task || 'opgaven';
if (formData.workBreakdown.length <= 1) {
notify.warning('Du skal have mindst én arbejdsopgave', 'Kan ikke fjerne');
return;
}
if (window.confirm(`Er du sikker på at du vil fjerne "${taskName}"?`)) {
const updatedBreakdown = formData.workBreakdown.filter((_, i) => i !== index);
setFormData(prev => ({
...prev,
workBreakdown: updatedBreakdown
}));
// Update total hours
const totalFromBreakdown = updatedBreakdown.reduce((sum, item) => sum + (parseInt(item.hours) || 0), 0);
setFormData(prev => ({
...prev,
totalWorkHours: totalFromBreakdown
}));
}
};
// Add tag experience suggestion to work breakdown
const addTagExperienceToWorkBreakdown = (suggestion) => {
const newWorkItem = {
task: suggestion.task_description,
hours: suggestion.estimated_hours,
allocatedHours: suggestion.estimated_hours, // Start with same as estimated
description: `Fra tidligere erfaring (${suggestion.case_number || 'tidligere opgave'})`
};
const updatedBreakdown = [...formData.workBreakdown, newWorkItem];
// Update totals
const totalFromBreakdown = updatedBreakdown.reduce((sum, item) => sum + (parseInt(item.hours) || 0), 0);
const totalAllocatedFromBreakdown = updatedBreakdown.reduce((sum, item) => sum + (parseInt(item.allocatedHours) || 0), 0);
setFormData(prev => ({
...prev,
workBreakdown: updatedBreakdown,
totalWorkHours: totalFromBreakdown,
totalAllocatedHours: totalAllocatedFromBreakdown,
hourlyRate: suggestion.hourly_rate || prev.hourlyRate // Update hourly rate if suggested
}));
};
const calculateTotalCost = () => {
return (parseInt(formData.totalWorkHours) || 0) * formData.hourlyRate;
};
const applyHistoricalData = async (historicalProject) => {
// Auto-fill form with data from historical project
const area = parseFloat(geometry?.total_area || 0);
const historicalArea = parseFloat(historicalProject.roof_area || 0);
// Scale hours based on area difference if applicable
let scaledHours = parseFloat(historicalProject.total_hours || 0);
if (area && historicalArea && historicalArea > 0) {
scaledHours = (scaledHours * area) / historicalArea;
}
// Get detailed work breakdown from ordrestyring database
let workBreakdownFromDB = defaultWorkBreakdown;
try {
const caseNumber = historicalProject.notes?.includes('case')
? historicalProject.notes.split('case ')[1]?.split(' ')[0]
: null;
if (caseNumber) {
console.log(`🔍 Getting work breakdown for case ${caseNumber} from ordrestyring`);
const response = await fetch(`${apiBaseUrl}/api/ordrestyring/case/${caseNumber}/work-breakdown`);
if (response.ok) {
const data = await response.json();
if (data.success && data.work_breakdown.length > 0) {
// Convert ordrestyring work breakdown to our format
workBreakdownFromDB = data.work_breakdown.map(item => ({
task: item.category || 'Generelt arbejde',
hours: Math.round(item.hours || 0).toString(),
allocatedHours: Math.round(item.hours || 0).toString(),
description: (item.description || item.remark || 'Arbejde fra ordrestyring').replace(/\\n/g, '\n')
}));
console.log(`✅ Loaded ${workBreakdownFromDB.length} work items from ordrestyring database`);
}
}
}
} catch (error) {
console.error('❌ Error loading work breakdown from ordrestyring:', error);
}
setFormData(prev => ({
...prev,
totalWorkHours: Math.round(scaledHours),
workBreakdown: workBreakdownFromDB,
notes: `Baseret på ordrestyring case: ${historicalProject.project_name || 'Ukendt projekt'} (${historicalProject.roof_area}m²)`
}));
};
const calculateDurationDays = () => {
const totalHours = parseInt(formData.totalWorkHours) || 0;
const carpenters = parseInt(formData.carpenterCount) || 1;
const hoursPerDay = 8;
return Math.ceil(totalHours / (carpenters * hoursPerDay));
};
// AI-baseret timeestimering
const getAIWorkHourEstimate = async () => {
setLoading(true);
setError('');
try {
let estimatedHours = 0;
let sources = [];
let carpenters = 2;
// 1. Analysér data fra "Lignende Projekter - Arbejdstimer"
if (historicalData?.historical_projects?.length > 0) {
const avgHours = historicalData.historical_projects
.filter(p => p.total_work_hours > 0)
.reduce((sum, p, _, arr) => sum + p.total_work_hours / arr.length, 0);
if (avgHours > 0) {
estimatedHours = Math.round(avgHours);
sources.push(`Lignende projekter (${historicalData.historical_projects.length} cases): ${estimatedHours}h`);
}
}
// 2. Analysér data fra "Erfaring fra tidligere tag-opgaver"
if (tagExperienceSuggestions?.length > 0) {
const avgTagHours = tagExperienceSuggestions
.filter(s => s.estimated_hours > 0)
.reduce((sum, s, _, arr) => sum + s.estimated_hours / arr.length, 0);
if (avgTagHours > 0) {
const tagEstimate = Math.round(avgTagHours);
sources.push(`Tag erfaring (${tagExperienceSuggestions.length} cases): ${tagEstimate}h`);
// Hvis vi ikke har andre estimater, brug tag erfaring
if (estimatedHours === 0) {
estimatedHours = tagEstimate;
} else {
// Gennemsnit af lignende projekter og tag erfaring
estimatedHours = Math.round((estimatedHours + tagEstimate) / 2);
}
}
}
// 3. Analysér data fra "Arbejdsfordeling" (existing work breakdown)
const currentWorkBreakdown = formData.workBreakdown || [];
const workBreakdownTotal = currentWorkBreakdown
.filter(item => item.hours && parseFloat(item.hours) > 0)
.reduce((sum, item) => sum + parseFloat(item.hours), 0);
if (workBreakdownTotal > 0) {
sources.push(`Arbejdsfordeling: ${Math.round(workBreakdownTotal)}h`);
if (estimatedHours === 0) {
estimatedHours = Math.round(workBreakdownTotal);
}
}
// 4. Fallback til geometri-baseret estimering hvis tilgængelig
if (estimatedHours === 0 && geometry?.total_area && geometry?.roof_type) {
const statsResponse = await fetch(
`${apiBaseUrl}/api/work-hour-statistics/${geometry.roof_type}?areaMin=${geometry.total_area * 0.7}&areaMax=${geometry.total_area * 1.3}`
);
const statsData = await statsResponse.json();
if (statsData.success && statsData.data.hasData) {
estimatedHours = Math.round(
parseFloat(statsData.data.averageHoursPerM2) * parseFloat(geometry.total_area)
);
carpenters = statsData.data.averageCarpenters || 2;
sources.push(`Geometri beregning: ${estimatedHours}h (${geometry.total_area}m²)`);
}
}
// 5. Sidste fallback til standard estimering
if (estimatedHours === 0) {
const estimateResponse = await fetch(`${apiBaseUrl}/api/estimate-work-hours`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
roof_type: geometry?.roof_type || 'standard',
total_area: parseFloat(geometry?.total_area || 100),
complexity_factor: parseFloat(geometry?.complexity_factor || 1.0)
})
});
const estimateData = await estimateResponse.json();
if (estimateData.success) {
estimatedHours = estimateData.data.estimatedHours;
sources.push(`Standard beregning: ${estimatedHours}h`);
}
}
// Opdater formData hvis vi har et estimat
if (estimatedHours > 0) {
setFormData(prev => ({
...prev,
totalWorkHours: Math.max(estimatedHours, 8), // Minimum 8 timer
carpenterCount: carpenters
}));
notify.info(
`Anbefalede timer: ${Math.max(estimatedHours, 8)}\nAnbefalede tømrere: ${carpenters}\n\nKilder:\n${sources.map(s => `• ${s}`).join('\n')}`,
'🤖 AI Forslag baseret på tilgængelig data'
);
} else {
notify.info(
'Tilføj geometri data for bedre estimater\nUdfyld arbejdsfordeling manuelt\nStandard: 8-16 timer for mindre tagarbejder',
'🤖 Ingen historisk data tilgængelig for AI estimering'
);
}
} catch (error) {
console.error('Error getting AI work hour estimate:', error);
setError('Fejl ved AI timeestimering: ' + error.message);
} finally {
setLoading(false);
}
};
const getAIGuidedTimeEstimate = async () => {
setLoading(true);
setError('');
try {
let totalEstimatedHours = 0;
let sources = [];
let carpenterCount = 2;
let guidedBreakdown = [...defaultWorkBreakdown];
let specialConditions = '';
let notes = '';
// 1. Analysér "Erfaring fra tidligere tag-opgaver" for opgavefordeling
if (tagExperienceSuggestions?.length > 0) {
const avgTagHours = tagExperienceSuggestions
.filter(s => s.estimated_hours > 0)
.reduce((sum, s, _, arr) => sum + s.estimated_hours / arr.length, 0);
if (avgTagHours > 0) {
totalEstimatedHours = Math.round(avgTagHours);
sources.push(`${tagExperienceSuggestions.length} tag cases: ${totalEstimatedHours}h`);
// Saml noter fra de bedste tag cases
const topCases = tagExperienceSuggestions.slice(0, 3);
notes = `Baseret på lignende tag cases:\n${topCases.map(c => `• Case ${c.case_number}: ${c.task_description}`).join('\n')}`;
}
}
// 2. Analysér "Lignende Projekter" for forbedring
if (historicalData?.historical_projects?.length > 0) {
const avgHistorical = historicalData.historical_projects
.filter(p => p.total_work_hours > 0)
.reduce((sum, p, _, arr) => sum + p.total_work_hours / arr.length, 0);
if (avgHistorical > 0) {
const historicalEstimate = Math.round(avgHistorical);
sources.push(`${historicalData.historical_projects.length} lignende: ${historicalEstimate}h`);
// Kombiner med tag estimat hvis begge findes
if (totalEstimatedHours > 0) {
totalEstimatedHours = Math.round((totalEstimatedHours + historicalEstimate) / 2);
} else {
totalEstimatedHours = historicalEstimate;
}
// Tilføj erfaring fra lignende projekter
const avgCarpenters = historicalData.historical_projects
.filter(p => p.carpenter_count > 0)
.reduce((sum, p, _, arr) => sum + p.carpenter_count / arr.length, 0);
if (avgCarpenters > 0) {
carpenterCount = Math.round(avgCarpenters);
}
}
}
// 3. Analysér eksisterende "Arbejdsfordeling" for detaljer
const currentWorkBreakdown = formData.workBreakdown || [];
const workBreakdownTotal = currentWorkBreakdown
.filter(item => item.hours && parseFloat(item.hours) > 0)
.reduce((sum, item) => sum + parseFloat(item.hours), 0);
if (workBreakdownTotal > 0) {
sources.push(`Nuværende fordeling: ${Math.round(workBreakdownTotal)}h`);
totalEstimatedHours = Math.round(workBreakdownTotal);
guidedBreakdown = currentWorkBreakdown;
}
// 4. Erfaren tømrer logik: Intelligent fordeling baseret på tag type
if (totalEstimatedHours > 0) {
// Erfaren tømrer ved hvilke opgaver der tager tid på tag projekter
const distributionLogic = {
'Forberedelse og opstilling': 0.12, // 12% - vigtigt for sikkerhed på tag
'Nedrivning af eksisterende': 0.25, // 25% - ofte underestimeret, gamle materialer
'Montering af nye lægter': 0.20, // 20% - præcisionsarbejde
'Montering af tagplader': 0.28, // 28% - hovedopgaven, skæring, justering
'Rygning og afslutning': 0.10, // 10% - kritisk for tæthed
'Oprydning og bortkørsel': 0.05 // 5% - materialer fra tag
};
// Tilpas fordeling baseret på erfaring fra cases
if (tagExperienceSuggestions?.length > 0) {
const hasRygning = tagExperienceSuggestions.some(s =>
s.task_description.toLowerCase().includes('rygning'));
const hasNedrivning = tagExperienceSuggestions.some(s =>
s.task_description.toLowerCase().includes('udskift') ||
s.task_description.toLowerCase().includes('demonteres'));
if (hasRygning) {
distributionLogic['Rygning og afslutning'] = 0.15; // Mere tid til rygning
distributionLogic['Montering af tagplader'] = 0.23;
}
if (hasNedrivning) {
distributionLogic['Nedrivning af eksisterende'] = 0.30; // Mere nedrivning
distributionLogic['Montering af tagplader'] = 0.23;
}
}
// Generer erfaren arbejdsfordeling
guidedBreakdown = [
{
task: 'Forberedelse og opstilling',
hours: Math.round(totalEstimatedHours * distributionLogic['Forberedelse og opstilling']).toString(),
allocatedHours: '',
description: 'Stillads, afdækning, værktøj. Sikkerhed er afgørende på tag.'
},
{
task: 'Nedrivning af eksisterende',
hours: Math.round(totalEstimatedHours * distributionLogic['Nedrivning af eksisterende']).toString(),
allocatedHours: '',
description: 'Forsigtigt fjernelse. Gamle materialer kan være overraskende tunge.'
},
{
task: 'Montering af nye lægter',
hours: Math.round(totalEstimatedHours * distributionLogic['Montering af nye lægter']).toString(),
allocatedHours: '',
description: 'C18 taglægter. Præcision er vigtig for korrekt tagpladepasset.'
},
{
task: 'Montering af tagplader',
hours: Math.round(totalEstimatedHours * distributionLogic['Montering af tagplader']).toString(),
allocatedHours: '',
description: 'Hovedopgaven. Skæring, justering og tætning af skruer.'
},
{
task: 'Rygning og afslutning',
hours: Math.round(totalEstimatedHours * distributionLogic['Rygning og afslutning']).toString(),
allocatedHours: '',
description: 'Ventileret rygning og finish. Kritisk for tæthed.'
},
{
task: 'Oprydning og bortkørsel',
hours: Math.round(totalEstimatedHours * distributionLogic['Oprydning og bortkørsel']).toString(),
allocatedHours: '',
description: 'Rengøring og bortkørsel. Tagmaterialer er ofte tunge.'
}
];
// Erfaren tømrer noter om særlige forhold
if (geometry?.total_area > 150) {
specialConditions += 'Stort tagprojekt - overvej ekstra mand til materialeløft. ';
}
if (geometry?.roof_type?.includes('eternit')) {
specialConditions += 'VIGTIGT: Eternit håndtering kræver særlig sikkerhed og bortskaffelse. ';
}
if (tagExperienceSuggestions?.some(s => s.task_description.includes('Velux'))) {
specialConditions += 'Velux inddækning kræver præcision og tætningsdetaljer. ';
}
}
// 5. Fallback til standard erfaren estimat
if (totalEstimatedHours === 0) {
totalEstimatedHours = 16; // Erfaren standard for mindre tagarbejde
sources.push('Erfaren standard: 16h');
notes = 'Standard estimat baseret på erfaring. Tilpas efter projektdetaljer.';
}
// Opdater alle felter som en erfaren tømrer ville
setFormData(prev => ({
...prev,
totalWorkHours: totalEstimatedHours.toString(),
carpenterCount: carpenterCount,
workBreakdown: guidedBreakdown,
specialConditions: specialConditions,
notes: notes + (prev.notes ? `\n\nEksisterende noter:\n${prev.notes}` : '')
}));
notify.info(
`⏱️ Total timer: ${totalEstimatedHours}\n👷 Tømrere: ${carpenterCount}\n💰 Estimeret pris: ${(totalEstimatedHours * (formData.hourlyRate || 580)).toLocaleString()} kr\n\n📊 Baseret på:\n${sources.map(s => `• ${s}`).join('\n')}\n\n📝 Arbejdsfordelingen er udfyldt baseret på erfaring fra lignende tag projekter.`,
'🏗️ AI Guided Timer - Erfaren Tømrer Estimat'
);
} catch (error) {
console.error('Error with AI guided estimate:', error);
setError('Fejl ved AI guided estimering: ' + error.message);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.totalWorkHours) {
setError('Total arbejdstimer er påkrævet');
return;
}
setLoading(true);
setError('');
try {
// Convert workBreakdown to laborEntries format that backend expects
const laborEntries = formData.workBreakdown
.filter(item => item.hours && parseInt(item.hours) > 0)
.map(item => ({
taskDescription: item.task,
estimatedHours: parseInt(item.hours),
allocatedHours: parseInt(item.allocatedHours) || 0,
hourlyRate: formData.hourlyRate,
notes: item.description || ''
}));
const laborData = {
laborEntries: laborEntries,
carpenterCount: parseInt(formData.carpenterCount),
specialConditions: formData.specialConditions,
totalAllocatedHours: parseInt(formData.totalAllocatedHours) || 0
};
console.log('📦 Sending labor data:', laborData);
const response = await fetch(`${apiBaseUrl}/api/customer-projects/${project.id}/labor`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(laborData)
});
console.log('📡 Response status:', response.status);
const data = await response.json();
console.log('📥 Response data:', data);
if (data.success) {
console.log('✅ Labor saved successfully!');
// Call onComplete with the labor data that has the correct structure
onComplete(data.labor);
} else {
console.log('❌ Server returned error:', data.error);
setError(data.error || 'Fejl ved gemning af arbejdstimer');
}
} catch (error) {
console.error('💥 Network/JavaScript error:', error);
let errorMessage = 'Netværksfejl ved gemning af arbejdstimer';
if (error.name === 'TypeError' && error.message.includes('fetch')) {
errorMessage = 'Kan ikke forbinde til serveren. Tjek din internetforbindelse.';
} else if (error.name === 'SyntaxError') {
errorMessage = 'Serveren returnerede ugyldige data. Prøv igen.';
} else if (error.message) {
errorMessage = `Fejl: ${error.message}`;
}
setError(errorMessage);
} finally {
setLoading(false);
}
};
return (
<div className="labor-input">
<div className="section-header">
<h3>⏱️ Arbejdstimer</h3>
<p>Registrer arbejdstimer og tømrere til projektet</p>
</div>
{/* AI Guided Timer Button */}
<div className="ai-guided-section">
<button
type="button"
className="ai-guided-btn"
onClick={getAIGuidedTimeEstimate}
disabled={loading}
title="Lad AI udfylde hele siden som en erfaren tømrer baseret på tilgængelig historisk data"
>
🏗️ AI Guided Timer
</button>
<p className="ai-guided-description">
Lad AI analysere alle tilgængelige data og udfylde siden som en erfaren tømrer ville gøre det
</p>
</div>
{/* Historical Data Section */}
{historicalData && historicalData.historicalProjects && historicalData.historicalProjects.length > 0 && showHistorical && (
<div className="historical-section">
<div className="historical-header">
<h4>📊 Lignende Projekter - Arbejdstimer</h4>
<button
type="button"
className="hide-historical-btn"
onClick={() => setShowHistorical(false)}
>
✕ Skjul
</button>
</div>
<div className="historical-summary">
<div className="avg-stats">
<span>⏱️ Gennemsnit timer: {historicalData.averages?.hours || 0}</span>
<span>👷 Gennemsnit arbejdspris: {historicalData.averages?.laborCost || 0} kr</span>
<span>⭐ Tilfredshed: {historicalData.averages?.satisfaction || 0}/5</span>
</div>
</div>
<div className="historical-projects">
{historicalData.historicalProjects && historicalData.historicalProjects.slice(0, 3).map((project, index) => (
<div key={index} className="historical-project">
<div className="project-info">
<strong>{project.project_name}</strong>
<p>{project.task_name} - {project.roof_area}m² {project.roof_type}</p>
<div className="project-details">
<span>⏱️ {project.total_hours}t</span>
<span>👷 {project.total_labor_cost} kr</span>
<span>📅 {new Date(project.completion_date).toLocaleDateString('da-DK')}</span>
</div>
</div>
<button
type="button"
className="apply-historical-btn"
onClick={() => applyHistoricalData(project)}
>
📋 Anvend data
</button>
</div>
))}
</div>
</div>
)}
{/* Tag Experience Suggestions */}
{tagExperienceSuggestions.length > 0 && (
<div className="tag-experience-section">
<div className="section-header">
<h3>🏷️ Erfaring fra tidligere tag-opgaver</h3>
<button
type="button"
className="toggle-btn"
onClick={() => setShowTagExperience(!showTagExperience)}
>
{showTagExperience ? 'Skjul' : 'Vis'}
</button>
</div>
{showTagExperience && (
<div className="tag-suggestions">
<p className="suggestions-info">
💡 Klik på en opgave for at tilføje den til din arbejdsfordeling
</p>
<div className="suggestions-grid">
{tagExperienceSuggestions.slice(0, 6).map((suggestion, index) => (
<div key={index} className="suggestion-card">
<div className="suggestion-info">
<h4>{suggestion.task_description}</h4>
<div className="suggestion-stats">
<span>⏱️ {suggestion.estimated_hours}t</span>
<span>💰 {suggestion.hourly_rate} kr/t</span>
<span>📊 {suggestion.total_cost} kr total</span>
</div>
{suggestion.case_number && (
<div className="suggestion-source">
Sag: {suggestion.case_number}
</div>
)}
</div>
<button
type="button"
className="add-suggestion-btn"
onClick={() => addTagExperienceToWorkBreakdown(suggestion)}
title="Tilføj til arbejdsfordeling"
>
➕ Tilføj
</button>
</div>
))}
</div>
</div>
)}
</div>
)}
<form onSubmit={handleSubmit} className="labor-form">
{/* Overview Section */}
<div className="labor-overview">
<div className="overview-grid">
<div className="overview-item">
<label>Antal tømrere</label>
<input
type="number"
name="carpenterCount"
value={formData.carpenterCount}
onChange={handleInputChange}
min="1"
max="6"
required
/>
</div>
<div className="overview-item">
<label>Total arbejdstimer (estimeret)</label>
<div className="input-with-ai">
<input
type="number"
name="totalWorkHours"
value={formData.totalWorkHours}
onChange={handleInputChange}
min="1"
required
/>
<button
type="button"
className="ai-estimate-btn"
onClick={getAIWorkHourEstimate}
disabled={loading}
title="Få AI forslag til timer baseret på historiske data fra Lignende Projekter, Tag Erfaring og Arbejdsfordeling"
>
🤖 AI Forslag
</button>
</div>
</div>
<div className="overview-item">
<label>Total allokerede timer</label>
<input
type="number"
name="totalAllocatedHours"
value={formData.totalAllocatedHours}
onChange={handleInputChange}
min="0"
placeholder="Faktisk tildelte timer"
/>
</div>
<div className="overview-item">
<label>Timepris (kr)</label>
<input
type="number"
name="hourlyRate"
value={formData.hourlyRate}
onChange={handleInputChange}
min="500"
max="700"
required
className="fixed-rate"
/>
<small>Fast rate: 580 kr/time</small>
</div>
</div>
{/* Calculated Values */}
<div className="calculations">
<div className="calc-item">
<span className="calc-label">Estimeret varighed:</span>
<span className="calc-value">{calculateDurationDays()} arbejdsdage</span>
</div>
<div className="calc-item">
<span className="calc-label">Total arbejdsløn:</span>
<span className="calc-value">{calculateTotalCost().toLocaleString()} kr</span>
</div>
</div>
</div>
{/* Work Breakdown Section */}
<div className="work-breakdown">
<h4>📋 Arbejdsfordeling</h4>
<div className="breakdown-list">
{formData.workBreakdown.map((item, index) => (
<div key={index} className="breakdown-item">
<div className="breakdown-header">
<div className="task-title-section">
<input
type="text"
value={item.task}
onChange={(e) => handleBreakdownChange(index, 'task', e.target.value)}
placeholder="Arbejdsopgave navn..."
className="task-input"
/>
<button
type="button"
onClick={() => removeTask(index)}
className="remove-task-btn"
title="Fjern opgave"
>
🗑️
</button>
</div>
<div className="hours-inputs">
<div className="hour-input-group">
<label>Estimeret</label>
<input
type="number"
value={item.hours}
onChange={(e) => handleBreakdownChange(index, 'hours', e.target.value)}
placeholder="Timer"
min="0"
className="hours-input"
/>
</div>
<div className="hour-input-group">
<label>Allokeret</label>
<input
type="number"
value={item.allocatedHours}
onChange={(e) => handleBreakdownChange(index, 'allocatedHours', e.target.value)}
placeholder="Faktisk"
min="0"
className="hours-input allocated"
/>
</div>
</div>
</div>
<div className="breakdown-description">
<textarea
value={item.description}
onChange={(e) => handleBreakdownChange(index, 'description', e.target.value)}
placeholder="Beskrivelse af arbejdet..."
className="description-input"
rows="3"
/>
</div>
</div>
))}
</div>
{/* Add Task Buttons */}
<div className="add-task-section">
<button
type="button"
onClick={addCustomTask}
className="add-task-btn"
>
➕ Tilføj ny arbejdsopgave
</button>
<button
type="button"
onClick={() => {
if (window.confirm('Er du sikker på at du vil rydde alle opgaver og starte forfra?')) {
setFormData(prev => ({
...prev,
workBreakdown: [{
task: 'Ny arbejdsopgave',
hours: '',
allocatedHours: '',
description: 'Beskriv arbejdet her...'
}],
totalWorkHours: 0
}));
}
}}
className="clear-all-btn"
>
🗑️ Ryd alle opgaver
</button>
</div>
<div className="breakdown-totals">
<div className="total-row">
<strong>Total estimerede timer: {formData.workBreakdown.reduce((sum, item) => sum + (parseInt(item.hours) || 0), 0)} timer</strong>
</div>
<div className="total-row allocated">
<strong>Total allokerede timer: {formData.workBreakdown.reduce((sum, item) => sum + (parseInt(item.allocatedHours) || 0), 0)} timer</strong>
</div>
</div>
</div>
{/* Special Conditions */}
<div className="form-group">
<label htmlFor="specialConditions">Særlige forhold</label>
<textarea
id="specialConditions"
name="specialConditions"
value={formData.specialConditions}
onChange={handleInputChange}
placeholder="Beskrivelse af særlige arbejdsforhold, begrænsninger, tidskrav..."
rows="3"
/>
</div>
{/* Notes */}
<div className="form-group">
<label htmlFor="notes">Noter</label>
<textarea
id="notes"
name="notes"
value={formData.notes}
onChange={handleInputChange}
placeholder="Yderligere noter om arbejdet..."
rows="2"
/>
</div>
{error && (
<div className="error-message">
⚠️ {error}
</div>
)}
<button
type="submit"
className="submit-btn"
disabled={loading}
>
{loading ? '⏳ Gemmer...' : '✅ Gem Timer & Fortsæt'}
</button>
</form>
</div>
);
};
export default LaborInput;