- 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
1045 lines
41 KiB
JavaScript
1045 lines
41 KiB
JavaScript
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;
|